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.
Files changed (99) hide show
  1. package/core/Cargo.toml +1 -1
  2. package/package.json +3 -1
  3. package/src/client/InputListener.js +3 -0
  4. package/src/client/SoundManager.js +13 -0
  5. package/src/client/boot.js +30 -7
  6. package/src/client/components/controller/ClientReports.js +1 -1
  7. package/src/client/components/controller/Controls.js +10 -0
  8. package/src/client/components/controller/Games.js +1 -1
  9. package/src/client/components/controller/Voice.js +31 -0
  10. package/src/client/components/model/ClientReports.js +1 -1
  11. package/src/client/components/model/Controls.js +64 -0
  12. package/src/client/components/model/Games.js +24 -0
  13. package/src/client/components/model/Voice.js +96 -0
  14. package/src/client/components/model/Vote.js +2 -1
  15. package/src/client/components/view/ClientReports.js +1 -1
  16. package/src/client/components/view/Games.js +19 -1
  17. package/src/client/components/view/Lobby.js +1 -7
  18. package/src/client/components/view/Voice.js +172 -0
  19. package/src/client/debug.js +1 -1
  20. package/src/client/lib/authApi.js +1 -1
  21. package/src/client/lib/diagnostics.js +19 -4
  22. package/src/client/lib/engineVersion.js +0 -6
  23. package/src/client/lib/footerLink.js +15 -14
  24. package/src/client/lib/retryOnNetworkError.js +60 -0
  25. package/src/client/lib/voiceState.js +78 -0
  26. package/src/client/main.js +152 -18
  27. package/src/client/network/HiddenHostHealthLog.js +40 -3
  28. package/src/client/network/HostConnectionManager.js +46 -5
  29. package/src/client/network/HostController.js +47 -4
  30. package/src/client/network/HostHealthPolicy.js +41 -0
  31. package/src/client/network/InlineHostBridge.js +8 -1
  32. package/src/client/network/LoopbackTransport.js +14 -0
  33. package/src/client/network/SessionSupervisor.js +24 -0
  34. package/src/client/network/WebRtcManager.js +45 -3
  35. package/src/client/session/HandoffFlow.js +5 -0
  36. package/src/client/session/HostRole.js +5 -2
  37. package/src/client/style.css +151 -9
  38. package/src/client/views/gameShell.js +3 -3
  39. package/src/client/views/includes/auth.pug +4 -4
  40. package/src/client/views/includes/lobby.pug +4 -4
  41. package/src/client/views/includes/reports.pug +1 -1
  42. package/src/client/voice/JitterBuffer.js +137 -0
  43. package/src/client/voice/MicCapture.js +307 -0
  44. package/src/client/voice/PttMachine.js +369 -0
  45. package/src/client/voice/VoiceClient.js +336 -0
  46. package/src/client/voice/VoiceCues.js +295 -0
  47. package/src/client/voice/VoiceEncoder.js +174 -0
  48. package/src/client/voice/VoicePlayback.js +558 -0
  49. package/src/client/voice/audioData.js +35 -0
  50. package/src/client/voice/captureProcessor.worklet.js +136 -0
  51. package/src/client/voice/opusConfig.js +49 -0
  52. package/src/client/voice/voiceOutput.js +72 -0
  53. package/src/client/voice/voiceSupport.js +71 -0
  54. package/src/client/voice/voiceTones.js +90 -0
  55. package/src/config/authClient.js +3 -3
  56. package/src/config/clientServices.js +2 -0
  57. package/src/config/lobby.js +4 -5
  58. package/src/config/opcodes.js +2 -1
  59. package/src/config/voiceDefaults.js +44 -0
  60. package/src/config/voiceProtocol.js +80 -0
  61. package/src/config/voteKeys.js +4 -0
  62. package/src/config/wsports.js +12 -0
  63. package/src/devtools/contract/loadContext.js +2 -2
  64. package/src/devtools/contract/rules/a7-package-repository.js +7 -8
  65. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  66. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  67. package/src/devtools/contract/rules/index.js +5 -1
  68. package/src/devtools/invariants.js +1 -1
  69. package/src/host/DebugRecorder.js +1 -1
  70. package/src/host/HostGame.js +93 -15
  71. package/src/host/PortMachine.js +19 -3
  72. package/src/host/commandFailure.js +140 -0
  73. package/src/host/host.worker.js +15 -2
  74. package/src/host/meta/SocketManager.js +8 -2
  75. package/src/host/meta/core/CommandProcessor.js +37 -7
  76. package/src/host/meta/modules/voice/Voice.js +430 -0
  77. package/src/host/meta/modules/voice/channels.js +58 -0
  78. package/src/host/meta/modules/voice/index.js +1 -0
  79. package/src/lib/VoiceRelay.js +200 -0
  80. package/src/lib/applyRoomOverrides.js +1 -1
  81. package/src/lib/buildClientConfig.js +7 -0
  82. package/src/lib/capabilities.js +5 -1
  83. package/src/lib/checkpointCodec.js +1 -2
  84. package/src/lib/coreConfig.js +1 -1
  85. package/src/lib/createHostRuntime.js +9 -1
  86. package/src/lib/gameConfigView.js +11 -1
  87. package/src/lib/gameId.js +1 -1
  88. package/src/lib/gamePlugin.js +6 -2
  89. package/src/lib/jwt.js +1 -1
  90. package/src/lib/packageLink.js +18 -61
  91. package/src/lib/validators.js +3 -3
  92. package/src/lib/voiceConfig.js +136 -0
  93. package/src/lib/voiceMessages.js +172 -0
  94. package/src/lib/voicePacket.js +142 -0
  95. package/src/standalone/index.js +1 -1
  96. package/tests/fixtures/generations/gen-api3/README.md +2 -2
  97. package/tests/fixtures/generations/gen-api4/README.md +2 -2
  98. package/src/devtools/surface/abiParse.js +0 -175
  99. package/src/devtools/surface/collect.js +0 -413
@@ -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;
@@ -311,8 +312,8 @@ export default class SocketManager {
311
312
  }
312
313
 
313
314
  /**
314
- * Отправка отладочного лога хоста в консоль клиента (порт CONSOLE,
315
- * этап 6 плана plan/done/ai-debug). Worker изолирован от DevTools вкладки —
315
+ * Отправка отладочного лога хоста в консоль клиента (порт CONSOLE).
316
+ * Worker изолирован от DevTools вкладки —
316
317
  * без этого канала его события в браузере не видны вовсе.
317
318
  * @param {string} socketId
318
319
  * @param {*} data
@@ -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
@@ -4,11 +4,19 @@
4
4
  // разных играх может делать разное или отсутствовать вовсе; незнакомая
5
5
  // команда — COMMANDS_NOT_FOUND.
6
6
  //
7
- // Всё, что раньше разбирал движковый switch, доступно играм через контекст:
8
- // roundManager (смена ника, новый раунд, текущая карта), timerManager
9
- // (остаток времени карты), playerDataSync (ранг), chat, isDevMode.
7
+ // Контекст команд: roundManager (новый раунд, текущая карта, смена карты),
8
+ // timerManager (остаток времени карты), playerDataSync (ранг), chat,
9
+ // isDevMode и остальные поля deps.
10
+ //
11
+ // Обработчик — код игры и вызывается под защитой: его сбой (исключение или
12
+ // отклонённый промис) уходит в onError, матч продолжается.
10
13
  class CommandProcessor {
11
- constructor(deps) {
14
+ /**
15
+ * @param {Object} deps - контекст игровых команд.
16
+ * @param {Object} [options]
17
+ * @param {Function} [options.onError] - (command, error) сбой обработчика.
18
+ */
19
+ constructor(deps, { onError = null } = {}) {
12
20
  this._chat = deps.chat;
13
21
 
14
22
  // контекст игровых команд (participants, chat, scripted, roundManager,
@@ -17,6 +25,7 @@ class CommandProcessor {
17
25
  this._ctx = deps;
18
26
 
19
27
  this._commands = new Map();
28
+ this._onError = onError;
20
29
  }
21
30
 
22
31
  /**
@@ -36,10 +45,31 @@ class CommandProcessor {
36
45
  const cmd = arr.shift();
37
46
  const handler = this._commands.get(cmd);
38
47
 
39
- if (handler) {
40
- handler(this._ctx, gameId, arr);
41
- } else {
48
+ if (!handler) {
42
49
  this._chat.pushSystemByUser(gameId, 'COMMANDS_NOT_FOUND');
50
+
51
+ return;
52
+ }
53
+
54
+ // в Worker'е сбой стал бы необработанным reject'ом, в dedicated-процессе —
55
+ // uncaughtException, то есть падением матча всех игроков
56
+ try {
57
+ const result = handler(this._ctx, gameId, arr);
58
+
59
+ if (result && typeof result.then === 'function') {
60
+ result.then(null, error => this._fail(cmd, error));
61
+ }
62
+ } catch (error) {
63
+ this._fail(cmd, error);
64
+ }
65
+ }
66
+
67
+ // сбой приёмника не должен породить новый сбой
68
+ _fail(command, error) {
69
+ try {
70
+ this._onError?.(command, error);
71
+ } catch {
72
+ // приёмник молчит о своих сбоях
43
73
  }
44
74
  }
45
75
  }
@@ -0,0 +1,430 @@
1
+ import clock from '../../../../lib/clock.js';
2
+ import {
3
+ denyMessage,
4
+ endMessage,
5
+ grantMessage,
6
+ startMessage,
7
+ stopMessage,
8
+ } from '../../../../lib/voiceMessages.js';
9
+ import {
10
+ VOICE_DENY,
11
+ VOICE_END,
12
+ VOICE_HOST_GRACE_MS,
13
+ VOICE_MAX_TALK_ID,
14
+ VOICE_REQUEST_MIN_INTERVAL_MS,
15
+ } from '../../../../config/voiceProtocol.js';
16
+ import { isVoiceSpeakerId } from '../../../../lib/voicePacket.js';
17
+ import { VOICE_DEFAULTS } from '../../../../config/voiceDefaults.js';
18
+ import { listenChannel, speakChannel } from './channels.js';
19
+
20
+ // Управление эфиром (floor control): кто говорит, кто слышит, таймауты,
21
+ // отзыв. Аудио сюда не попадает никогда — пакеты раздаёт ретранслятор
22
+ // транспортного уровня по маршрутам, которые модуль публикует через
23
+ // setRouteSink. Состояние эфемерно: в контрольные точки не пишется.
24
+ export default class Voice {
25
+ /**
26
+ * @param {Object} [config] - нормализованный gameConfig.voice; без него
27
+ * берутся VOICE_DEFAULTS (связь выключена).
28
+ * @param {Object} deps
29
+ * @param {Object} deps.participants - ParticipantManager.
30
+ * @param {Object} deps.socketManager - sendVoice(socketId, data).
31
+ * @param {Function} [deps.isAvailable] - () => boolean: хост может выдавать
32
+ * эфир (матч не стоит; причины паузы знает HostGame).
33
+ * @param {Function} [deps.now] - часы.
34
+ */
35
+ constructor(config, { participants, socketManager, isAvailable, now }) {
36
+ this._config = config ?? VOICE_DEFAULTS;
37
+ this._participants = participants;
38
+ this._socketManager = socketManager;
39
+ this._isAvailable = isAvailable ?? (() => true);
40
+ this._now = now ?? (() => clock.now());
41
+ this._routeSink = null;
42
+
43
+ // канал → эфир; gameId говорящего → эфир
44
+ this._talks = new Map();
45
+ this._speakerTalks = new Map();
46
+ this._cooldownUntil = new Map();
47
+ this._lastRequestAt = new Map();
48
+ this._nextTalkId = 1;
49
+ }
50
+
51
+ /**
52
+ * Приёмник маршрутов ретранслятора. Без приёмника модуль работает,
53
+ * маршруты не публикуются.
54
+ * @param {Function|null} sink - ({ speakerSocketId, route }) => void.
55
+ */
56
+ setRouteSink(sink) {
57
+ this._routeSink = typeof sink === 'function' ? sink : null;
58
+ }
59
+
60
+ get enabled() {
61
+ return this._config.enabled === true;
62
+ }
63
+
64
+ /**
65
+ * Запрос эфира. Порядок проверок фиксирован.
66
+ * @param {string} gameId
67
+ */
68
+ request(gameId) {
69
+ const participant = this._participants.get(gameId);
70
+
71
+ if (!participant?.isNetworked) {
72
+ return;
73
+ }
74
+
75
+ const now = this._now();
76
+
77
+ if (!this.enabled || !this._isAvailable()) {
78
+ this._sendTo(gameId, denyMessage(VOICE_DENY.UNAVAILABLE, 0));
79
+ return;
80
+ }
81
+
82
+ const wireId = Number(gameId);
83
+
84
+ // id на проводе — u8; при ≤ 64 участниках недостижимо
85
+ if (!isVoiceSpeakerId(wireId)) {
86
+ this._sendTo(gameId, denyMessage(VOICE_DENY.FORBIDDEN, 0));
87
+ return;
88
+ }
89
+
90
+ const lastRequestAt = this._lastRequestAt.get(gameId);
91
+
92
+ if (
93
+ lastRequestAt !== undefined &&
94
+ now - lastRequestAt < VOICE_REQUEST_MIN_INTERVAL_MS
95
+ ) {
96
+ this._sendTo(
97
+ gameId,
98
+ denyMessage(
99
+ VOICE_DENY.COOLDOWN,
100
+ VOICE_REQUEST_MIN_INTERVAL_MS - (now - lastRequestAt),
101
+ ),
102
+ );
103
+ return;
104
+ }
105
+
106
+ this._lastRequestAt.set(gameId, now);
107
+
108
+ const own = this._speakerTalks.get(gameId);
109
+
110
+ // повторный запрос во время своего эфира идемпотентен
111
+ if (own) {
112
+ this._sendTo(
113
+ gameId,
114
+ grantMessage(
115
+ own.talkId,
116
+ Math.max(0, this._config.maxDurationMs - (now - own.startedAt)),
117
+ ),
118
+ );
119
+ return;
120
+ }
121
+
122
+ const channel = speakChannel(participant, this._config);
123
+
124
+ if (!this._isReady(participant) || channel === null) {
125
+ this._sendTo(gameId, denyMessage(VOICE_DENY.FORBIDDEN, 0));
126
+ return;
127
+ }
128
+
129
+ const cooldownUntil = this._cooldownUntil.get(gameId) ?? 0;
130
+
131
+ if (cooldownUntil > now) {
132
+ this._sendTo(
133
+ gameId,
134
+ denyMessage(VOICE_DENY.COOLDOWN, cooldownUntil - now),
135
+ );
136
+ return;
137
+ }
138
+
139
+ if (this._talks.has(channel)) {
140
+ this._sendTo(gameId, denyMessage(VOICE_DENY.BUSY, 0));
141
+ return;
142
+ }
143
+
144
+ const talk = {
145
+ talkId: this._allocTalkId(),
146
+ speakerId: gameId,
147
+ wireId,
148
+ speakerSocketId: participant.socketId,
149
+ channel,
150
+ startedAt: now,
151
+ listeners: new Map(),
152
+ };
153
+
154
+ this._talks.set(channel, talk);
155
+ this._speakerTalks.set(gameId, talk);
156
+
157
+ // маршрут и START раньше GRANT: ретранслятор должен знать маршрут до
158
+ // первого пакета говорящего
159
+ this._refreshListeners(talk, this._bucketListeners().get(channel));
160
+ this._sendTo(gameId, grantMessage(talk.talkId, this._config.maxDurationMs));
161
+ }
162
+
163
+ /**
164
+ * Отпускание эфира; запоздавшее отпускание старого эфира игнорируется.
165
+ * @param {string} gameId
166
+ * @param {number} talkId
167
+ */
168
+ release(gameId, talkId) {
169
+ const talk = this._speakerTalks.get(gameId);
170
+
171
+ if (talk && talk.talkId === talkId) {
172
+ this._end(talk, VOICE_END.RELEASED);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Раз в снапшот-тик, после рассылки кадров: отзыв, таймаут, слушатели.
178
+ */
179
+ tick() {
180
+ if (this._talks.size === 0) {
181
+ return;
182
+ }
183
+
184
+ const now = this._now();
185
+ const alive = [];
186
+
187
+ for (const talk of [...this._talks.values()]) {
188
+ const speaker = this._participants.get(talk.speakerId);
189
+
190
+ if (
191
+ !this._isReady(speaker) ||
192
+ speakChannel(speaker, this._config) !== talk.channel ||
193
+ speaker.socketId !== talk.speakerSocketId
194
+ ) {
195
+ this._end(talk, VOICE_END.REVOKED);
196
+ } else if (
197
+ now - talk.startedAt >=
198
+ this._config.maxDurationMs + VOICE_HOST_GRACE_MS
199
+ ) {
200
+ this._end(talk, VOICE_END.TIMEOUT);
201
+ } else {
202
+ alive.push(talk);
203
+ }
204
+ }
205
+
206
+ if (alive.length === 0) {
207
+ return;
208
+ }
209
+
210
+ const buckets = this._bucketListeners();
211
+
212
+ for (const talk of alive) {
213
+ this._refreshListeners(talk, buckets.get(talk.channel));
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Участник снят: его эфир кончается без отправок ему, из слушателей он
219
+ * исчезает без уведомлений.
220
+ * @param {string} gameId
221
+ */
222
+ removeUser(gameId) {
223
+ const own = this._speakerTalks.get(gameId);
224
+
225
+ if (own) {
226
+ this._end(own, VOICE_END.REVOKED, { speaker: false });
227
+ }
228
+
229
+ for (const talk of this._talks.values()) {
230
+ if (talk.listeners.delete(gameId)) {
231
+ this._publishRoute(talk);
232
+ }
233
+ }
234
+
235
+ this._cooldownUntil.delete(gameId);
236
+ this._lastRequestAt.delete(gameId);
237
+ }
238
+
239
+ /**
240
+ * Все эфиры заканчиваются (REVOKED), кулдауны забываются.
241
+ * @param {Object} [options]
242
+ * @param {boolean} [options.notify=true] - слать END/STOP.
243
+ */
244
+ reset({ notify = true } = {}) {
245
+ for (const talk of [...this._talks.values()]) {
246
+ this._end(talk, VOICE_END.REVOKED, {
247
+ speaker: notify,
248
+ listeners: notify,
249
+ });
250
+ }
251
+
252
+ this._cooldownUntil.clear();
253
+ this._lastRequestAt.clear();
254
+ }
255
+
256
+ debugState() {
257
+ const now = this._now();
258
+
259
+ return {
260
+ talks: [...this._talks.values()].map(talk => ({
261
+ talkId: talk.talkId,
262
+ speakerId: talk.speakerId,
263
+ channel: talk.channel,
264
+ listeners: talk.listeners.size,
265
+ ageMs: now - talk.startedAt,
266
+ })),
267
+ };
268
+ }
269
+
270
+ _isReady(participant) {
271
+ return Boolean(
272
+ participant?.isNetworked &&
273
+ participant.isReady &&
274
+ participant.detachedAt === null,
275
+ );
276
+ }
277
+
278
+ // отправка по ТЕКУЩЕМУ socketId: неизвестный сокет SocketManager ругается
279
+ _sendTo(gameId, message) {
280
+ const participant = this._participants.get(gameId);
281
+
282
+ if (participant) {
283
+ this._socketManager.sendVoice(participant.socketId, message);
284
+ }
285
+ }
286
+
287
+ // 1..VOICE_MAX_TALK_ID по кругу, занятые активными эфирами пропускаются
288
+ // (эфиров не больше, чем каналов, свободный найдётся всегда)
289
+ _allocTalkId() {
290
+ const busy = new Set();
291
+
292
+ for (const talk of this._talks.values()) {
293
+ busy.add(talk.talkId);
294
+ }
295
+
296
+ for (;;) {
297
+ const talkId = this._nextTalkId;
298
+
299
+ this._nextTalkId = talkId === VOICE_MAX_TALK_ID ? 1 : talkId + 1;
300
+
301
+ if (!busy.has(talkId)) {
302
+ return talkId;
303
+ }
304
+ }
305
+ }
306
+
307
+ _end(talk, reason, { speaker = true, listeners = true } = {}) {
308
+ this._talks.delete(talk.channel);
309
+ this._speakerTalks.delete(talk.speakerId);
310
+
311
+ this._routeSink?.({
312
+ speakerSocketId: talk.speakerSocketId,
313
+ route: null,
314
+ });
315
+
316
+ if (listeners) {
317
+ for (const gameId of talk.listeners.keys()) {
318
+ if (gameId !== talk.speakerId) {
319
+ this._sendTo(gameId, stopMessage(talk.wireId, talk.talkId));
320
+ }
321
+ }
322
+ }
323
+
324
+ if (speaker) {
325
+ this._sendTo(talk.speakerId, stopMessage(talk.wireId, talk.talkId));
326
+ this._sendTo(talk.speakerId, endMessage(talk.talkId, reason));
327
+ }
328
+
329
+ this._cooldownUntil.set(
330
+ talk.speakerId,
331
+ this._now() + this._config.cooldownMs,
332
+ );
333
+ }
334
+
335
+ // канал → Map<gameId, socketId> слушателей; один проход на тик
336
+ _bucketListeners() {
337
+ const activeIds = new Set(this._participants.getActiveList());
338
+ const buckets = new Map();
339
+
340
+ for (const participant of this._participants.getNetworkedReady()) {
341
+ const channel = listenChannel(participant, {
342
+ scope: this._config.scope,
343
+ participants: this._participants,
344
+ activeIds,
345
+ });
346
+
347
+ if (channel === null) {
348
+ continue;
349
+ }
350
+
351
+ let bucket = buckets.get(channel);
352
+
353
+ if (!bucket) {
354
+ bucket = new Map();
355
+ buckets.set(channel, bucket);
356
+ }
357
+
358
+ bucket.set(participant.gameId, participant.socketId);
359
+ }
360
+
361
+ return buckets;
362
+ }
363
+
364
+ // дифф слушателей эфира: START добавленным, STOP ушедшим, новый маршрут.
365
+ // Переподключённому (socketId сменился без отсоединения) STOP на старый
366
+ // сокет не шлётся — получателя у него нет
367
+ _refreshListeners(talk, bucket) {
368
+ const desired = new Map(bucket ?? []);
369
+
370
+ desired.set(talk.speakerId, talk.speakerSocketId);
371
+
372
+ const added = [];
373
+ const gone = [];
374
+
375
+ for (const [gameId, socketId] of desired) {
376
+ if (talk.listeners.get(gameId) !== socketId) {
377
+ added.push([gameId, socketId]);
378
+ }
379
+ }
380
+
381
+ for (const gameId of talk.listeners.keys()) {
382
+ if (!desired.has(gameId)) {
383
+ gone.push(gameId);
384
+ }
385
+ }
386
+
387
+ if (added.length === 0 && gone.length === 0) {
388
+ return;
389
+ }
390
+
391
+ talk.listeners = desired;
392
+ this._publishRoute(talk);
393
+
394
+ for (const gameId of gone) {
395
+ this._sendTo(gameId, stopMessage(talk.wireId, talk.talkId));
396
+ }
397
+
398
+ const speaker = this._participants.get(talk.speakerId);
399
+ const start = startMessage({
400
+ speakerId: talk.wireId,
401
+ talkId: talk.talkId,
402
+ name: speaker.name,
403
+ teamId: speaker.teamId,
404
+ chatColor: speaker.chatColor ?? null,
405
+ });
406
+
407
+ for (const [, socketId] of added) {
408
+ this._socketManager.sendVoice(socketId, start);
409
+ }
410
+ }
411
+
412
+ _publishRoute(talk) {
413
+ const listenerSocketIds = [];
414
+
415
+ for (const [gameId, socketId] of talk.listeners) {
416
+ if (gameId !== talk.speakerId) {
417
+ listenerSocketIds.push(socketId);
418
+ }
419
+ }
420
+
421
+ this._routeSink?.({
422
+ speakerSocketId: talk.speakerSocketId,
423
+ route: {
424
+ talkId: talk.talkId,
425
+ speakerGameId: talk.wireId,
426
+ listenerSocketIds,
427
+ },
428
+ });
429
+ }
430
+ }
@@ -0,0 +1,58 @@
1
+ // канал «вся комната» (scope 'global')
2
+ export const GLOBAL_CHANNEL = 'global';
3
+
4
+ /**
5
+ * Канал, в который участник может говорить; null — говорить нельзя.
6
+ * Говорит только человек, сам управляющий живым актором.
7
+ * @param {Object} participant
8
+ * @param {Object} options
9
+ * @param {string} options.scope - 'team' | 'global'.
10
+ * @returns {number|string|null}
11
+ */
12
+ export function speakChannel(participant, { scope }) {
13
+ if (!participant?.isNetworked || participant.isWatching !== false) {
14
+ return null;
15
+ }
16
+
17
+ return scope === 'global' ? GLOBAL_CHANNEL : participant.teamId;
18
+ }
19
+
20
+ /**
21
+ * Канал, который участник слышит; null — не слышит ничего. Живой игрок —
22
+ * свою команду; любой наблюдающий (наблюдатель, погибший, ждущий респауна) —
23
+ * команду того, за чьей камерой следит.
24
+ * @param {Object} participant
25
+ * @param {Object} options
26
+ * @param {string} options.scope - 'team' | 'global'.
27
+ * @param {Object} options.participants - ParticipantManager.
28
+ * @param {Set<string>} [options.activeIds] - живые id (Voice строит один раз
29
+ * на тик); без него берётся participants.getActiveList().
30
+ * @returns {number|string|null}
31
+ */
32
+ export function listenChannel(participant, { scope, participants, activeIds }) {
33
+ if (!participant?.isNetworked) {
34
+ return null;
35
+ }
36
+
37
+ if (scope === 'global') {
38
+ return GLOBAL_CHANNEL;
39
+ }
40
+
41
+ if (participant.isWatching === false) {
42
+ return participant.teamId;
43
+ }
44
+
45
+ const watchedId = participant.watchedGameId;
46
+
47
+ // наблюдаемый обязан быть в активном списке: камера наблюдателя смотрит
48
+ // только на него (HostGame._onShotTick → getCamera)
49
+ const isActive = activeIds
50
+ ? activeIds.has(watchedId)
51
+ : participants.getActiveList().includes(watchedId);
52
+
53
+ if (watchedId === null || !isActive) {
54
+ return null;
55
+ }
56
+
57
+ return participants.get(watchedId)?.teamId ?? null;
58
+ }
@@ -0,0 +1 @@
1
+ export { default } from './Voice.js';