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.
- package/core/Cargo.toml +1 -1
- package/package.json +3 -1
- package/src/client/InputListener.js +3 -0
- package/src/client/SoundManager.js +13 -0
- package/src/client/boot.js +30 -7
- package/src/client/components/controller/ClientReports.js +1 -1
- package/src/client/components/controller/Controls.js +10 -0
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/controller/Voice.js +31 -0
- package/src/client/components/model/ClientReports.js +1 -1
- package/src/client/components/model/Controls.js +64 -0
- package/src/client/components/model/Games.js +24 -0
- package/src/client/components/model/Voice.js +96 -0
- package/src/client/components/model/Vote.js +2 -1
- package/src/client/components/view/ClientReports.js +1 -1
- package/src/client/components/view/Games.js +19 -1
- package/src/client/components/view/Lobby.js +1 -7
- package/src/client/components/view/Voice.js +172 -0
- package/src/client/debug.js +1 -1
- package/src/client/lib/authApi.js +1 -1
- package/src/client/lib/diagnostics.js +19 -4
- package/src/client/lib/engineVersion.js +0 -6
- package/src/client/lib/footerLink.js +15 -14
- package/src/client/lib/retryOnNetworkError.js +60 -0
- package/src/client/lib/voiceState.js +78 -0
- package/src/client/main.js +152 -18
- package/src/client/network/HiddenHostHealthLog.js +40 -3
- package/src/client/network/HostConnectionManager.js +46 -5
- package/src/client/network/HostController.js +47 -4
- package/src/client/network/HostHealthPolicy.js +41 -0
- package/src/client/network/InlineHostBridge.js +8 -1
- package/src/client/network/LoopbackTransport.js +14 -0
- package/src/client/network/SessionSupervisor.js +24 -0
- package/src/client/network/WebRtcManager.js +45 -3
- package/src/client/session/HandoffFlow.js +5 -0
- package/src/client/session/HostRole.js +5 -2
- package/src/client/style.css +151 -9
- package/src/client/views/gameShell.js +3 -3
- package/src/client/views/includes/auth.pug +4 -4
- package/src/client/views/includes/lobby.pug +4 -4
- package/src/client/views/includes/reports.pug +1 -1
- package/src/client/voice/JitterBuffer.js +137 -0
- package/src/client/voice/MicCapture.js +307 -0
- package/src/client/voice/PttMachine.js +369 -0
- package/src/client/voice/VoiceClient.js +336 -0
- package/src/client/voice/VoiceCues.js +295 -0
- package/src/client/voice/VoiceEncoder.js +174 -0
- package/src/client/voice/VoicePlayback.js +558 -0
- package/src/client/voice/audioData.js +35 -0
- package/src/client/voice/captureProcessor.worklet.js +136 -0
- package/src/client/voice/opusConfig.js +49 -0
- package/src/client/voice/voiceOutput.js +72 -0
- package/src/client/voice/voiceSupport.js +71 -0
- package/src/client/voice/voiceTones.js +90 -0
- package/src/config/authClient.js +3 -3
- package/src/config/clientServices.js +2 -0
- package/src/config/lobby.js +4 -5
- package/src/config/opcodes.js +2 -1
- package/src/config/voiceDefaults.js +44 -0
- package/src/config/voiceProtocol.js +80 -0
- package/src/config/voteKeys.js +4 -0
- package/src/config/wsports.js +12 -0
- package/src/devtools/contract/loadContext.js +2 -2
- package/src/devtools/contract/rules/a7-package-repository.js +7 -8
- package/src/devtools/contract/rules/b11-voice-config.js +34 -0
- package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
- package/src/devtools/contract/rules/index.js +5 -1
- package/src/devtools/invariants.js +1 -1
- package/src/host/DebugRecorder.js +1 -1
- package/src/host/HostGame.js +93 -15
- package/src/host/PortMachine.js +19 -3
- package/src/host/commandFailure.js +140 -0
- package/src/host/host.worker.js +15 -2
- package/src/host/meta/SocketManager.js +8 -2
- package/src/host/meta/core/CommandProcessor.js +37 -7
- package/src/host/meta/modules/voice/Voice.js +430 -0
- package/src/host/meta/modules/voice/channels.js +58 -0
- package/src/host/meta/modules/voice/index.js +1 -0
- package/src/lib/VoiceRelay.js +200 -0
- package/src/lib/applyRoomOverrides.js +1 -1
- package/src/lib/buildClientConfig.js +7 -0
- package/src/lib/capabilities.js +5 -1
- package/src/lib/checkpointCodec.js +1 -2
- package/src/lib/coreConfig.js +1 -1
- package/src/lib/createHostRuntime.js +9 -1
- package/src/lib/gameConfigView.js +11 -1
- package/src/lib/gameId.js +1 -1
- package/src/lib/gamePlugin.js +6 -2
- package/src/lib/jwt.js +1 -1
- package/src/lib/packageLink.js +18 -61
- package/src/lib/validators.js +3 -3
- package/src/lib/voiceConfig.js +136 -0
- package/src/lib/voiceMessages.js +172 -0
- package/src/lib/voicePacket.js +142 -0
- package/src/standalone/index.js +1 -1
- package/tests/fixtures/generations/gen-api3/README.md +2 -2
- package/tests/fixtures/generations/gen-api4/README.md +2 -2
- package/src/devtools/surface/abiParse.js +0 -175
- package/src/devtools/surface/collect.js +0 -413
|
@@ -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
|
+
}
|
|
@@ -54,7 +54,7 @@ export function applyRoomOverrides(
|
|
|
54
54
|
game.timers.mapTime = clampTime(room.mapTime);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
// dev-режим вкладки
|
|
57
|
+
// dev-режим вкладки хоста: включает рекордер
|
|
58
58
|
// и хостовый CONSOLE-лог. Прод-бандл его не выставляет — поведение то же
|
|
59
59
|
if (typeof room.isDevMode === 'boolean') {
|
|
60
60
|
game.isDevMode = room.isDevMode;
|
|
@@ -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
|
};
|
package/src/lib/capabilities.js
CHANGED
|
@@ -43,7 +43,7 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
|
|
|
43
43
|
// `SimCtx::map_body_state`) и доступ игры к телам карты по индексу
|
|
44
44
|
// (хэндл, индекс по хэндлу, отключение тела)
|
|
45
45
|
{ value: 'map.bodyState', since: '0.34.0' },
|
|
46
|
-
// сервис пула `diagnostics` (журнал клиентских
|
|
46
|
+
// сервис пула `diagnostics` (журнал клиентских ошибок);
|
|
47
47
|
// опционален — игра не пишет его в `requires`
|
|
48
48
|
{ value: 'diagnostics', since: '0.35.0' },
|
|
49
49
|
// контрольная точка хоста посреди раунда (host-migration этап 5):
|
|
@@ -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`
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Контейнер до сжатия: [u32 metaLen LE][meta JSON utf-8][байты дампа ядра];
|
|
6
6
|
// весь буфер — gzip. Дамп ядра сам по себе JSON (serialize_state), и без
|
|
7
|
-
// сжатия точка tanks весит ~420 КБ, с ним ~21
|
|
8
|
-
// spike-results).
|
|
7
|
+
// сжатия точка tanks весит ~420 КБ, с ним ~21 КБ.
|
|
9
8
|
|
|
10
9
|
import hostDefaults from '../config/hostDefaults.js';
|
|
11
10
|
|
package/src/lib/coreConfig.js
CHANGED
|
@@ -2,7 +2,7 @@ import { SNAPSHOT_FORMAT_VERSION } from '../config/opcodes.js';
|
|
|
2
2
|
import hostDefaults from '../config/hostDefaults.js';
|
|
3
3
|
import wsports from '../config/wsports.js';
|
|
4
4
|
|
|
5
|
-
// Сборка JSON-конфига Rust-ядра (
|
|
5
|
+
// Сборка JSON-конфига Rust-ядра (крейт vimp-engine-core + core/ в репозитории
|
|
6
6
|
// игры, например vimp-tanks): движковая половина
|
|
7
7
|
// (timeStep/mapScale/mapSetId/snapshot/seed) + игровая
|
|
8
8
|
// (models/weapons/playerKeys/panel/friendlyFire) — форма {engine, game} из
|
|
@@ -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, dedicated).
|
|
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 =
|
|
@@ -87,7 +95,7 @@ export async function createHostRuntime(room, options = {}) {
|
|
|
87
95
|
// преемник собирает из них тот же конфиг ядра
|
|
88
96
|
roomSettings: pickRoomSettings(room),
|
|
89
97
|
mapsVersion: room?.mapsVersion ?? null,
|
|
90
|
-
// рекордер
|
|
98
|
+
// рекордер кладёт seed в сценарий — без него
|
|
91
99
|
// записанный матч невоспроизводим
|
|
92
100
|
seed,
|
|
93
101
|
...hostOptions,
|
|
@@ -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
|
-
|
|
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);
|
package/src/lib/gameId.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Формат id игры — изоморфно: его проверяет мастер (реестр, раздача, каталог)
|
|
2
2
|
// и клиентский роутер ссылок (client/lib/roomLink.js) до похода в сеть.
|
|
3
|
-
// Значение обязано совпадать с
|
|
3
|
+
// Значение обязано совпадать с games из конфига auth-сервиса
|
|
4
4
|
// (см. master/gameRefs.js — он реэкспортирует отсюда)
|
|
5
5
|
export const GAME_ID_PATTERN = /^[a-z][a-z0-9-]{1,30}$/;
|
|
6
6
|
|
package/src/lib/gamePlugin.js
CHANGED
|
@@ -7,8 +7,12 @@ import { createGameConfigView } from './gameConfigView.js';
|
|
|
7
7
|
// entries.client из манифеста.
|
|
8
8
|
|
|
9
9
|
// каталог всех игр мастера (GameCatalog, см. Этап 6.2) — массив манифестов
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// doFetch подставляет клиент: загрузка страницы повторяет сбой сети
|
|
11
|
+
export async function fetchGamesManifest(
|
|
12
|
+
url = '/games/manifest.json',
|
|
13
|
+
doFetch = fetch,
|
|
14
|
+
) {
|
|
15
|
+
const res = await doFetch(url);
|
|
12
16
|
|
|
13
17
|
if (!res.ok) {
|
|
14
18
|
throw new Error(`games manifest: HTTP ${res.status}`);
|
package/src/lib/jwt.js
CHANGED
|
@@ -41,7 +41,7 @@ export function decodeJwtPayload(token) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// Авторитетная проверка identity-токена (Этап B3): подпись RS256 по JWKS
|
|
44
|
-
// central auth
|
|
44
|
+
// central auth-сервиса, issuer и срок годности. Работает через
|
|
45
45
|
// Web Crypto API (crypto.subtle) — доступен в браузере, Worker'е хоста и в
|
|
46
46
|
// Node ≥19 глобально, отдельной JWT-библиотеки не требует. Возвращает
|
|
47
47
|
// проверенный payload ({ sub, nick, iss, exp, ... }) или бросает исключение.
|
package/src/lib/packageLink.js
CHANGED
|
@@ -1,63 +1,31 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Адрес проекта пакета — его `repository`: ссылка ведёт на саму игру, в том
|
|
2
|
+
// числе закрытую. `homepage` не читается. Фолбэка нет: пакет без repository
|
|
3
|
+
// адреса не получает — страница пакета в npm маскировала бы недостающие
|
|
4
|
+
// метаданные. О пропущенном поле предупреждает правило контракта A7.
|
|
3
5
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// в npm давал разнобой («GitHub» у одной игры, «npm» у другой) и молча
|
|
8
|
-
// маскировал недостающие метаданные вместо того, чтобы показать их отсутствие.
|
|
9
|
-
// О пропущенном поле предупреждает правило контракта A7.
|
|
10
|
-
//
|
|
11
|
-
// Хостинг не ограничен github.com: подпись выводится из хоста, поэтому чужой
|
|
12
|
-
// плагин на GitLab получает честную ссылку, а не пустую ячейку.
|
|
13
|
-
//
|
|
14
|
-
// Модуль изоморфный: мастер зовёт resolveProjectUrl для пакета игры
|
|
15
|
-
// (GameCatalog), клиент — для собственного package.json движка
|
|
16
|
-
// (client/lib/engineVersion.js).
|
|
6
|
+
// Хостинг не ограничен github.com. Модуль изоморфный: мастер зовёт
|
|
7
|
+
// resolveProjectUrl для метаданных пакета игры (адрес уходит в реестр игр),
|
|
8
|
+
// devtools — для package.json игры (правило A7).
|
|
17
9
|
|
|
18
10
|
/**
|
|
19
|
-
* Адрес проекта из package.json, приведённый к
|
|
20
|
-
* repository.
|
|
11
|
+
* Адрес проекта из package.json, приведённый к http(s): поле repository.
|
|
21
12
|
* @param {Object} pkg - разобранный package.json
|
|
22
|
-
* @returns {string|null}
|
|
13
|
+
* @returns {string|null} http(s)-URL либо null, если поля нет или это не адрес
|
|
23
14
|
*/
|
|
24
15
|
export function resolveProjectUrl(pkg) {
|
|
25
16
|
if (!pkg || typeof pkg !== 'object') {
|
|
26
17
|
return null;
|
|
27
18
|
}
|
|
28
19
|
|
|
29
|
-
const {
|
|
20
|
+
const { repository } = pkg;
|
|
30
21
|
|
|
31
22
|
// repository бывает и строкой ("github:user/repo"), и объектом {type, url}
|
|
32
|
-
|
|
33
|
-
typeof repository === 'string' ? repository : repository?.url
|
|
34
|
-
|
|
35
|
-
// homepage важнее repository: поле repository обязано называть
|
|
36
|
-
// репозиторий публикации (его сверяет npm Trusted Publishing), и у пакета
|
|
37
|
-
// из закрытого репозитория такая ссылка отдаёт игроку 404
|
|
38
|
-
return normalize(homepage) ?? normalizeRepository(declared);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Ссылка футера по готовому адресу проекта.
|
|
43
|
-
* @param {string|null} [url] - результат resolveProjectUrl
|
|
44
|
-
* @returns {{url: string, label: string}|null} null, если адреса нет
|
|
45
|
-
*/
|
|
46
|
-
export function projectLink(url) {
|
|
47
|
-
// адрес перепроверяется, хотя свои вызовы приходят уже из
|
|
48
|
-
// resolveProjectUrl: функция — публичный экспорт ("./lib/*"), а результат
|
|
49
|
-
// уходит прямо в href якоря (client/lib/footerLink.js)
|
|
50
|
-
const value = typeof url === 'string' ? url.trim() : '';
|
|
51
|
-
|
|
52
|
-
if (!/^https?:\/\//.test(value)) {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return { url: value, label: labelFor(value) };
|
|
23
|
+
return normalizeRepository(
|
|
24
|
+
typeof repository === 'string' ? repository : repository?.url,
|
|
25
|
+
);
|
|
57
26
|
}
|
|
58
27
|
|
|
59
|
-
// шорткат "user/repo" — семантика поля repository (так его читает npm)
|
|
60
|
-
// homepage такая строка означала бы относительный путь, а не адрес на GitHub
|
|
28
|
+
// шорткат "user/repo" — семантика поля repository (так его читает npm)
|
|
61
29
|
function normalizeRepository(raw) {
|
|
62
30
|
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
63
31
|
|
|
@@ -94,6 +62,9 @@ function normalize(raw) {
|
|
|
94
62
|
|
|
95
63
|
url = url.replace(/^git\+/, '');
|
|
96
64
|
|
|
65
|
+
// порт SSH (ssh://host:2222/…) к адресу https не относится
|
|
66
|
+
url = url.replace(/^(ssh:\/\/(?:[^/@]*@)?[^/:@]+):\d+(?=\/|$)/, '$1');
|
|
67
|
+
|
|
97
68
|
// scp-форма git@host:user/repo (в том числе с лишним ssh://, как её
|
|
98
69
|
// нередко пишет npm) — двоеточие здесь разделитель, не порт
|
|
99
70
|
const scp = /^(?:ssh:\/\/)?git@([^:/]+):(.+)$/.exec(url);
|
|
@@ -117,22 +88,8 @@ function normalize(raw) {
|
|
|
117
88
|
// в href он уехал бы в DOM и историю браузера
|
|
118
89
|
.replace(/^(https?:\/\/)[^/@]*@/, '$1')
|
|
119
90
|
.replace(/\.git(?=$|[#?])/, '')
|
|
120
|
-
//
|
|
91
|
+
// якорь #readme в адресе репозитория ссылке не нужен
|
|
121
92
|
.replace(/#readme$/, '')
|
|
122
93
|
.replace(/\/+$/, '')
|
|
123
94
|
);
|
|
124
95
|
}
|
|
125
|
-
|
|
126
|
-
// подпись ячейки: у наших пакетов это всегда GitHub, у чужого хостинга —
|
|
127
|
-
// его хост, чтобы подпись не врала об источнике
|
|
128
|
-
function labelFor(url) {
|
|
129
|
-
let host;
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
host = new URL(url).hostname.replace(/^www\./, '');
|
|
133
|
-
} catch {
|
|
134
|
-
return 'Website';
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return host === 'github.com' ? 'GitHub' : host;
|
|
138
|
-
}
|
package/src/lib/validators.js
CHANGED
|
@@ -13,7 +13,7 @@ const NAME_REGEXP = new RegExp('^[a-zA-Z]([\\w\\s#]{0,13})[\\w]{1}$');
|
|
|
13
13
|
export const isValidName = name =>
|
|
14
14
|
typeof name === 'string' && NAME_REGEXP.test(name);
|
|
15
15
|
|
|
16
|
-
// ник профиля: копия правила auth-сервиса (
|
|
16
|
+
// ник профиля: копия правила auth-сервиса (валидаторы auth-сервиса)
|
|
17
17
|
// — общей рантайм-зависимости у пакетов нет. Строже isValidName: внутри ника
|
|
18
18
|
// допустим только обычный пробел, не любой \s
|
|
19
19
|
const NICK_REGEXP = new RegExp('^[a-zA-Z]([\\w #]{0,13})[\\w]{1}$');
|
|
@@ -272,8 +272,8 @@ export const clampLimit = (value, fallback, max) => {
|
|
|
272
272
|
// на его стороне — минута (lobbyConfig.playerData.minFlushInterval), столько
|
|
273
273
|
// игр в неё не влезает.
|
|
274
274
|
//
|
|
275
|
-
// Множитель СВЯЗАН с rank.maxPoints auth
|
|
276
|
-
//
|
|
275
|
+
// Множитель СВЯЗАН с rank.maxPoints из конфига auth-сервиса: мастер
|
|
276
|
+
// не должен пропускать то, что auth отклонит. Отклонённое
|
|
277
277
|
// тело для хоста неустранимо — PlayerDataSync повторял бы его до конца жизни
|
|
278
278
|
// комнаты, — поэтому при правке per-game maxGameScore проверяется и потолок
|
|
279
279
|
// суммы на той стороне.
|
|
@@ -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
|
+
}
|