vimp-engine 0.38.3 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +2 -2
  2. package/bin/vimp-sim.js +2 -2
  3. package/core/Cargo.toml +2 -2
  4. package/package.json +3 -4
  5. package/src/client/InputListener.js +3 -0
  6. package/src/client/SoundManager.js +13 -0
  7. package/src/client/boot.js +11 -87
  8. package/src/client/components/controller/Controls.js +10 -0
  9. package/src/client/components/controller/Voice.js +31 -0
  10. package/src/client/components/model/Controls.js +64 -0
  11. package/src/client/components/model/RoomMenu.js +1 -1
  12. package/src/client/components/model/Voice.js +96 -0
  13. package/src/client/components/model/Vote.js +2 -1
  14. package/src/client/components/view/Auth.js +3 -3
  15. package/src/client/components/view/Voice.js +172 -0
  16. package/src/client/lib/formBuilder.js +7 -6
  17. package/src/client/lib/hostVoteCommand.js +2 -2
  18. package/src/client/lib/pickActiveGame.js +3 -12
  19. package/src/client/lib/retryOnNetworkError.js +0 -9
  20. package/src/client/lib/voiceState.js +78 -0
  21. package/src/client/main.js +160 -90
  22. package/src/client/network/HostConnectionManager.js +46 -5
  23. package/src/client/network/HostController.js +43 -0
  24. package/src/client/network/LoopbackTransport.js +14 -0
  25. package/src/client/network/SessionSupervisor.js +38 -14
  26. package/src/client/network/WebRtcManager.js +46 -5
  27. package/src/client/network/policyClose.js +10 -77
  28. package/src/client/style.css +150 -9
  29. package/src/client/views/gameShell.js +2 -4
  30. package/src/client/views/includes/auth.pug +1 -4
  31. package/src/client/views/includes/lobby.pug +3 -6
  32. package/src/client/voice/JitterBuffer.js +137 -0
  33. package/src/client/voice/MicCapture.js +307 -0
  34. package/src/client/voice/PttMachine.js +369 -0
  35. package/src/client/voice/VoiceClient.js +336 -0
  36. package/src/client/voice/VoiceCues.js +295 -0
  37. package/src/client/voice/VoiceEncoder.js +174 -0
  38. package/src/client/voice/VoicePlayback.js +558 -0
  39. package/src/client/voice/audioData.js +35 -0
  40. package/src/client/voice/captureProcessor.worklet.js +136 -0
  41. package/src/client/voice/opusConfig.js +49 -0
  42. package/src/client/voice/voiceOutput.js +72 -0
  43. package/src/client/voice/voiceSupport.js +71 -0
  44. package/src/client/voice/voiceTones.js +90 -0
  45. package/src/config/clientServices.js +2 -0
  46. package/src/config/closeCodes.js +7 -9
  47. package/src/config/gameCodes.js +1 -1
  48. package/src/config/hostDefaults.js +2 -2
  49. package/src/config/lobby.js +3 -4
  50. package/src/config/voiceDefaults.js +44 -0
  51. package/src/config/voiceProtocol.js +80 -0
  52. package/src/config/voteKeys.js +4 -0
  53. package/src/config/wsports.js +13 -1
  54. package/src/devtools/ScenarioRunner.js +1 -1
  55. package/src/devtools/builtinScenario.js +2 -2
  56. package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
  57. package/src/devtools/contract/rules/a4-vite-config.js +1 -1
  58. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  59. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  60. package/src/devtools/contract/rules/c4-component-dependencies.js +1 -1
  61. package/src/devtools/contract/rules/index.js +4 -0
  62. package/src/devtools/invariants.js +1 -1
  63. package/src/devtools/pluginLoader.js +2 -2
  64. package/src/host/HostGame.js +62 -4
  65. package/src/host/PortMachine.js +23 -24
  66. package/src/host/commandFailure.js +2 -2
  67. package/src/host/host.worker.js +7 -0
  68. package/src/host/identity.js +3 -3
  69. package/src/host/meta/SocketManager.js +6 -0
  70. package/src/host/meta/core/CommandProcessor.js +2 -2
  71. package/src/host/meta/modules/Accolades.js +1 -1
  72. package/src/host/meta/modules/voice/Voice.js +430 -0
  73. package/src/host/meta/modules/voice/channels.js +58 -0
  74. package/src/host/meta/modules/voice/index.js +1 -0
  75. package/src/lib/VoiceRelay.js +200 -0
  76. package/src/lib/buildClientConfig.js +7 -0
  77. package/src/lib/capabilities.js +4 -0
  78. package/src/lib/createHostRuntime.js +8 -0
  79. package/src/lib/gameConfigView.js +11 -1
  80. package/src/lib/loadGamePackage.js +7 -9
  81. package/src/lib/offlinePlayerData.js +1 -1
  82. package/src/lib/validators.js +2 -2
  83. package/src/lib/voiceConfig.js +136 -0
  84. package/src/lib/voiceMessages.js +172 -0
  85. package/src/lib/voicePacket.js +142 -0
  86. package/src/standalone/index.js +3 -2
  87. package/src/client/lib/footerLink.js +0 -30
  88. package/src/client/network/WebSocketTransport.js +0 -78
@@ -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
- export const KNOWN_GAME_CONFIG_PATHS = Object.keys(FIELDS);
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-ов нет — оба Node-контура (headless-runner devtools/pluginLoader.js
10
- // и dedicated-сервер) читают манифест с диска и импортируют половины плагина
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.vimp.lgick.dev/en/packaging). Для боевой игры она обязательна: без ядра
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); dedicated-сервер всегда даёт каталог
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-прогона; dedicated-сервер её не касается вовсе (в браузер она
62
- // едет статикой, и pixi.js там резолвится import map'ом). Грузить её
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
- // (dedicated, vimp-sim, inline host) читает авторитетный манифест, и его
161
+ // (vimp-sim, inline host) читает авторитетный манифест, и его
164
162
  // совместимость уже проверена выше (checkPluginCompatibility): рассинхрон с
165
163
  // половинами ничего здесь не ломает. Бросить значило бы отвергнуть пакет,
166
164
  // который грузился раньше, — прямое нарушение И4 плана
@@ -1,5 +1,5 @@
1
1
  // Профиль участника в контуре без мастера и auth-сервиса (headless-прогон,
2
- // standalone SDK, dedicated-сервер). Это не заглушка ради тишины: пустой
2
+ // standalone SDK). Это не заглушка ради тишины: пустой
3
3
  // профиль и есть корректное состояние такого матча, а настоящий fetch по
4
4
  // относительному URL вне браузерной вкладки просто не разрешается.
5
5
 
@@ -24,8 +24,8 @@ export const isValidNick = nick =>
24
24
  // потолок длины поля формы, когда maxlength в дескрипторе не объявлен.
25
25
  // regExp игры исполняется на хосте против строки, которую прислал клиент, и
26
26
  // катастрофический паттерн вроде "(a+)+b" превращает три десятка символов в
27
- // минуты заблокированного event loop — а хост это либо Worker с авторитетным
28
- // матчем (замирает комната целиком), либо процесс dedicated. 256 — с запасом
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
+ }
@@ -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.vimp.lgick.dev/en/standalone). По
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`, docs.vimp.lgick.dev/en/plugin-api)
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
- }