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
@@ -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';
@@ -0,0 +1,200 @@
1
+ import wsports from '../config/wsports.js';
2
+ import {
3
+ VOICE_BACKPRESSURE_BYTES,
4
+ VOICE_RELAY_BYTE_RATE,
5
+ VOICE_RELAY_RATE,
6
+ } from '../config/voiceProtocol.js';
7
+ import { readVoiceHeader, stampForListeners, toBytes } from './voicePacket.js';
8
+
9
+ // Ретранслятор голоса транспортного уровня (главный поток вкладки хоста).
10
+ // Раздаёт пакеты говорящего слушателям по маршруту, который присылает мета-
11
+ // модуль Voice. Аудио не входит в Worker, порт-машину и ядро. Содержимое
12
+ // пакетов не логируется.
13
+
14
+ // пополнение ведра: не выше ёмкости burst
15
+ const refill = (value, burst, perSecond, elapsed) =>
16
+ Math.min(burst, value + elapsed * perSecond);
17
+
18
+ export default class VoiceRelay {
19
+ /**
20
+ * @param {Object} [opts]
21
+ * @param {Function} [opts.now] - мс; по умолчанию performance.now().
22
+ * @param {{ packetsPerSecond: number, burst: number }} [opts.rate]
23
+ * @param {{ bytesPerSecond: number, burst: number }} [opts.byteRate]
24
+ * @param {number} [opts.backpressureBytes] - порог bufferedAmount выхода.
25
+ */
26
+ constructor({
27
+ now = () => globalThis.performance.now(),
28
+ rate = VOICE_RELAY_RATE,
29
+ byteRate = VOICE_RELAY_BYTE_RATE,
30
+ backpressureBytes = VOICE_BACKPRESSURE_BYTES,
31
+ } = {}) {
32
+ this._now = now;
33
+ this._rate = rate;
34
+ this._byteRate = byteRate;
35
+ this._backpressureBytes = backpressureBytes;
36
+
37
+ // socketId → { send(bytes), bufferedAmount?() }
38
+ this._sinks = new Map();
39
+ // speakerSocketId → { talkId, speakerGameId, listenerSocketIds }
40
+ this._routes = new Map();
41
+ // speakerSocketId → { tokens, bytes, last }
42
+ this._buckets = new Map();
43
+ }
44
+
45
+ /**
46
+ * Выход к соединению (канал WebRTC гостя или loopback хоста-игрока).
47
+ * @param {string} socketId
48
+ * @param {{ send: Function, bufferedAmount?: Function }} sink
49
+ */
50
+ attach(socketId, sink) {
51
+ this._sinks.set(socketId, sink);
52
+ }
53
+
54
+ /**
55
+ * Соединение ушло: снимает выход, маршрут, где оно говорящий, и убирает его
56
+ * из списков слушателей.
57
+ * @param {string} socketId
58
+ */
59
+ detach(socketId) {
60
+ this._sinks.delete(socketId);
61
+ this._routes.delete(socketId);
62
+ this._buckets.delete(socketId);
63
+
64
+ for (const [speakerSocketId, route] of this._routes) {
65
+ if (route.listenerSocketIds.includes(socketId)) {
66
+ this._routes.set(speakerSocketId, {
67
+ ...route,
68
+ listenerSocketIds: route.listenerSocketIds.filter(
69
+ id => id !== socketId,
70
+ ),
71
+ });
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Маршрут от модуля Voice; route === null снимает его.
78
+ * @param {Object} msg
79
+ * @param {string} msg.speakerSocketId
80
+ * @param {{ talkId: number, speakerGameId: number,
81
+ * listenerSocketIds: string[] }|null} msg.route
82
+ */
83
+ applyRoute({ speakerSocketId, route }) {
84
+ if (!route) {
85
+ this._routes.delete(speakerSocketId);
86
+ this._buckets.delete(speakerSocketId);
87
+ return;
88
+ }
89
+
90
+ // ведро полное только у нового эфира
91
+ if (this._routes.get(speakerSocketId)?.talkId !== route.talkId) {
92
+ this._buckets.set(speakerSocketId, {
93
+ tokens: this._rate.burst,
94
+ bytes: this._byteRate.burst,
95
+ last: this._now(),
96
+ });
97
+ }
98
+
99
+ this._routes.set(speakerSocketId, {
100
+ talkId: route.talkId,
101
+ speakerGameId: route.speakerGameId,
102
+ listenerSocketIds: [...route.listenerSocketIds],
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Пакет от соединения socketId. Любой провал проверок — молчаливый выброс.
108
+ * @param {string} socketId
109
+ * @param {ArrayBuffer|Uint8Array} data
110
+ */
111
+ ingress(socketId, data) {
112
+ const route = this._routes.get(socketId);
113
+
114
+ if (!route) {
115
+ return;
116
+ }
117
+
118
+ const bytes = toBytes(data);
119
+ const header = readVoiceHeader(bytes);
120
+
121
+ if (
122
+ !header ||
123
+ header.port !== wsports.client.VOICE_AUDIO ||
124
+ header.talkId !== route.talkId ||
125
+ !this._admit(socketId, bytes.length)
126
+ ) {
127
+ return;
128
+ }
129
+
130
+ // id говорящего хост знает сам — клиентскому байту не верим
131
+ stampForListeners(bytes, route.speakerGameId);
132
+
133
+ for (const id of route.listenerSocketIds) {
134
+ const sink = this._sinks.get(id);
135
+
136
+ if (!sink) {
137
+ continue;
138
+ }
139
+
140
+ // сбой одного выхода не лишает пакета остальных слушателей
141
+ try {
142
+ // живой голос важнее старого: переполненному выходу пакет не шлём
143
+ if (sink.bufferedAmount?.() > this._backpressureBytes) {
144
+ continue;
145
+ }
146
+
147
+ sink.send(bytes);
148
+ } catch {
149
+ // канал закрывается: пакет пропадает, как при потере
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Снимает все маршруты и вёдра (эстафета Worker'ов, закрытие комнаты).
156
+ * Выходы остаются: транспорты переживают смену Worker'а.
157
+ */
158
+ clear() {
159
+ this._routes.clear();
160
+ this._buckets.clear();
161
+ }
162
+
163
+ // вёдра говорящего: пакеты (packetsPerSecond, ёмкость burst) и байты
164
+ // (bytesPerSecond, ёмкость burst). Пакет проходит, только если хватает
165
+ // обоих; отказ не тратит ни одного
166
+ _admit(socketId, size) {
167
+ const bucket = this._buckets.get(socketId);
168
+ const now = this._now();
169
+
170
+ if (!bucket) {
171
+ return false;
172
+ }
173
+
174
+ const elapsed = (now - bucket.last) / 1000;
175
+
176
+ bucket.tokens = refill(
177
+ bucket.tokens,
178
+ this._rate.burst,
179
+ this._rate.packetsPerSecond,
180
+ elapsed,
181
+ );
182
+ bucket.bytes = refill(
183
+ bucket.bytes,
184
+ this._byteRate.burst,
185
+ this._byteRate.bytesPerSecond,
186
+ elapsed,
187
+ );
188
+ bucket.last = now;
189
+
190
+ // отрицание «>=», а не «<»: NaN в ведре закрывает, а не открывает
191
+ if (!(bucket.tokens >= 1 && bucket.bytes >= size)) {
192
+ return false;
193
+ }
194
+
195
+ bucket.tokens -= 1;
196
+ bucket.bytes -= size;
197
+
198
+ return true;
199
+ }
200
+ }
@@ -1,3 +1,5 @@
1
+ import { normalizeVoiceConfig } from './voiceConfig.js';
2
+
1
3
  // Сборка клиентского CONFIG_DATA (порт 0): merge движковых дефолтов
2
4
  // (src/config/clientDefaults.js) с игровым client-конфигом
3
5
  // (@vimp-games/tanks/config/client.js) + время голосования и данные client-side
@@ -54,5 +56,10 @@ export const buildClientConfig = (game, defaults, gameClient) => {
54
56
  // читает hot-буфер — бандл клиента не обязан совпадать с бандлом хоста
55
57
  config.snapshot = game.snapshot;
56
58
 
59
+ // голосовая связь: клиенту нужен весь блок — хоткей, тайминги для HUD и
60
+ // локальных таймеров, битрейт энкодера и подписи. enabled здесь уже
61
+ // фактический (createHostRuntime гасит его без ретранслятора)
62
+ config.voice = game.voice ?? normalizeVoiceConfig(undefined);
63
+
57
64
  return config;
58
65
  };