vimp-engine 0.38.3 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/client/InputListener.js +3 -0
  3. package/src/client/SoundManager.js +13 -0
  4. package/src/client/components/controller/Controls.js +10 -0
  5. package/src/client/components/controller/Voice.js +31 -0
  6. package/src/client/components/model/Controls.js +64 -0
  7. package/src/client/components/model/Voice.js +96 -0
  8. package/src/client/components/model/Vote.js +2 -1
  9. package/src/client/components/view/Voice.js +172 -0
  10. package/src/client/lib/voiceState.js +78 -0
  11. package/src/client/main.js +117 -0
  12. package/src/client/network/HostConnectionManager.js +46 -5
  13. package/src/client/network/HostController.js +43 -0
  14. package/src/client/network/LoopbackTransport.js +14 -0
  15. package/src/client/network/SessionSupervisor.js +24 -0
  16. package/src/client/network/WebRtcManager.js +45 -3
  17. package/src/client/style.css +146 -3
  18. package/src/client/voice/JitterBuffer.js +137 -0
  19. package/src/client/voice/MicCapture.js +307 -0
  20. package/src/client/voice/PttMachine.js +369 -0
  21. package/src/client/voice/VoiceClient.js +336 -0
  22. package/src/client/voice/VoiceCues.js +295 -0
  23. package/src/client/voice/VoiceEncoder.js +174 -0
  24. package/src/client/voice/VoicePlayback.js +558 -0
  25. package/src/client/voice/audioData.js +35 -0
  26. package/src/client/voice/captureProcessor.worklet.js +136 -0
  27. package/src/client/voice/opusConfig.js +49 -0
  28. package/src/client/voice/voiceOutput.js +72 -0
  29. package/src/client/voice/voiceSupport.js +71 -0
  30. package/src/client/voice/voiceTones.js +90 -0
  31. package/src/config/clientServices.js +2 -0
  32. package/src/config/voiceDefaults.js +44 -0
  33. package/src/config/voiceProtocol.js +80 -0
  34. package/src/config/voteKeys.js +4 -0
  35. package/src/config/wsports.js +12 -0
  36. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  37. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  38. package/src/devtools/contract/rules/index.js +4 -0
  39. package/src/host/HostGame.js +58 -0
  40. package/src/host/PortMachine.js +19 -3
  41. package/src/host/host.worker.js +7 -0
  42. package/src/host/meta/SocketManager.js +6 -0
  43. package/src/host/meta/modules/voice/Voice.js +430 -0
  44. package/src/host/meta/modules/voice/channels.js +58 -0
  45. package/src/host/meta/modules/voice/index.js +1 -0
  46. package/src/lib/VoiceRelay.js +200 -0
  47. package/src/lib/buildClientConfig.js +7 -0
  48. package/src/lib/capabilities.js +4 -0
  49. package/src/lib/createHostRuntime.js +8 -0
  50. package/src/lib/gameConfigView.js +11 -1
  51. package/src/lib/voiceConfig.js +136 -0
  52. package/src/lib/voiceMessages.js +172 -0
  53. package/src/lib/voicePacket.js +142 -0
@@ -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';
@@ -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,
@@ -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,6 +13,7 @@ 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';
@@ -250,6 +252,20 @@ export default class HostGame {
250
252
 
251
253
  this._socketManager = socketManager;
252
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
+
253
269
  this._networkSendRate = data.timers.networkSendRate;
254
270
  this._snapshotManager = new SnapshotThrottle(this._networkSendRate);
255
271
 
@@ -363,6 +379,9 @@ export default class HostGame {
363
379
  this._restoreMode = null; // 'midRound' | 'soft'
364
380
  this._restoredTimers = null;
365
381
  this._frozen = false;
382
+ // эстафета Worker'ов прошла границу раунда: игра стоит до terminate()
383
+ // этого Worker'а или resumeAfterHandoff()
384
+ this._handoffPaused = false;
366
385
  // точка снята на замороженном хосте: pack_body осушил тело кадра вне
367
386
  // цикла, и его события (удаления, взрывы) никому не ушли
368
387
  this._drainedWhileFrozen = false;
@@ -608,6 +627,11 @@ export default class HostGame {
608
627
  }
609
628
  });
610
629
 
630
+ // эфиры: таймауты, отзыв, слушатели — с частотой снапшотов; после цикла
631
+ // рассылки, потому что getCamera в нём исправляет watchedGameId
632
+ // наблюдателей
633
+ this._voice.tick();
634
+
611
635
  // граница кадра: pack_body опустошил накопители снапшота — единственный
612
636
  // момент, когда дамп ядра корректен
613
637
  this._maybeCheckpoint();
@@ -815,6 +839,7 @@ export default class HostGame {
815
839
  })),
816
840
  activeList: this._participants.getActiveList(),
817
841
  },
842
+ voice: this._voice.debugState(),
818
843
  core: this._game.debugJson(),
819
844
  };
820
845
  }
@@ -840,6 +865,8 @@ export default class HostGame {
840
865
  */
841
866
  async destroy() {
842
867
  this._isDestroying = true;
868
+ // комната закрывается, сообщать некому
869
+ this._voice.reset({ notify: false });
843
870
 
844
871
  if (this._resumeWait) {
845
872
  clock.clearTimeout(this._resumeWait.timer);
@@ -882,6 +909,11 @@ export default class HostGame {
882
909
  this._roundManager.requestHandoff(() => {
883
910
  this._timerManager.stopGameTimers();
884
911
  this._timerManager.stopIdleCheckTimer();
912
+ // без тиков эфир не закончился бы сам, а новый Worker начинает без
913
+ // эфиров: говорящие получают END, слушатели — STOP; до terminate()
914
+ // или resumeAfterHandoff() запрос получает UNAVAILABLE
915
+ this._handoffPaused = true;
916
+ this._voice.reset();
885
917
  this._flushBeforeHandoff().then(() => cb(this._collectHandoff()));
886
918
  });
887
919
  }
@@ -928,6 +960,7 @@ export default class HostGame {
928
960
  // отказ от эстафеты (новый Worker не поднялся): вернуть таймеры и
929
961
  // продолжить жить на старой версии кода
930
962
  resumeAfterHandoff() {
963
+ this._handoffPaused = false;
931
964
  this._roundManager.cancelHandoff();
932
965
  this._timerManager.startIdleCheckTimer();
933
966
  this._timerManager.resumeGameTimers(this._timerManager.getMapTimeLeft());
@@ -1072,6 +1105,15 @@ export default class HostGame {
1072
1105
  this._healthSink = typeof sink === 'function' ? sink : null;
1073
1106
  }
1074
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
+
1075
1117
  // RTT — только удалённые люди: хост-игрок на loopback связь хоста не мерит
1076
1118
  _onLoopStats(stats) {
1077
1119
  if (!this._healthSink) {
@@ -1543,6 +1585,9 @@ export default class HostGame {
1543
1585
 
1544
1586
  this._frozen = true;
1545
1587
  this._timerManager.pause();
1588
+ // без тиков эфир не закончился бы сам: говорящие получают END,
1589
+ // слушатели — STOP
1590
+ this._voice.reset();
1546
1591
  }
1547
1592
 
1548
1593
  // разморозка (передача сорвалась): отсчёты продолжаются с остатков.
@@ -1672,6 +1717,7 @@ export default class HostGame {
1672
1717
  this._stat.removeUser(gameId, teamId);
1673
1718
  this._chat.removeUser(gameId);
1674
1719
  this._vote.removeUser(gameId);
1720
+ this._voice.removeUser(gameId);
1675
1721
  this._panel.removeUser(gameId);
1676
1722
 
1677
1723
  // финальная синхронизация профиля перед уходом участника (Этап B4):
@@ -1702,6 +1748,18 @@ export default class HostGame {
1702
1748
  }
1703
1749
  }
1704
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
+
1705
1763
  // обновляет команды (формат wire: 'seq:action:name', указатель —
1706
1764
  // 'seq:aim:x:y:flags')
1707
1765
  updateKeys(gameId, keyStr) {
@@ -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
- const PORT_COUNT = 12;
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
- if (msg && state.enabled[msg[0]]) {
248
- state.methods[msg[0]](msg[1]);
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
  }
@@ -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,11 @@ 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
+ );
154
161
 
155
162
  // сбой кода игры, пойманный хостом (чат-команда): в журнал клиентских
156
163
  // ошибок тем же сообщением, что и необработанный reject
@@ -41,6 +41,7 @@ export default class SocketManager {
41
41
  this._PORT_ACCOLADES_DATA = ports.ACCOLADES_DATA;
42
42
  this._PORT_SESSION_DATA = ports.SESSION_DATA;
43
43
  this._PORT_RESUME_RESULT = ports.RESUME_RESULT;
44
+ this._PORT_VOICE_DATA = ports.VOICE_DATA;
44
45
 
45
46
  this._soundCues = soundCues;
46
47
  this._initialVote = initialVote;
@@ -349,6 +350,11 @@ export default class SocketManager {
349
350
  this._send(socketId, this._PORT_ACCOLADES_DATA, data);
350
351
  }
351
352
 
353
+ // голосовая связь: управление эфиром (lib/voiceMessages.js), персонально
354
+ sendVoice(socketId, data) {
355
+ this._send(socketId, this._PORT_VOICE_DATA, data);
356
+ }
357
+
352
358
  /**
353
359
  * Отправка сообщения чата.
354
360
  * @param {string} socketId