vimp-engine 0.23.0 → 0.24.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/bin/vimp-surface.js +1 -1
- package/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/lib/formBuilder.js +15 -23
- package/src/client/lib/pickActiveGame.js +59 -0
- package/src/client/main.js +29 -30
- package/src/config/abiOps.js +6 -0
- package/src/devtools/contract/rules/b2-engine-api.js +53 -5
- package/src/devtools/contract/rules/c10-auth-schema.js +34 -1
- package/src/host/GameCoreAdapter.js +23 -23
- package/src/lib/applyRoomOverrides.js +3 -1
- package/src/lib/coreAbi.js +116 -0
- package/src/lib/formUnit.js +25 -0
- package/src/lib/gameConfigView.js +4 -1
- package/src/lib/gamePlugin.js +24 -2
- package/src/lib/validators.js +79 -17
- package/src/standalone/index.js +20 -5
package/bin/vimp-surface.js
CHANGED
|
@@ -44,7 +44,7 @@ async function main(argv) {
|
|
|
44
44
|
|
|
45
45
|
try {
|
|
46
46
|
committed = JSON.parse(await readFile(SURFACE_PATH, 'utf8'));
|
|
47
|
-
} catch
|
|
47
|
+
} catch {
|
|
48
48
|
process.stderr.write(
|
|
49
49
|
`surface: no snapshot at ${SURFACE_PATH} — run ` +
|
|
50
50
|
'`npm run surface:update` to create it\n',
|
package/core/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "vimp-engine-core"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.1"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
|
|
6
6
|
license = "MIT"
|
package/package.json
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { anchorPattern } from '../../lib/formPattern.js';
|
|
2
2
|
import { normalizeOptions } from '../../lib/formOptions.js';
|
|
3
3
|
import { resolveDescriptor } from '../../lib/formControls.js';
|
|
4
|
+
import { toDisplay, toStored, isNumericField } from '../../lib/formUnit.js';
|
|
4
5
|
|
|
5
6
|
// Общий билдер полей форм (room-форма и auth-форма используют один
|
|
6
7
|
// контракт дескрипторов — docs/en/plugin-api.md, раздел "Form schema").
|
|
7
8
|
// control: 'select'|'text'|'checkbox'|'radio'; все контролы — нативные
|
|
8
|
-
// элементы формы, без визуальной кастомизации.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
9
|
+
// элементы формы, без визуальной кастомизации.
|
|
10
|
+
//
|
|
11
|
+
// Имя контрола приезжает от игры и разрешается через append-only реестр
|
|
12
|
+
// (lib/formControls.js): имена, выведенные из эксплуатации в v3 ('range',
|
|
13
|
+
// 'number', 'toggle', 'segmented'), продолжают строиться алиасами своих
|
|
14
|
+
// нативных замен (И1). Тот же резолв обязана делать авторитетная валидация
|
|
15
|
+
// хоста (lib/validators.js) — иначе поле строится одним контролом, а
|
|
16
|
+
// проверяется как другой.
|
|
17
|
+
//
|
|
18
|
+
// Валидация — не нативная (никаких браузерных попапов):
|
|
19
|
+
// collectFormErrors/renderFormErrors ниже собирают и рисуют ошибки в
|
|
20
|
+
// разметку (#lobby-error/#auth-error).
|
|
14
21
|
// Проверяются только поля, у которых есть строка в DOM: ошибка на скрытом
|
|
15
22
|
// поле игроку не видна и исправить её нечем — она просто запирает форму.
|
|
16
23
|
|
|
@@ -18,12 +25,6 @@ import { resolveDescriptor } from '../../lib/formControls.js';
|
|
|
18
25
|
// resolveForcedValue не расходился с builders при добавлении нового
|
|
19
26
|
const OPTION_CONTROLS = ['select', 'radio'];
|
|
20
27
|
|
|
21
|
-
// числовое text-поле: unit задан или numeric:true. Правило одно на билдер и
|
|
22
|
-
// на валидацию, поэтому живёт в одном месте
|
|
23
|
-
function isNumeric(descriptor) {
|
|
24
|
-
return descriptor.numeric === true || descriptor.unit !== undefined;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
28
|
// 'source' — спец-источник вариантов из каталога движка (например карты),
|
|
28
29
|
// прокидывается вызывающей стороной через ctx.sources
|
|
29
30
|
function resolveOptions(descriptor, ctx) {
|
|
@@ -63,15 +64,6 @@ export function resolveForcedValue(descriptor, ctx = {}) {
|
|
|
63
64
|
return forced === undefined ? undefined : String(forced);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
// unit:'s' — значение хранится в мс, показывается/редактируется в секундах
|
|
67
|
-
function toDisplay(descriptor, value) {
|
|
68
|
-
return descriptor.unit === 's' ? value / 1000 : value;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function toStored(descriptor, value) {
|
|
72
|
-
return descriptor.unit === 's' ? value * 1000 : value;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
67
|
function buildSelect(descriptor, ctx) {
|
|
76
68
|
const el = document.createElement('select');
|
|
77
69
|
const options = resolveOptions(descriptor, ctx);
|
|
@@ -116,7 +108,7 @@ function buildSelect(descriptor, ctx) {
|
|
|
116
108
|
// вместо превращения его в 0 на сабмите
|
|
117
109
|
function buildText(descriptor) {
|
|
118
110
|
const el = document.createElement('input');
|
|
119
|
-
const numeric =
|
|
111
|
+
const numeric = isNumericField(descriptor);
|
|
120
112
|
|
|
121
113
|
el.type = 'text';
|
|
122
114
|
// выпадашка прошлых значений/автозаполнения кроет форму, а подставить в
|
|
@@ -386,7 +378,7 @@ function validateField(descriptor, field) {
|
|
|
386
378
|
const isEmpty = isText
|
|
387
379
|
? raw === ''
|
|
388
380
|
: value === undefined || value === null || value === '';
|
|
389
|
-
const numeric = isText &&
|
|
381
|
+
const numeric = isText && isNumericField(descriptor);
|
|
390
382
|
|
|
391
383
|
// числовое поле всегда несёт число и «необязательным» быть не может:
|
|
392
384
|
// пустой ввод getValue() подменяет default'ом (чтобы не уехал нолём), и
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Выбор активной игры из каталога мастера. Вынесено из client/main.js по той
|
|
2
|
+
// же причине, что и socketDispatch.js: main.js исполняется при импорте, и
|
|
3
|
+
// проверить ветвление внутри него нечем.
|
|
4
|
+
//
|
|
5
|
+
// Недоступная игра (manifest.compat.ok === false, этап 5 плана
|
|
6
|
+
// plugin-forward-compat) в СПИСОК лобби попадает — с причиной, — но активной
|
|
7
|
+
// быть не может: её плагин не загрузится, и вкладка встала бы на первой же
|
|
8
|
+
// игре каталога.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Доступна ли игра. Поле `compat` появляется у манифеста каталога мастера,
|
|
12
|
+
* только когда игра просит возможность, которой в этой сборке движка нет;
|
|
13
|
+
* манифест без него (все опубликованные до этапа 5) доступен по определению.
|
|
14
|
+
* @param {Object} manifest - GameManifest из каталога мастера.
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export function isGameAvailable(manifest) {
|
|
18
|
+
return manifest.compat?.ok !== false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Активная игра вкладки.
|
|
23
|
+
*
|
|
24
|
+
* Обе ветки фильтруются одинаково: сохранённый выбор игры (`gameId`) тоже мог
|
|
25
|
+
* указывать на игру, ставшую недоступной после обновления движка, и брать её
|
|
26
|
+
* «раз уж попросили» значило бы падать там, где можно объяснить.
|
|
27
|
+
* @param {Array<Object>} manifests - Каталог мастера.
|
|
28
|
+
* @param {string} [gameId] - Сохранённый выбор игры (boot.gameId).
|
|
29
|
+
* @returns {Object|undefined} Манифест активной игры.
|
|
30
|
+
* @throws {Error} Если каталог непустой, но играбельной игры в нём нет —
|
|
31
|
+
* с причиной по каждой строке.
|
|
32
|
+
*/
|
|
33
|
+
export function pickActiveGame(manifests, gameId) {
|
|
34
|
+
const active = gameId
|
|
35
|
+
? manifests.find(
|
|
36
|
+
manifest => manifest.id === gameId && isGameAvailable(manifest),
|
|
37
|
+
)
|
|
38
|
+
: manifests.find(isGameAvailable);
|
|
39
|
+
|
|
40
|
+
if (active) {
|
|
41
|
+
return active;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// непустой каталог без единой играбельной строки — сказать это одной
|
|
45
|
+
// понятной фразой, а не отдать наверх первую попавшуюся и упасть в
|
|
46
|
+
// loadClientPlugin непрозрачной ошибкой
|
|
47
|
+
if (manifests.length) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'no playable game in the lobby catalog: ' +
|
|
50
|
+
manifests
|
|
51
|
+
.map(manifest => manifest.compat?.text ?? `"${manifest.id}"`)
|
|
52
|
+
.join('; '),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default pickActiveGame;
|
package/src/client/main.js
CHANGED
|
@@ -26,7 +26,11 @@ import StatCtrl from './components/controller/Stat.js';
|
|
|
26
26
|
import VoteModel from './components/model/Vote.js';
|
|
27
27
|
import VoteView from './components/view/Vote.js';
|
|
28
28
|
import VoteCtrl from './components/controller/Vote.js';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
buildForm,
|
|
31
|
+
mergeRoomDefaults,
|
|
32
|
+
bindLiveErrors,
|
|
33
|
+
} from './lib/formBuilder.js';
|
|
30
34
|
import { normalizeAuthParams } from './lib/authParams.js';
|
|
31
35
|
import { renderProjectLink } from './lib/footerLink.js';
|
|
32
36
|
import { createGameActivator } from './lib/gameActivator.js';
|
|
@@ -37,6 +41,9 @@ import { createContextTracker } from './lib/contextTracker.js';
|
|
|
37
41
|
import { createLocalPlayer } from './lib/localPlayer.js';
|
|
38
42
|
import { createAccolades } from './lib/accolades.js';
|
|
39
43
|
import { dispatchSocketMessage } from './lib/socketDispatch.js';
|
|
44
|
+
import { pickActiveGame, isGameAvailable } from './lib/pickActiveGame.js';
|
|
45
|
+
import { readCoreAbi, dispatchCoreOp, ABI_UNKNOWN } from '../lib/coreAbi.js';
|
|
46
|
+
import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
|
|
40
47
|
import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
|
|
41
48
|
import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
|
|
42
49
|
import {
|
|
@@ -110,13 +117,6 @@ function bindActiveGame(manifest, plugin) {
|
|
|
110
117
|
gameStyleNode.textContent = plugin.styles ?? '';
|
|
111
118
|
}
|
|
112
119
|
|
|
113
|
-
// поле `compat` появляется у манифеста каталога мастера, только когда игра
|
|
114
|
-
// просит возможность, которой в этой сборке движка нет; манифест без него
|
|
115
|
-
// (все опубликованные до этапа 5) доступен по определению
|
|
116
|
-
function isGameAvailable(manifest) {
|
|
117
|
-
return manifest.compat?.ok !== false;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
120
|
// режим загрузки (Этап 2 плана standalone-sdk): lobby — прод с мастером,
|
|
121
121
|
// solo — хост в этой же вкладке (standalone SDK), dedicated — прямой WS к
|
|
122
122
|
// Node-серверу. Ветвлений ровно пять: манифест, сигналинг/лобби, транспорт,
|
|
@@ -154,13 +154,8 @@ try {
|
|
|
154
154
|
throw gamesManifest;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
// недоступная игра
|
|
158
|
-
|
|
159
|
-
// и вкладка встала бы на первой же игре каталога. В список лобби она
|
|
160
|
-
// при этом попадает — с причиной
|
|
161
|
-
activeGameManifest = boot.gameId
|
|
162
|
-
? gamesManifest.find(manifest => manifest.id === boot.gameId)
|
|
163
|
-
: (gamesManifest.find(isGameAvailable) ?? gamesManifest[0]);
|
|
157
|
+
// недоступная игра активной быть не может (lib/pickActiveGame.js)
|
|
158
|
+
activeGameManifest = pickActiveGame(gamesManifest, boot.gameId);
|
|
164
159
|
}
|
|
165
160
|
|
|
166
161
|
if (!activeGameManifest) {
|
|
@@ -294,7 +289,7 @@ let wasm = null;
|
|
|
294
289
|
// читаются один раз при создании ядра, а не в момент вызова. Ядро старше
|
|
295
290
|
// самоописания даёт поколение 0 с пустым списком опкодов: это не ошибка,
|
|
296
291
|
// а игра, собранная до появления механизма (И2 плана plugin-forward-compat)
|
|
297
|
-
let clientCoreAbi =
|
|
292
|
+
let clientCoreAbi = ABI_UNKNOWN;
|
|
298
293
|
|
|
299
294
|
// сервис пула зависимостей: «эта сущность моя или чужая?». Ядро читается
|
|
300
295
|
// геттером — оно создаётся позже пула сервисов (см. lib/localPlayer.js)
|
|
@@ -330,10 +325,7 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
330
325
|
|
|
331
326
|
clientCore = core;
|
|
332
327
|
wasm = { memory };
|
|
333
|
-
clientCoreAbi =
|
|
334
|
-
typeof core.abi_describe === 'function'
|
|
335
|
-
? JSON.parse(core.abi_describe())
|
|
336
|
-
: { abi: 0, core: null, ops: [] };
|
|
328
|
+
clientCoreAbi = readCoreAbi(core, 'client core');
|
|
337
329
|
|
|
338
330
|
// инициализация сущностей игры
|
|
339
331
|
for (const entity of Object.keys(entitiesOnCanvas)) {
|
|
@@ -1152,8 +1144,7 @@ const RESYNC_AFTER_HIDDEN_MS = 3000;
|
|
|
1152
1144
|
|
|
1153
1145
|
// вкладка могла быть скрыта уже в момент навешивания слушателя — события
|
|
1154
1146
|
// 'hidden' тогда не будет, а пауза всё равно идёт
|
|
1155
|
-
let hiddenAt =
|
|
1156
|
-
document.visibilityState === 'hidden' ? performance.now() : null;
|
|
1147
|
+
let hiddenAt = document.visibilityState === 'hidden' ? performance.now() : null;
|
|
1157
1148
|
|
|
1158
1149
|
// обработчик видимости вкладки
|
|
1159
1150
|
function handleVisibilityChange() {
|
|
@@ -1199,18 +1190,22 @@ function handleVisibilityChange() {
|
|
|
1199
1190
|
|
|
1200
1191
|
// дамп клиентского ядра: сначала опкод dispatch, затем замороженный метод.
|
|
1201
1192
|
// Метод не удаляется никогда (И1), поэтому запасной путь остаётся навсегда:
|
|
1202
|
-
// ядро, собранное до появления dispatch, отдаёт дамп
|
|
1193
|
+
// ядро, собранное до появления dispatch, отдаёт дамп по-старому.
|
|
1194
|
+
// dispatchCoreOp — та же точка вызова, что у хостового GameCoreAdapter._op:
|
|
1195
|
+
// имя опкода читается из реестра, три исхода ответа различимы
|
|
1203
1196
|
function clientCoreDebug() {
|
|
1204
1197
|
if (!clientCore) {
|
|
1205
1198
|
return undefined;
|
|
1206
1199
|
}
|
|
1207
1200
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1201
|
+
const { handled, bytes } = dispatchCoreOp(
|
|
1202
|
+
clientCore,
|
|
1203
|
+
clientCoreAbi,
|
|
1204
|
+
ABI_OP_DEBUG_JSON,
|
|
1205
|
+
);
|
|
1210
1206
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
}
|
|
1207
|
+
if (handled && bytes !== null) {
|
|
1208
|
+
return new TextDecoder().decode(bytes);
|
|
1214
1209
|
}
|
|
1215
1210
|
|
|
1216
1211
|
return clientCore.debug_json?.();
|
|
@@ -1413,7 +1408,9 @@ if (isDevBuild) {
|
|
|
1413
1408
|
reportUrl: lobbyConfig.debugReportUrl,
|
|
1414
1409
|
});
|
|
1415
1410
|
|
|
1416
|
-
debugLog(
|
|
1411
|
+
debugLog(
|
|
1412
|
+
'window.__vimpDebug is available: dump, startRecording, stopRecording, divergence',
|
|
1413
|
+
);
|
|
1417
1414
|
}
|
|
1418
1415
|
|
|
1419
1416
|
// WebRTC обязателен для P2P-игры. В Firefox RTCPeerConnection может
|
|
@@ -1944,7 +1941,9 @@ function populateRoomForm(manifest) {
|
|
|
1944
1941
|
|
|
1945
1942
|
// каталог манифестов по id: форма и leaderboard селектора игр, а также
|
|
1946
1943
|
// активация игры перед созданием комнаты и входом в чужую
|
|
1947
|
-
const gamesById = new Map(
|
|
1944
|
+
const gamesById = new Map(
|
|
1945
|
+
gamesManifest.map(manifest => [manifest.id, manifest]),
|
|
1946
|
+
);
|
|
1948
1947
|
|
|
1949
1948
|
// ClientPlugin выбранной игры грузится в момент клика (создание комнаты /
|
|
1950
1949
|
// вход в комнату), а не при смене селектора: просмотр каталога не должен
|
package/src/config/abiOps.js
CHANGED
|
@@ -28,4 +28,10 @@ export const abiOps = createRegistry('abiOps', [
|
|
|
28
28
|
// плоский список активных опкодов — то, что вправе позвать движок
|
|
29
29
|
export const ABI_OPS = abiOps.values();
|
|
30
30
|
|
|
31
|
+
// Именованные константы вместо литералов в местах вызова: строка 'debug.json',
|
|
32
|
+
// написанная руками у хоста и у клиента, обходила бы реестр молча — новый
|
|
33
|
+
// опкод уехал бы в прод, не попав ни в слепок поверхности, ни в
|
|
34
|
+
// CHANGELOG. Имя опкода читается ТОЛЬКО отсюда.
|
|
35
|
+
export const ABI_OP_DEBUG_JSON = 'debug.json';
|
|
36
|
+
|
|
31
37
|
export default abiOps;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { ERROR, skip, verdict } from '../result.js';
|
|
2
|
-
import {
|
|
1
|
+
import { ERROR, WARN, skip, verdict } from '../result.js';
|
|
2
|
+
import {
|
|
3
|
+
ENGINE_CAPABILITIES,
|
|
4
|
+
CAPABILITIES,
|
|
5
|
+
} from '../../../lib/capabilities.js';
|
|
3
6
|
|
|
4
7
|
// `engineApi` живёт в трёх местах (манифест, HostPlugin, ClientPlugin).
|
|
5
8
|
// Расхождение между ними — рассинхрон сборки внутри пакета, и это ошибка.
|
|
@@ -34,6 +37,7 @@ export default {
|
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
const violations = [];
|
|
40
|
+
const retired = [];
|
|
37
41
|
const [source, reference] = declared[0];
|
|
38
42
|
|
|
39
43
|
for (const [where, value] of declared) {
|
|
@@ -56,15 +60,59 @@ export default {
|
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
// форма `requires` — то же недоверие, что в checkPluginCompatibility:
|
|
64
|
+
// строка проитерировалась бы здесь посимвольно, объект уронил бы чекер
|
|
65
|
+
// «not iterable»
|
|
66
|
+
const requires = ctx.manifest?.requires;
|
|
67
|
+
|
|
68
|
+
if (requires === undefined || requires === null) {
|
|
69
|
+
return finish(violations, retired);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
!Array.isArray(requires) ||
|
|
74
|
+
requires.some(name => typeof name !== 'string')
|
|
75
|
+
) {
|
|
76
|
+
violations.push(
|
|
77
|
+
'manifest.requires must be an array of capability names, got ' +
|
|
78
|
+
`${JSON.stringify(requires)} — the engine reads it before it ` +
|
|
79
|
+
'loads the plugin',
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return finish(violations, retired);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const name of requires) {
|
|
86
|
+
// has, а не CAPABILITIES.includes: реестр возможностей append-only,
|
|
87
|
+
// и выведенное алиасом имя движок принимает вечно (ENGINE_CAPABILITIES.
|
|
88
|
+
// has в checkPluginCompatibility). Сверка с одними активными именами
|
|
89
|
+
// отвергала бы игру за то, что движок переименовал возможность — тот
|
|
90
|
+
// самый отказ по возрасту, который план и снимал
|
|
91
|
+
if (!ENGINE_CAPABILITIES.has(name)) {
|
|
61
92
|
violations.push(
|
|
62
93
|
`manifest.requires names '${name}', which this engine does not ` +
|
|
63
94
|
`provide — known capabilities: ${CAPABILITIES.join(', ')}`,
|
|
64
95
|
);
|
|
96
|
+
} else if (ENGINE_CAPABILITIES.isRetired(name)) {
|
|
97
|
+
retired.push(
|
|
98
|
+
`manifest.requires names '${name}', which was retired — it still ` +
|
|
99
|
+
`works (the engine resolves it to ` +
|
|
100
|
+
`'${ENGINE_CAPABILITIES.resolve(name)}' forever), but a new game ` +
|
|
101
|
+
`should declare '${ENGINE_CAPABILITIES.resolve(name)}' itself`,
|
|
102
|
+
);
|
|
65
103
|
}
|
|
66
104
|
}
|
|
67
105
|
|
|
68
|
-
return
|
|
106
|
+
return finish(violations, retired);
|
|
69
107
|
},
|
|
70
108
|
};
|
|
109
|
+
|
|
110
|
+
// то же, что делают B5 и C10: выведенное имя — не отказ, а предупреждение.
|
|
111
|
+
// Отвергать за него значило бы отвергать игру за возраст (И1)
|
|
112
|
+
function finish(violations, retired) {
|
|
113
|
+
if (violations.length === 0 && retired.length > 0) {
|
|
114
|
+
return verdict(retired, 'retired capability names still resolve', WARN);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return verdict(violations);
|
|
118
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { ERROR, skip, verdict } from '../result.js';
|
|
1
|
+
import { ERROR, WARN, skip, verdict } from '../result.js';
|
|
2
2
|
import { resolveValidator } from '../../../lib/validators.js';
|
|
3
|
+
import {
|
|
4
|
+
formControls,
|
|
5
|
+
ACTIVE_FORM_CONTROLS,
|
|
6
|
+
} from '../../../lib/formControls.js';
|
|
3
7
|
|
|
4
8
|
// authSchema. Четыре ошибки, каждая из которых уже случалась:
|
|
5
9
|
// formId вместо fieldsId (контейнер резолвится в null и экран авторизации
|
|
@@ -24,6 +28,7 @@ export default {
|
|
|
24
28
|
|
|
25
29
|
const { elems = {}, params = [] } = ctx.authSchema;
|
|
26
30
|
const violations = [];
|
|
31
|
+
const retired = [];
|
|
27
32
|
|
|
28
33
|
if (elems.formId !== undefined) {
|
|
29
34
|
violations.push(
|
|
@@ -48,6 +53,24 @@ export default {
|
|
|
48
53
|
);
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
const control = field.options?.control;
|
|
57
|
+
|
|
58
|
+
if (control !== undefined && !formControls.has(control)) {
|
|
59
|
+
violations.push(
|
|
60
|
+
`authSchema param "${field.name}": control "${control}" does not ` +
|
|
61
|
+
`exist (${ACTIVE_FORM_CONTROLS.join(', ')}) — the form throws ` +
|
|
62
|
+
"'unknown control' and the auth screen never renders",
|
|
63
|
+
);
|
|
64
|
+
} else if (control !== undefined && formControls.isRetired(control)) {
|
|
65
|
+
retired.push(
|
|
66
|
+
`authSchema param "${field.name}": control "${control}" was ` +
|
|
67
|
+
`retired in plugin API v${formControls.get(control).retiredIn} — ` +
|
|
68
|
+
`it still works (the engine builds and validates it as ` +
|
|
69
|
+
`"${formControls.resolve(control)}" forever), but a new game ` +
|
|
70
|
+
`should declare "${formControls.resolve(control)}" itself`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
const validatorName = field.options?.validator;
|
|
52
75
|
|
|
53
76
|
// опечатка в имени (как и не-функция под верным именем) = поле не
|
|
@@ -73,6 +96,16 @@ export default {
|
|
|
73
96
|
);
|
|
74
97
|
}
|
|
75
98
|
|
|
99
|
+
// то же, что делает B5 для roomForm: выведенный контрол — не отказ, а
|
|
100
|
+
// предупреждение, иначе правило отвергало бы игру за возраст (И1)
|
|
101
|
+
if (violations.length === 0 && retired.length > 0) {
|
|
102
|
+
return verdict(
|
|
103
|
+
retired,
|
|
104
|
+
'retired controls still build and validate, via aliases',
|
|
105
|
+
WARN,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
76
109
|
return verdict(violations);
|
|
77
110
|
},
|
|
78
111
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
|
|
2
|
+
import { readCoreAbi, dispatchCoreOp } from '../lib/coreAbi.js';
|
|
1
3
|
// Адаптер Rust-ядра (GameCore) под интерфейс, который потребляют мета-модули
|
|
2
4
|
// хоста (RoundManager, SocketManager) и host-фасад. За поверхностью Game.js
|
|
3
5
|
// (+ упаковка снапшотов) стоит WASM-ядро; события ядра (take_events) несут
|
|
@@ -10,9 +12,6 @@
|
|
|
10
12
|
// (spawn_scripted_actor/remove_scripted_actor), человек — только танк
|
|
11
13
|
// (spawn_actor/remove_actor).
|
|
12
14
|
|
|
13
|
-
// пустая нагрузка опкода: ядро ждёт байты всегда, даже когда их нет
|
|
14
|
-
const EMPTY_PAYLOAD = new Uint8Array(0);
|
|
15
|
-
|
|
16
15
|
export default class GameCoreAdapter {
|
|
17
16
|
/**
|
|
18
17
|
* @param {GameCore} core - экземпляр WASM-ядра.
|
|
@@ -28,14 +27,11 @@ export default class GameCoreAdapter {
|
|
|
28
27
|
this._onCoreEvent = onCoreEvent;
|
|
29
28
|
this._services = {}; // { vimp, panel } — инъекция как у Game.js
|
|
30
29
|
|
|
31
|
-
// Метода нет — ядро собрано до появления
|
|
32
|
-
// поколение 0, ни одного опционального опкода
|
|
33
|
-
// здесь, а не при вызове: ветку упаковки, поле
|
|
34
|
-
// движок выбирает заранее, а не посреди матча.
|
|
35
|
-
this._abi =
|
|
36
|
-
typeof core.abi_describe === 'function'
|
|
37
|
-
? JSON.parse(core.abi_describe())
|
|
38
|
-
: { abi: 0, core: null, ops: [] };
|
|
30
|
+
// Метода нет (или самоописание нечитаемо) — ядро собрано до появления
|
|
31
|
+
// механизма. Это не ошибка: поколение 0, ни одного опционального опкода
|
|
32
|
+
// (И2). Читается один раз здесь, а не при вызове: ветку упаковки, поле
|
|
33
|
+
// формы или ответ лобби движок выбирает заранее, а не посреди матча.
|
|
34
|
+
this._abi = readCoreAbi(core, 'game core');
|
|
39
35
|
}
|
|
40
36
|
|
|
41
37
|
/**
|
|
@@ -237,10 +233,10 @@ export default class GameCoreAdapter {
|
|
|
237
233
|
* @returns {Object|null}
|
|
238
234
|
*/
|
|
239
235
|
debugJson() {
|
|
240
|
-
const
|
|
236
|
+
const { handled, bytes } = this._op(ABI_OP_DEBUG_JSON);
|
|
241
237
|
|
|
242
|
-
if (
|
|
243
|
-
return JSON.parse(new TextDecoder().decode(
|
|
238
|
+
if (handled && bytes !== null) {
|
|
239
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
244
240
|
}
|
|
245
241
|
|
|
246
242
|
// ядро старше опкода, но с замороженным методом — метод не удаляется
|
|
@@ -257,18 +253,22 @@ export default class GameCoreAdapter {
|
|
|
257
253
|
* ядра: прямой вызов нового метода на `this._core` запрещён (И2) — у
|
|
258
254
|
* ядра, собранного год назад, его нет и не будет. ESLint стережёт это
|
|
259
255
|
* правилом no-restricted-syntax.
|
|
256
|
+
*
|
|
257
|
+
* Исходов ровно три, и они РАЗЛИЧИМЫ (соглашение `abi::dispatch_result`,
|
|
258
|
+
* core/src/abi.rs): пустой ответ — «опкод не понят», маркер `[0x00]` —
|
|
259
|
+
* «понят, ответа нет», иначе — полезные байты. Схлопывать первые два в
|
|
260
|
+
* один `null` нельзя: опкод-команда (ради них механизм и делался) отдавала
|
|
261
|
+
* бы вызывающему сырой `Uint8Array [0]`, который поехал бы в TextDecoder
|
|
262
|
+
* и JSON.parse.
|
|
260
263
|
* @param {string} op - Опкод из config/abiOps.js.
|
|
261
264
|
* @param {Uint8Array} [payload] - Полезная нагрузка опкода.
|
|
262
|
-
* @returns {Uint8Array|null}
|
|
265
|
+
* @returns {{handled: boolean, bytes: Uint8Array|null}} `handled: false` —
|
|
266
|
+
* ядро опкода не знает (вызывающий идёт по запасному пути);
|
|
267
|
+
* `handled: true, bytes: null` — обработано без ответа.
|
|
268
|
+
* @see lib/coreAbi.js — то же для клиентского ядра
|
|
263
269
|
*/
|
|
264
|
-
_op(op, payload
|
|
265
|
-
|
|
266
|
-
return null;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const out = this._core.dispatch(op, payload);
|
|
270
|
-
|
|
271
|
-
return out.length === 0 ? null : out;
|
|
270
|
+
_op(op, payload) {
|
|
271
|
+
return dispatchCoreOp(this._core, this._abi, op, payload);
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
// бот ли участник (спавн/удаление в ядре различаются)
|
|
@@ -8,7 +8,9 @@ import { createGameConfigView } from './gameConfigView.js';
|
|
|
8
8
|
* @param {Object} [room] - Переопределения комнаты.
|
|
9
9
|
* @param {Object} plugin - HostPlugin игры.
|
|
10
10
|
* @param {Object} [view] - Готовая gameConfig-view (createHostRuntime строит
|
|
11
|
-
* её один раз на прогон)
|
|
11
|
+
* её один раз на прогон). Путь по умолчанию — ТЕСТОВЫЙ: он строит вторую
|
|
12
|
+
* view, и deriveSpectatorTeam предупреждает в консоль дважды за прогон.
|
|
13
|
+
* Прод-вызов всегда передаёт готовую.
|
|
12
14
|
* @returns {Object} Конфиг матча: движковые дефолты + игровая половина.
|
|
13
15
|
*/
|
|
14
16
|
export function applyRoomOverrides(
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Единственная точка чтения самоописания wasm-ядра (`abi_describe`).
|
|
2
|
+
//
|
|
3
|
+
// Фрагмент жил в трёх местах — конструкторе GameCoreAdapter и двух местах
|
|
4
|
+
// client/main.js — и дефолт `{abi: 0, core: null, ops: []}` был написан
|
|
5
|
+
// руками трижды: разъехавшись, они дали бы `ops === undefined` и TypeError
|
|
6
|
+
// в первом же вызове опкода.
|
|
7
|
+
//
|
|
8
|
+
// Плюс `JSON.parse` не был обёрнут. Ядро, отдающее не-JSON, роняло
|
|
9
|
+
// конструктор адаптера и (на клиенте) async-обработчик PS_CONFIG_DATA —
|
|
10
|
+
// невыловленным промисом, после которого конфиг не применяется вовсе. Это
|
|
11
|
+
// тот же режим «получатель падает от того, что отправитель другой», который
|
|
12
|
+
// на JSON-портах уже вылечен веткой по умолчанию
|
|
13
|
+
// (client/lib/socketDispatch.js): битое самоописание обязано читаться как
|
|
14
|
+
// поколение 0, а не как отказ движка.
|
|
15
|
+
|
|
16
|
+
import { abiOps } from '../config/abiOps.js';
|
|
17
|
+
|
|
18
|
+
// пустая нагрузка опкода: ядро ждёт байты всегда, даже когда их нет
|
|
19
|
+
const EMPTY_PAYLOAD = new Uint8Array(0);
|
|
20
|
+
|
|
21
|
+
// «опкод не обработан»: ядро его не знает либо вернуло пустой вектор
|
|
22
|
+
// (соглашение abi::dispatch_result в крейте)
|
|
23
|
+
const NOT_HANDLED = Object.freeze({ handled: false, bytes: null });
|
|
24
|
+
|
|
25
|
+
// ядро старше самоописания: ни одного опционального опкода (И2 плана
|
|
26
|
+
// plugin-forward-compat). Один объект на все такие ядра — он заморожен и
|
|
27
|
+
// никем не правится
|
|
28
|
+
export const ABI_UNKNOWN = Object.freeze({ abi: 0, core: null, ops: [] });
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Читает самоописание ядра: версия формата, версия движкового крейта,
|
|
32
|
+
* список опкодов `dispatch`.
|
|
33
|
+
* @param {Object} core - Экземпляр wasm-ядра (игрового или клиентского).
|
|
34
|
+
* @param {string} [label] - Имя ядра в тексте предупреждения.
|
|
35
|
+
* @returns {{abi: number, core: string|null, ops: Array<string>}} Всегда
|
|
36
|
+
* пригодный к чтению объект: `abi: 0` с пустым `ops` — ядро старше
|
|
37
|
+
* механизма ЛИБО его самоописание нечитаемо.
|
|
38
|
+
*/
|
|
39
|
+
export function readCoreAbi(core, label = 'core') {
|
|
40
|
+
if (typeof core?.abi_describe !== 'function') {
|
|
41
|
+
return ABI_UNKNOWN;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let described;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
described = JSON.parse(core.abi_describe());
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.warn(`${label}: abi_describe is not JSON (${err.message})`);
|
|
50
|
+
|
|
51
|
+
return ABI_UNKNOWN;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// нормализация, а не проверка: движок обязан продолжить работу на любом
|
|
55
|
+
// содержимом. Не-строки из ops выбрасываются — по ним всё равно нечего
|
|
56
|
+
// диспетчеризовать
|
|
57
|
+
return {
|
|
58
|
+
abi: Number.isInteger(described?.abi) ? described.abi : 0,
|
|
59
|
+
core: typeof described?.core === 'string' ? described.core : null,
|
|
60
|
+
ops: Array.isArray(described?.ops)
|
|
61
|
+
? described.ops.filter(op => typeof op === 'string')
|
|
62
|
+
: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default readCoreAbi;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Зовёт необязательную возможность ядра опкодом. Единственное место, где
|
|
70
|
+
* движок вызывает `dispatch` — и игровой половины, и клиентской: таблица
|
|
71
|
+
* экспортов wasm заморожена (И1/И3), поэтому новая возможность приезжает
|
|
72
|
+
* строкой, а не новым символом.
|
|
73
|
+
*
|
|
74
|
+
* Исходов ровно три, и они РАЗЛИЧИМЫ (соглашение `abi::dispatch_result`,
|
|
75
|
+
* core/src/abi.rs): пустой ответ — «опкод не понят», маркер `[0x00]` —
|
|
76
|
+
* «понят, ответа нет», иначе — полезные байты. Схлопывать первые два в один
|
|
77
|
+
* `null` нельзя: опкод-команда (ради них механизм и делался) отдавала бы
|
|
78
|
+
* вызывающему сырой `Uint8Array [0]`, который поехал бы в TextDecoder и
|
|
79
|
+
* JSON.parse.
|
|
80
|
+
* @param {Object} core - Экземпляр wasm-ядра.
|
|
81
|
+
* @param {Object} abi - Результат readCoreAbi для этого ядра.
|
|
82
|
+
* @param {string} op - Опкод из config/abiOps.js.
|
|
83
|
+
* @param {Uint8Array} [payload] - Полезная нагрузка опкода.
|
|
84
|
+
* @returns {{handled: boolean, bytes: Uint8Array|null}} `handled: false` —
|
|
85
|
+
* ядро опкода не знает (вызывающий идёт по запасному пути);
|
|
86
|
+
* `handled: true, bytes: null` — обработано без ответа.
|
|
87
|
+
*/
|
|
88
|
+
export function dispatchCoreOp(core, abi, op, payload = EMPTY_PAYLOAD) {
|
|
89
|
+
// опкод вне реестра — дефект ДВИЖКА, а не старого ядра: имя, которого нет
|
|
90
|
+
// в config/abiOps.js, не попадает ни в слепок поверхности, ни в CHANGELOG.
|
|
91
|
+
// Падаем сразу, а не на игре, которая его однажды поймёт
|
|
92
|
+
if (!abiOps.has(op)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`dispatchCoreOp: unknown opcode "${op}" — declare it in ` +
|
|
95
|
+
'config/abiOps.js (append-only, И1)',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const resolved = abiOps.resolve(op);
|
|
100
|
+
|
|
101
|
+
if (!abi.ops.includes(resolved)) {
|
|
102
|
+
return NOT_HANDLED;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const out = core.dispatch(resolved, payload);
|
|
106
|
+
|
|
107
|
+
if (out.length === 0) {
|
|
108
|
+
return NOT_HANDLED;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// [0x00] — «обработан, ответа нет»: полезных байтов у него нет
|
|
112
|
+
return {
|
|
113
|
+
handled: true,
|
|
114
|
+
bytes: out.length === 1 && out[0] === 0x00 ? null : out,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Единица измерения числового поля формы: `unit: 's'` значит, что игра
|
|
2
|
+
// хранит значение в миллисекундах, а игрок видит и вводит секунды.
|
|
3
|
+
//
|
|
4
|
+
// Определение общее на движок (как anchorPattern и normalizeOptions рядом):
|
|
5
|
+
// конвертацию делают и билдер формы (client/lib/formBuilder.js), и
|
|
6
|
+
// авторитетная валидация хоста (lib/validators.js) — min/max дескриптора
|
|
7
|
+
// объявлены в единице ОТОБРАЖЕНИЯ, а по сети едет единица хранения. Две
|
|
8
|
+
// копии этого правила разъехались бы молча: форма пропускала бы то, что
|
|
9
|
+
// отбивает хост, или наоборот.
|
|
10
|
+
|
|
11
|
+
/** Значение хранения → значение, в котором объявлены min/max и default. */
|
|
12
|
+
export function toDisplay(descriptor, value) {
|
|
13
|
+
return descriptor.unit === 's' ? value / 1000 : value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Обратное преобразование: то, что вводит игрок → то, что едет на хост. */
|
|
17
|
+
export function toStored(descriptor, value) {
|
|
18
|
+
return descriptor.unit === 's' ? value * 1000 : value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// числовое text-поле: unit задан или numeric:true. Правило одно на билдер и
|
|
22
|
+
// на валидацию — иначе поле строится числовым, а проверяется как текст
|
|
23
|
+
export function isNumericField(descriptor) {
|
|
24
|
+
return descriptor?.numeric === true || descriptor?.unit !== undefined;
|
|
25
|
+
}
|
|
@@ -126,7 +126,10 @@ function setPath(target, dottedPath, value) {
|
|
|
126
126
|
* Строит представление gameConfig с умолчаниями и проверяет обязательное.
|
|
127
127
|
* @param {Object} gameConfig - HostPlugin.gameConfig игры как есть.
|
|
128
128
|
* @param {string} [gameId] - id плагина; попадает в текст ошибок.
|
|
129
|
-
* @returns {Object}
|
|
129
|
+
* @returns {Object} Конфиг: поля игры плюс умолчания движка. Заморожен
|
|
130
|
+
* ПОВЕРХНОСТНО — вложенные ветки (parts, roomDefaults) правятся; глубокая
|
|
131
|
+
* заморозка стоила бы обхода всего конфига игры на каждом старте матча, а
|
|
132
|
+
* единственный потребитель (applyRoomOverrides) и так делает structuredClone.
|
|
130
133
|
* @throws {Error} Если нет поля из REQUIRED_GAME_CONFIG_PATHS или конфиг
|
|
131
134
|
* внутренне противоречив (spectatorTeam вне teams, noSpectators при двух
|
|
132
135
|
* командах).
|
package/src/lib/gamePlugin.js
CHANGED
|
@@ -42,8 +42,30 @@ export async function fetchGameManifest(url) {
|
|
|
42
42
|
// правильная реакция — каталог помечает игру недоступной и продолжает
|
|
43
43
|
// раздавать остальные, остальные три бросают.
|
|
44
44
|
export function checkPluginCompatibility(manifest) {
|
|
45
|
-
const wanted = manifest.requires
|
|
46
|
-
|
|
45
|
+
const wanted = manifest.requires;
|
|
46
|
+
|
|
47
|
+
// `requires` пишет ЧУЖОЙ репозиторий игры, и его форме нельзя доверять:
|
|
48
|
+
// строка или объект вместо массива давали здесь TypeError, который уходил
|
|
49
|
+
// из конструктора GameCatalog и не давал стартовать мастеру целиком —
|
|
50
|
+
// одна битая игра уносила весь каталог. Битый манифест обязан вести себя
|
|
51
|
+
// как несовместимый (вердикт), а не как краш движка
|
|
52
|
+
if (wanted !== undefined && wanted !== null) {
|
|
53
|
+
if (
|
|
54
|
+
!Array.isArray(wanted) ||
|
|
55
|
+
wanted.some(name => typeof name !== 'string')
|
|
56
|
+
) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
reason: 'bad-manifest',
|
|
60
|
+
missing: [],
|
|
61
|
+
text:
|
|
62
|
+
`game "${manifest.id}": manifest.requires must be an array of ` +
|
|
63
|
+
'capability names — rebuild the game package',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const missing = (wanted ?? []).filter(name => !ENGINE_CAPABILITIES.has(name));
|
|
47
69
|
|
|
48
70
|
if (missing.length === 0) {
|
|
49
71
|
return { ok: true };
|
package/src/lib/validators.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { anchorPattern } from './formPattern.js';
|
|
2
2
|
import { normalizeOptions } from './formOptions.js';
|
|
3
|
+
import { resolveDescriptor } from './formControls.js';
|
|
4
|
+
import { toDisplay, isNumericField } from './formUnit.js';
|
|
3
5
|
|
|
4
6
|
const NAME_REGEXP = new RegExp('^[a-zA-Z]([\\w\\s#]{0,13})[\\w]{1}$');
|
|
5
7
|
|
|
@@ -30,6 +32,33 @@ const OPTION_CONTROLS = ['select', 'radio'];
|
|
|
30
32
|
// умолчанию — text (тот же дефолт, что у билдера формы)
|
|
31
33
|
const isTextControl = control => control === 'text' || control === undefined;
|
|
32
34
|
|
|
35
|
+
// числовое поле (control 'text' + numeric/unit — в том числе разрешённые из
|
|
36
|
+
// выведенных 'number' и 'range'). Форма отдаёт его ЧИСЛОМ в единице
|
|
37
|
+
// хранения (formBuilder.buildText: getValue → toStored(Number(...))), а не
|
|
38
|
+
// строкой, поэтому оно идёт отдельной веткой до проверки типа
|
|
39
|
+
const isNumericControl = options =>
|
|
40
|
+
isTextControl(options?.control) && isNumericField(options);
|
|
41
|
+
|
|
42
|
+
// min/max дескриптора объявлены в единице ОТОБРАЖЕНИЯ, а по сети едет
|
|
43
|
+
// единица хранения — сравниваем ровно так же, как validateField на клиенте
|
|
44
|
+
const numericError = (options, value) => {
|
|
45
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
46
|
+
return 'must be a number';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const shown = toDisplay(options, value);
|
|
50
|
+
|
|
51
|
+
if (options.min !== undefined && shown < options.min) {
|
|
52
|
+
return `must be >= ${options.min}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (options.max !== undefined && shown > options.max) {
|
|
56
|
+
return `must be <= ${options.max}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
|
|
33
62
|
// source-варианты хост не резолвит (их и форма в auth не резолвит: она
|
|
34
63
|
// строится с пустым ctx) — сверяем только объявленный inline-список.
|
|
35
64
|
// String(): и <option>.value, и <input type=radio>.value — DOM-свойства,
|
|
@@ -76,6 +105,26 @@ export const resolveValidator = (name, validators = {}) => {
|
|
|
76
105
|
return typeof fn === 'function' ? fn : undefined;
|
|
77
106
|
};
|
|
78
107
|
|
|
108
|
+
// игровой валидатор поля (authSchema.validators). Нерезолвнутое имя
|
|
109
|
+
// (опечатка) и не-функция ведут себя одинаково — поле проходит. Для клиента
|
|
110
|
+
// это норма (игровые валидаторы к нему не едут, авторитет проверки на
|
|
111
|
+
// хосте), для хоста — дефект схемы, о котором говорят C10 и console.error в
|
|
112
|
+
// PortMachine: звать что попало нельзя, TypeError отсюда уходит прямо в
|
|
113
|
+
// обработчик сообщения
|
|
114
|
+
const gameValidatorError = (options, value, validators) => {
|
|
115
|
+
if (options?.validator === undefined) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const validatorFn = resolveValidator(options.validator, validators);
|
|
120
|
+
|
|
121
|
+
return validatorFn && !validatorFn(value) ? 'not valid' : null;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// null → ничего не добавляем: errors.push(...withName(...)) читается одной
|
|
125
|
+
// строкой на обеих ветках поля
|
|
126
|
+
const withName = (name, error) => (error === null ? [] : [{ name, error }]);
|
|
127
|
+
|
|
79
128
|
/**
|
|
80
129
|
* Валидирует объект с данными для авторизации.
|
|
81
130
|
* @param {object} data - Объект с данными для проверки.
|
|
@@ -87,13 +136,38 @@ export const resolveValidator = (name, validators = {}) => {
|
|
|
87
136
|
export const validateAuth = (data, authParams, validators = {}) => {
|
|
88
137
|
const errors = [];
|
|
89
138
|
|
|
90
|
-
for (const { name, options } of authParams) {
|
|
139
|
+
for (const { name, options: declared } of authParams) {
|
|
91
140
|
if (!(name in data)) {
|
|
92
141
|
return [{ name, error: `Property is missing` }];
|
|
93
142
|
}
|
|
94
143
|
|
|
144
|
+
// Тот же резолв алиаса, что делает билдер формы (client/lib/formBuilder.js:
|
|
145
|
+
// buildField, collectFormErrors, resolveForcedValue). Без него контрол,
|
|
146
|
+
// выведенный из эксплуатации в v3 ('segmented', 'number', 'range',
|
|
147
|
+
// 'toggle'), не совпадает ни с одним именем ниже, и клиент, обошедший
|
|
148
|
+
// форму, получает поле ВООБЩЕ без проверок — ровно то превосходство над
|
|
149
|
+
// заполнившим форму, которого здесь быть не должно. Алиасы стали
|
|
150
|
+
// достижимы вместе с реестром контролов (этап 3 plugin-forward-compat):
|
|
151
|
+
// до него такое поле не строилось у клиента и сюда не доезжало
|
|
152
|
+
const options = resolveDescriptor(declared);
|
|
95
153
|
const value = data[name];
|
|
96
154
|
|
|
155
|
+
// Числовое поле идёт своей веткой: форма отдаёт его числом, и общий
|
|
156
|
+
// путь ниже (длина, список вариантов, regExp) к нему не применим —
|
|
157
|
+
// проверяется диапазон, ровно как validateField делает на клиенте.
|
|
158
|
+
// Игровой валидатор зовётся для обоих видов поля одинаково
|
|
159
|
+
if (isNumericControl(options)) {
|
|
160
|
+
const error = numericError(options, value);
|
|
161
|
+
|
|
162
|
+
errors.push(
|
|
163
|
+
...withName(
|
|
164
|
+
name,
|
|
165
|
+
error ?? gameValidatorError(options, value, validators),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
97
171
|
if (typeof value !== 'string') {
|
|
98
172
|
return [{ name, error: `Property must be a string` }];
|
|
99
173
|
}
|
|
@@ -103,10 +177,7 @@ export const validateAuth = (data, authParams, validators = {}) => {
|
|
|
103
177
|
// не должен получать больше прав, чем клиент, её заполнивший. Пустое
|
|
104
178
|
// значение пропускается ровно как на клиенте (required здесь не
|
|
105
179
|
// проверяется: solo-путь boot.autoAuth отвечает дефолтами схемы, среди
|
|
106
|
-
// которых бывает '') — пустота остаётся делом игрового
|
|
107
|
-
// min/max формы здесь не применяются и не нужны: числовое поле отдаёт из
|
|
108
|
-
// формы число, а нестроковое значение отбито выше, то есть числовых
|
|
109
|
-
// полей в authSchema не бывает вовсе
|
|
180
|
+
// которых бывает '') — пустота остаётся делом игрового валидатора
|
|
110
181
|
|
|
111
182
|
// длина — первой и безусловно: потолок ограничивает не столько ввод,
|
|
112
183
|
// сколько работу паттерна ниже (см. MAX_FIELD_LENGTH)
|
|
@@ -142,18 +213,9 @@ export const validateAuth = (data, authParams, validators = {}) => {
|
|
|
142
213
|
continue;
|
|
143
214
|
}
|
|
144
215
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// нерезолвнутое имя (опечатка) и не-функция ведут себя одинаково —
|
|
149
|
-
// поле проходит. Для клиента это норма (игровые валидаторы к нему не
|
|
150
|
-
// едут, авторитет проверки на хосте), для хоста — дефект схемы, о
|
|
151
|
-
// котором говорят C10 и console.error в PortMachine: звать что попало
|
|
152
|
-
// нельзя, TypeError отсюда уходит прямо в обработчик сообщения
|
|
153
|
-
if (validatorFn && !validatorFn(value)) {
|
|
154
|
-
errors.push({ name, error: 'not valid' });
|
|
155
|
-
}
|
|
156
|
-
}
|
|
216
|
+
errors.push(
|
|
217
|
+
...withName(name, gameValidatorError(options, value, validators)),
|
|
218
|
+
);
|
|
157
219
|
}
|
|
158
220
|
|
|
159
221
|
return errors.length ? errors : undefined;
|
package/src/standalone/index.js
CHANGED
|
@@ -36,6 +36,10 @@ import { ensureGameShell } from '../client/views/gameShell.js';
|
|
|
36
36
|
* голосований, например `['/bot 4']`.
|
|
37
37
|
* @param {Object} [options.room] - Переопределения комнаты (map, maxPlayers,
|
|
38
38
|
* roundTime, friendlyFire, seed).
|
|
39
|
+
* @param {Array<string>} [options.requires] - Возможности движка, без
|
|
40
|
+
* которых игра не запустится (аналог `GameManifest.requires`, которого в
|
|
41
|
+
* solo-режиме нет). По умолчанию — объединение `requires` обеих половин
|
|
42
|
+
* плагина.
|
|
39
43
|
* @param {boolean} [options.devMode] - room.isDevMode: рекордер и хостовый
|
|
40
44
|
* CONSOLE-лог.
|
|
41
45
|
* @returns {Promise<Object>} `{ stop() }` — останов матча.
|
|
@@ -52,6 +56,7 @@ export async function startStandaloneGame({
|
|
|
52
56
|
startupVotes = [],
|
|
53
57
|
startupCommands = [],
|
|
54
58
|
room = {},
|
|
59
|
+
requires,
|
|
55
60
|
devMode = false,
|
|
56
61
|
} = {}) {
|
|
57
62
|
requireOption(hostPlugin, 'hostPlugin');
|
|
@@ -59,9 +64,19 @@ export async function startStandaloneGame({
|
|
|
59
64
|
requireOption(wasmUrl, 'wasmUrl');
|
|
60
65
|
|
|
61
66
|
// возможность, которой в этой сборке движка нет, иначе всплыла бы где-то
|
|
62
|
-
// в середине хендшейка непрозрачной
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
// в середине хендшейка непрозрачной ошибкой. Мастера тут нет, а значит нет
|
|
68
|
+
// и GameManifest с его `requires` — источником служат сами половины
|
|
69
|
+
// плагина (необязательное поле `requires`, docs/en/plugin-api.md) либо
|
|
70
|
+
// опция вызова, если встраивающий знает лучше. Одна проверка вместо двух:
|
|
71
|
+
// сообщение адресовано разработчику встраивания, делить его по половинам
|
|
72
|
+
// незачем
|
|
73
|
+
requireCompatible({
|
|
74
|
+
id: hostPlugin.id,
|
|
75
|
+
requires: requires ?? [
|
|
76
|
+
...(hostPlugin.requires ?? []),
|
|
77
|
+
...(clientPlugin.requires ?? []),
|
|
78
|
+
],
|
|
79
|
+
});
|
|
65
80
|
|
|
66
81
|
// одна view на прогон: умолчания вместо обязательных полей (И2 этапа 2)
|
|
67
82
|
const configView = assertGameConfigShape(hostPlugin);
|
|
@@ -97,8 +112,8 @@ export async function startStandaloneGame({
|
|
|
97
112
|
};
|
|
98
113
|
}
|
|
99
114
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
115
|
+
// игра просит возможность, которой в этой сборке движка нет: подменить её в
|
|
116
|
+
// SDK нечем — матч не поднимется
|
|
102
117
|
function requireCompatible(plugin) {
|
|
103
118
|
const compat = checkPluginCompatibility(plugin);
|
|
104
119
|
|