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
@@ -0,0 +1,172 @@
1
+ // Singleton VoiceView
2
+
3
+ let voiceView;
4
+
5
+ const SVG_NS = 'http://www.w3.org/2000/svg';
6
+ const RING_RADIUS = 15;
7
+ const RING_LENGTH = 2 * Math.PI * RING_RADIUS;
8
+
9
+ // константная разметка значка микрофона: капсула, дуга держателя, ножка
10
+ // (перечёркивание — .voice-cross)
11
+ const MIC_SVG =
12
+ '<rect x="9" y="3" width="6" height="11" rx="3"/>' +
13
+ '<path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>' +
14
+ '<path class="voice-cross" d="M4 4l16 16"/>';
15
+
16
+ function createIcon() {
17
+ const svg = document.createElementNS(SVG_NS, 'svg');
18
+
19
+ svg.setAttribute('class', 'voice-icon');
20
+ svg.setAttribute('aria-hidden', 'true');
21
+ svg.setAttribute('viewBox', '0 0 24 24');
22
+ svg.innerHTML = MIC_SVG;
23
+
24
+ return svg;
25
+ }
26
+
27
+ export default class VoiceView {
28
+ /**
29
+ * @param {Object} model
30
+ * @param {HTMLElement} container - точка монтирования (контейнер игры).
31
+ */
32
+ constructor(model, container = document.body) {
33
+ if (voiceView) {
34
+ return voiceView;
35
+ }
36
+
37
+ voiceView = this;
38
+
39
+ this._hud = document.createElement('div');
40
+ this._hud.id = 'voice-hud';
41
+
42
+ const own = document.createElement('div');
43
+ const incoming = document.createElement('div');
44
+ const ring = document.createElementNS(SVG_NS, 'svg');
45
+
46
+ own.className = 'voice-own';
47
+ incoming.className = 'voice-incoming';
48
+
49
+ ring.setAttribute('class', 'voice-ring');
50
+ ring.setAttribute('aria-hidden', 'true');
51
+ ring.setAttribute('viewBox', '0 0 36 36');
52
+ ring.innerHTML = `<circle cx="18" cy="18" r="${RING_RADIUS}"/>`;
53
+ this._circle = ring.firstChild;
54
+ this._circle.style.strokeDasharray = String(RING_LENGTH);
55
+
56
+ this._timerElem = document.createElement('span');
57
+ this._timerElem.className = 'voice-timer';
58
+ this._timerElem.setAttribute('aria-hidden', 'true');
59
+
60
+ this._textElem = document.createElement('span');
61
+ this._textElem.className = 'voice-text';
62
+ this._textElem.setAttribute('role', 'status');
63
+ this._textElem.setAttribute('aria-live', 'polite');
64
+
65
+ this._nameElem = document.createElement('span');
66
+ this._nameElem.className = 'voice-name';
67
+
68
+ own.append(createIcon(), ring, this._timerElem, this._textElem);
69
+ incoming.append(createIcon(), this._nameElem);
70
+ this._hud.append(own, incoming);
71
+ container.appendChild(this._hud);
72
+
73
+ this._own = null;
74
+ this._raf = null;
75
+
76
+ model.publisher.on('own', 'renderOwn', this);
77
+ model.publisher.on('incoming', 'renderIncoming', this);
78
+ }
79
+
80
+ // свой эфир: класс состояния, текст, цикл отсчёта
81
+ renderOwn(data) {
82
+ const { kind, text, endsAt, totalMs } = data;
83
+
84
+ for (const name of ['talking', 'busy', 'cooldown', 'error']) {
85
+ this._hud.classList.toggle(`is-${name}`, name === kind);
86
+ }
87
+
88
+ this._textElem.textContent = text;
89
+
90
+ if (kind === 'talking' || kind === 'cooldown') {
91
+ this._own = { kind, endsAt, totalMs };
92
+
93
+ if (this._raf === null) {
94
+ this._raf = requestAnimationFrame(() => this._tick());
95
+ }
96
+
97
+ return;
98
+ }
99
+
100
+ this._stopCountdown();
101
+ }
102
+
103
+ // чужой эфир: ник как текст, цвет как у чата
104
+ renderIncoming(speaker) {
105
+ this._hud.classList.toggle('is-incoming', Boolean(speaker));
106
+
107
+ for (const className of [...this._nameElem.classList]) {
108
+ if (className.startsWith('voice-team')) {
109
+ this._nameElem.classList.remove(className);
110
+ }
111
+ }
112
+
113
+ if (!speaker) {
114
+ this._nameElem.textContent = '';
115
+ this._nameElem.style.removeProperty('--voice-name-color');
116
+
117
+ return;
118
+ }
119
+
120
+ this._nameElem.textContent = speaker.name;
121
+
122
+ if (Number.isInteger(speaker.teamId)) {
123
+ this._nameElem.classList.add(`voice-team${speaker.teamId}`);
124
+ }
125
+
126
+ if (speaker.color) {
127
+ this._nameElem.style.setProperty('--voice-name-color', speaker.color);
128
+ } else {
129
+ this._nameElem.style.removeProperty('--voice-name-color');
130
+ }
131
+ }
132
+
133
+ destroy() {
134
+ this._stopCountdown();
135
+ this._hud.remove();
136
+ }
137
+
138
+ _tick() {
139
+ const own = this._own;
140
+
141
+ this._raf = null;
142
+
143
+ // вид сменился после планирования кадра
144
+ if (!own || (own.kind !== 'talking' && own.kind !== 'cooldown')) {
145
+ return;
146
+ }
147
+
148
+ const remaining = Math.max(0, own.endsAt - performance.now());
149
+
150
+ this._timerElem.textContent = `${(remaining / 1000).toFixed(1)}s`;
151
+
152
+ // у кулдауна кольца нет
153
+ this._circle.style.strokeDashoffset = String(
154
+ own.kind === 'talking' && own.totalMs > 0
155
+ ? RING_LENGTH * (1 - remaining / own.totalMs)
156
+ : RING_LENGTH,
157
+ );
158
+
159
+ this._raf = requestAnimationFrame(() => this._tick());
160
+ }
161
+
162
+ _stopCountdown() {
163
+ if (this._raf !== null) {
164
+ cancelAnimationFrame(this._raf);
165
+ this._raf = null;
166
+ }
167
+
168
+ this._own = null;
169
+ this._timerElem.textContent = '';
170
+ this._circle.style.strokeDashoffset = String(RING_LENGTH);
171
+ }
172
+ }
@@ -1,4 +1,4 @@
1
- // Браузерная половина отладочного контура (этап 6 плана plan/done/ai-debug).
1
+ // Браузерная половина отладочного контура.
2
2
  //
3
3
  // Всё, что headless-прогон не воспроизводит — реальный WebRTC, PixiJS, живой
4
4
  // ввод человека — читается и записывается отсюда: `window.__vimpDebug`
@@ -1,4 +1,4 @@
1
- // REST-клиент auth-сервиса (packages/auth): регистрация, сессия, OAuth,
1
+ // REST-клиент auth-сервиса: регистрация, сессия, OAuth,
2
2
  // управление профилем. Без DOM; fetch инъектируется (тесты).
3
3
  //
4
4
  // Каждый метод возвращает { ok: true, status, data } или
@@ -1,4 +1,4 @@
1
- // Журнал клиентских ошибок (plan/client-reports, этап 3): неперехваченные
1
+ // Журнал клиентских ошибок: неперехваченные
2
2
  // ошибки и отклонения страницы, ошибки Worker'а хоста, нарушения CSP и явные
3
3
  // warn/capture плагинов уходят на свой же бокс (POST /client-reports), а тот
4
4
  // пересылает их в центральный журнал.
@@ -175,7 +175,7 @@ function noop() {}
175
175
  * @param {number} [opts.flushDelayMs=2000] - задержка первой отправки
176
176
  * @param {number} [opts.minIntervalMs=10000] - интервал между отправками по
177
177
  * таймеру; прямой flush() его не ждёт
178
- * @returns {{ capture, warn, flush, setContext, install }}
178
+ * @returns {{ capture, ignore, warn, flush, setContext, install }}
179
179
  */
180
180
  export function createDiagnostics({
181
181
  url,
@@ -189,6 +189,7 @@ export function createDiagnostics({
189
189
  if (!url) {
190
190
  return {
191
191
  capture: noop,
192
+ ignore: noop,
192
193
  warn: noop,
193
194
  flush: noop,
194
195
  setContext: noop,
@@ -211,7 +212,7 @@ export function createDiagnostics({
211
212
  let busy = false;
212
213
 
213
214
  function record(item) {
214
- const key = `${item.source}|${item.kind}|${item.code ?? item.message}|${topFrame(item.stack)}`;
215
+ const key = `${item.source}|${item.kind}|${item.code ?? item.message}|${item.code ? '' : topFrame(item.stack)}`;
215
216
  const at = now();
216
217
  const known = records.get(key);
217
218
 
@@ -276,8 +277,22 @@ export function createDiagnostics({
276
277
  }
277
278
  }
278
279
 
280
+ // ошибки, о которых вызывающий уже позаботился сам (сбой сети при
281
+ // загрузке): перехватчик окна их не записывает
282
+ const ignored = new WeakSet();
283
+
284
+ function ignore(error) {
285
+ if (error !== null && typeof error === 'object') {
286
+ ignored.add(error);
287
+ }
288
+ }
289
+
279
290
  function capture(error, { source = 'client', kind = 'error' } = {}) {
280
291
  guarded(() => {
292
+ if (error !== null && typeof error === 'object' && ignored.has(error)) {
293
+ return;
294
+ }
295
+
281
296
  const raw =
282
297
  error !== null && error !== undefined && error.message !== undefined
283
298
  ? error.message
@@ -485,5 +500,5 @@ export function createDiagnostics({
485
500
  };
486
501
  }
487
502
 
488
- return { capture, warn, flush, setContext, install };
503
+ return { capture, ignore, warn, flush, setContext, install };
489
504
  }
@@ -7,11 +7,5 @@
7
7
  // бандлу (в npm-тарболле vimp-engine package.json есть всегда, поэтому
8
8
  // standalone SDK собирается у игры тем же импортом).
9
9
  import pkg from '../../../package.json' with { type: 'json' };
10
- import { resolveProjectUrl } from '../../lib/packageLink.js';
11
10
 
12
11
  export const ENGINE_VERSION = pkg.version;
13
-
14
- // адрес проекта для ссылки в футере лобби — то же поле, что мастер отдаёт
15
- // про пакет игры (GameCatalog.packageUrl), чтобы оба футера строил один
16
- // projectLink
17
- export const ENGINE_PROJECT_URL = resolveProjectUrl(pkg);
@@ -1,29 +1,30 @@
1
- import { projectLink } from '../../lib/packageLink.js';
2
-
3
- // Ссылка в футере лобби и формы входа. Один рендер на оба экрана: лобби
4
- // подставляет адрес проекта движка, форма входа — адрес игры из манифеста.
5
- //
6
- // Адреса нет (пакет не объявил repository/homepage) — якорь гасится, а не
7
- // прячется: ячейка футера остаётся пустой, и раскладка space-between не едет.
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';
8
7
 
9
8
  /**
10
9
  * @param {HTMLAnchorElement|null} anchor - якорь футера
11
- * @param {string|null} url - адрес проекта (resolveProjectUrl)
10
+ * @param {boolean} enabled - true только в dev-сборке
12
11
  */
13
- export function renderProjectLink(anchor, url) {
12
+ export function renderDocsLink(anchor, enabled) {
14
13
  if (!anchor) {
15
14
  return;
16
15
  }
17
16
 
18
- const link = projectLink(url);
19
-
20
- if (!link) {
17
+ if (!enabled) {
21
18
  anchor.textContent = '';
22
19
  anchor.removeAttribute('href');
20
+ anchor.removeAttribute('target');
21
+ anchor.removeAttribute('rel');
23
22
 
24
23
  return;
25
24
  }
26
25
 
27
- anchor.textContent = link.label;
28
- anchor.href = link.url;
26
+ anchor.textContent = 'docs';
27
+ anchor.href = DOCS_URL;
28
+ anchor.target = '_blank';
29
+ anchor.rel = 'noopener';
29
30
  }
@@ -0,0 +1,60 @@
1
+ // Повтор запроса загрузки при сбое сети. fetch отклоняется TypeError'ом,
2
+ // только когда ответа нет вовсе (обрыв, смена сети, вкладка из фона);
3
+ // HTTP-статус и разбор ответа сбоем сети не являются и не повторяются.
4
+ // Повторяется только сам вызов fetch: TypeError из разбора ответа — ошибка
5
+ // кода, а не сети.
6
+
7
+ // паузы перед повторами: три запроса, отказ не позже ~2 с сверх самих запросов
8
+ export const BOOT_RETRY_DELAYS_MS = [500, 1500];
9
+
10
+ const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms));
11
+
12
+ /** Сбой сети, оставшийся после повторов; отличает его от ошибок кода. */
13
+ export class NetworkFailure extends Error {
14
+ constructor(cause) {
15
+ super(cause?.message ?? 'Network failure', { cause });
16
+ this.name = 'NetworkFailure';
17
+ }
18
+ }
19
+
20
+ export const isNetworkFailure = error => error instanceof NetworkFailure;
21
+
22
+ /**
23
+ * Оборачивает отказ чтения тела ответа: SyntaxError — это мусор в JSON,
24
+ * любой другой отказ (TypeError обрыва) — сбой сети.
25
+ * @param {Error} error
26
+ * @returns {Error}
27
+ */
28
+ export const bodyReadFailure = error =>
29
+ error instanceof SyntaxError ? error : new NetworkFailure(error);
30
+
31
+ /**
32
+ * @param {Function} doFetch - (...args) => Promise<Response>: сам fetch.
33
+ * @param {*} url
34
+ * @param {Object} [options]
35
+ * @param {number[]} [options.delaysMs] - паузы перед повторами, мс.
36
+ * @param {Function} [options.sleep] - (ms) => Promise (тесты подставляют свою).
37
+ * @returns {Promise<Response>} ответ первой удавшейся попытки; отклоняется
38
+ * NetworkFailure, когда сеть не ответила во всех попытках.
39
+ */
40
+ export async function fetchRetrying(
41
+ doFetch,
42
+ url,
43
+ { delaysMs = BOOT_RETRY_DELAYS_MS, sleep = defaultSleep } = {},
44
+ ) {
45
+ for (let attempt = 0; ; attempt += 1) {
46
+ try {
47
+ return await doFetch(url);
48
+ } catch (e) {
49
+ if (!(e instanceof TypeError)) {
50
+ throw e;
51
+ }
52
+
53
+ if (attempt >= delaysMs.length) {
54
+ throw new NetworkFailure(e);
55
+ }
56
+
57
+ await sleep(delaysMs[attempt]);
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,78 @@
1
+ // Сервис пула зависимостей `voice`: кто сейчас в эфире.
2
+ //
3
+ // Сервис только читает (его получают парты игры — индикаторы над танком,
4
+ // на радаре); пишет хранилище, которым владеет движок: оно применяет
5
+ // разобранные сообщения VOICE_DATA. START/STOP приходят только слушателям
6
+ // канала, поэтому противник при командном режиме не узнаёт о говорящем
7
+ // ничего.
8
+ //
9
+ // Ключи — строки: id в part приходит ключом кадра (строкой), а на проводе
10
+ // это число.
11
+ export function createVoiceState({ now = () => performance.now() } = {}) {
12
+ // String(speakerId) → { talkId, since, name }
13
+ const talks = new Map();
14
+ let enabled = false;
15
+
16
+ const service = Object.freeze({
17
+ // связь в этом матче включена (конфиг + контур с ретранслятором)
18
+ get enabled() {
19
+ return enabled;
20
+ },
21
+
22
+ isTalking(id) {
23
+ return talks.has(String(id));
24
+ },
25
+
26
+ // момент START по часам клиента (мс performance.now) или null — фаза
27
+ // анимации индикатора
28
+ talkingSince(id) {
29
+ return talks.get(String(id))?.since ?? null;
30
+ },
31
+ });
32
+
33
+ const store = {
34
+ setEnabled(flag) {
35
+ enabled = flag === true;
36
+ },
37
+
38
+ // разобранное parseVoiceMessage; прочие типы игнорируются
39
+ apply(message) {
40
+ if (message?.type === 'start') {
41
+ talks.set(String(message.speakerId), {
42
+ talkId: message.talkId,
43
+ since: now(),
44
+ name: message.name,
45
+ });
46
+ } else if (message?.type === 'stop') {
47
+ const key = String(message.speakerId);
48
+
49
+ // поздний STOP старого эфира не гасит новый
50
+ if (talks.get(key)?.talkId === message.talkId) {
51
+ talks.delete(key);
52
+ }
53
+ }
54
+ },
55
+
56
+ // эфир, не закрытый STOP дольше maxAgeMs, гаснет сам (страховка при
57
+ // потерянном STOP). Возвращает число погашенных
58
+ expire(maxAgeMs) {
59
+ const current = now();
60
+ let removed = 0;
61
+
62
+ for (const [key, talk] of talks) {
63
+ if (current - talk.since > maxAgeMs) {
64
+ talks.delete(key);
65
+ removed += 1;
66
+ }
67
+ }
68
+
69
+ return removed;
70
+ },
71
+
72
+ clear() {
73
+ talks.clear();
74
+ },
75
+ };
76
+
77
+ return { service, store };
78
+ }