vimp-engine 0.23.0 → 0.24.1
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 +123 -6
- package/src/devtools/contract/rules/c10-auth-schema.js +51 -3
- package/src/host/GameCoreAdapter.js +23 -22
- package/src/lib/applyRoomOverrides.js +3 -1
- package/src/lib/coreAbi.js +149 -0
- package/src/lib/formUnit.js +25 -0
- package/src/lib/gameConfigView.js +4 -1
- package/src/lib/gamePlugin.js +45 -2
- package/src/lib/loadGamePackage.js +68 -0
- package/src/lib/validators.js +114 -24
- package/src/standalone/index.js +23 -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.2"
|
|
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
|
// Расхождение между ними — рассинхрон сборки внутри пакета, и это ошибка.
|
|
@@ -14,11 +17,16 @@ import { CAPABILITIES } from '../../../lib/capabilities.js';
|
|
|
14
17
|
//
|
|
15
18
|
// Возможности из `manifest.requires` проверяются по реестру установленного
|
|
16
19
|
// движка: имени, которого нет, движок дать не может — игра просит будущее.
|
|
20
|
+
// `requires` при этом живёт в трёх местах пакета (манифест + обе половины
|
|
21
|
+
// плагина), и их расхождение — такой же рассинхрон сборки, как расхождение
|
|
22
|
+
// `engineApi`.
|
|
17
23
|
export default {
|
|
18
24
|
id: 'B2',
|
|
19
25
|
name: 'engineApiVersion',
|
|
20
26
|
level: ERROR,
|
|
21
|
-
title:
|
|
27
|
+
title:
|
|
28
|
+
'engineApi is consistent, requires names existing capabilities and ' +
|
|
29
|
+
'agrees between the manifest and both plugin halves',
|
|
22
30
|
|
|
23
31
|
check(ctx) {
|
|
24
32
|
// манифест первый: он — то, что о поколении пакета читает движок,
|
|
@@ -34,6 +42,7 @@ export default {
|
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
const violations = [];
|
|
45
|
+
const retired = [];
|
|
37
46
|
const [source, reference] = declared[0];
|
|
38
47
|
|
|
39
48
|
for (const [where, value] of declared) {
|
|
@@ -56,15 +65,123 @@ export default {
|
|
|
56
65
|
}
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
// форма `requires` — то же недоверие, что в checkPluginCompatibility:
|
|
69
|
+
// строка проитерировалась бы здесь посимвольно, объект уронил бы чекер
|
|
70
|
+
// «not iterable»
|
|
71
|
+
const requires = ctx.manifest?.requires;
|
|
72
|
+
|
|
73
|
+
if (requires === undefined || requires === null) {
|
|
74
|
+
violations.push(...halfViolations(ctx, []));
|
|
75
|
+
|
|
76
|
+
return finish(violations, retired);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
!Array.isArray(requires) ||
|
|
81
|
+
requires.some(name => typeof name !== 'string')
|
|
82
|
+
) {
|
|
83
|
+
violations.push(
|
|
84
|
+
'manifest.requires must be an array of capability names, got ' +
|
|
85
|
+
`${JSON.stringify(requires)} — the engine reads it before it ` +
|
|
86
|
+
'loads the plugin',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return finish(violations, retired);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const name of requires) {
|
|
93
|
+
// has, а не CAPABILITIES.includes: реестр возможностей append-only,
|
|
94
|
+
// и выведенное алиасом имя движок принимает вечно (ENGINE_CAPABILITIES.
|
|
95
|
+
// has в checkPluginCompatibility). Сверка с одними активными именами
|
|
96
|
+
// отвергала бы игру за то, что движок переименовал возможность — тот
|
|
97
|
+
// самый отказ по возрасту, который план и снимал
|
|
98
|
+
if (!ENGINE_CAPABILITIES.has(name)) {
|
|
61
99
|
violations.push(
|
|
62
100
|
`manifest.requires names '${name}', which this engine does not ` +
|
|
63
101
|
`provide — known capabilities: ${CAPABILITIES.join(', ')}`,
|
|
64
102
|
);
|
|
103
|
+
} else if (ENGINE_CAPABILITIES.isRetired(name)) {
|
|
104
|
+
retired.push(
|
|
105
|
+
`manifest.requires names '${name}', which was retired — it still ` +
|
|
106
|
+
`works (the engine resolves it to ` +
|
|
107
|
+
`'${ENGINE_CAPABILITIES.resolve(name)}' forever), but a new game ` +
|
|
108
|
+
`should declare '${ENGINE_CAPABILITIES.resolve(name)}' itself`,
|
|
109
|
+
);
|
|
65
110
|
}
|
|
66
111
|
}
|
|
67
112
|
|
|
68
|
-
|
|
113
|
+
violations.push(...halfViolations(ctx, requires));
|
|
114
|
+
|
|
115
|
+
return finish(violations, retired);
|
|
69
116
|
},
|
|
70
117
|
};
|
|
118
|
+
|
|
119
|
+
// `requires` пишут три места одного пакета: скрипт сборки манифеста и обе
|
|
120
|
+
// половины плагина (последние — ради standalone SDK, у которого манифеста
|
|
121
|
+
// нет). Разъехавшись, они дают игру, которую лобби честно отвергает, а
|
|
122
|
+
// solo-режим принимает и тихо недоигрывает. Половина без поля из сверки
|
|
123
|
+
// исключена: пакет, собранный до появления поля, обязан оставаться
|
|
124
|
+
// валидным (И1/И2)
|
|
125
|
+
function halfViolations(ctx, manifestRequires) {
|
|
126
|
+
const wanted = new Set(manifestRequires);
|
|
127
|
+
const violations = [];
|
|
128
|
+
const halves = [
|
|
129
|
+
['HostPlugin', ctx.hostPlugin?.requires],
|
|
130
|
+
['ClientPlugin', ctx.clientPlugin?.requires],
|
|
131
|
+
].filter(([, value]) => value !== undefined && value !== null);
|
|
132
|
+
|
|
133
|
+
for (const [where, value] of halves) {
|
|
134
|
+
if (!Array.isArray(value) || value.some(name => typeof name !== 'string')) {
|
|
135
|
+
violations.push(
|
|
136
|
+
`${where}.requires must be an array of capability names, got ` +
|
|
137
|
+
`${JSON.stringify(value)} — the standalone SDK reads it`,
|
|
138
|
+
);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const extra = value.filter(name => !wanted.has(name));
|
|
143
|
+
|
|
144
|
+
if (extra.length) {
|
|
145
|
+
violations.push(
|
|
146
|
+
`${where}.requires names ${extra.join(', ')}, which ` +
|
|
147
|
+
'manifest.requires does not list — the lobby master reads the ' +
|
|
148
|
+
'manifest, so the two must agree',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// «поле объявлено» — это наличие ключа, а не непустой список: половина с
|
|
154
|
+
// `requires: []` утверждает, что игре ничего не нужно, и расходится с
|
|
155
|
+
// манифестом, который просит возможность. Половина БЕЗ поля не утверждает
|
|
156
|
+
// ничего — пакет, собранный до его появления
|
|
157
|
+
if (halves.length === 0) {
|
|
158
|
+
return violations;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const declared = halves
|
|
162
|
+
.map(([, value]) => value)
|
|
163
|
+
.filter(Array.isArray)
|
|
164
|
+
.flat();
|
|
165
|
+
|
|
166
|
+
const missing = [...wanted].filter(name => !declared.includes(name));
|
|
167
|
+
|
|
168
|
+
if (missing.length) {
|
|
169
|
+
violations.push(
|
|
170
|
+
`manifest.requires names ${missing.join(', ')}, which neither plugin ` +
|
|
171
|
+
'half declares — the standalone SDK has no manifest and would run ' +
|
|
172
|
+
'the game on an engine that cannot support it',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return violations;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// то же, что делают B5 и C10: выведенное имя — не отказ, а предупреждение.
|
|
180
|
+
// Отвергать за него значило бы отвергать игру за возраст (И1)
|
|
181
|
+
function finish(violations, retired) {
|
|
182
|
+
if (violations.length === 0 && retired.length > 0) {
|
|
183
|
+
return verdict(retired, 'retired capability names still resolve', WARN);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return verdict(violations);
|
|
187
|
+
}
|
|
@@ -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 и экран авторизации
|
|
@@ -14,8 +18,8 @@ export default {
|
|
|
14
18
|
name: 'authSchema',
|
|
15
19
|
level: ERROR,
|
|
16
20
|
title:
|
|
17
|
-
'authSchema: fieldsId, no nickname field, the model field,
|
|
18
|
-
'validators',
|
|
21
|
+
'authSchema: fieldsId, no nickname field, the model field, inline ' +
|
|
22
|
+
'options, resolvable validators',
|
|
19
23
|
|
|
20
24
|
check(ctx) {
|
|
21
25
|
if (!ctx.authSchema) {
|
|
@@ -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,39 @@ 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
|
+
|
|
74
|
+
// `source` резолвится вызывающей стороной через ctx.sources, а
|
|
75
|
+
// auth-форма строится с ПУСТЫМ ctx (client/components/view/Auth.js) —
|
|
76
|
+
// значит список вариантов у такого поля пуст всегда: игрок видит
|
|
77
|
+
// 'no options available' и войти не может, а хост отвергает любое
|
|
78
|
+
// значение как не-вариант. Это только для authSchema; в roomForm
|
|
79
|
+
// (правило B5) source штатный — там движок отдаёт каталог карт
|
|
80
|
+
if (field.options?.source !== undefined) {
|
|
81
|
+
violations.push(
|
|
82
|
+
`authSchema param "${field.name}" declares options.source ` +
|
|
83
|
+
`"${field.options.source}" — the auth form is built without ` +
|
|
84
|
+
'sources, so the field resolves to an empty list and nobody can ' +
|
|
85
|
+
'log in; inline the options',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
51
89
|
const validatorName = field.options?.validator;
|
|
52
90
|
|
|
53
91
|
// опечатка в имени (как и не-функция под верным именем) = поле не
|
|
@@ -73,6 +111,16 @@ export default {
|
|
|
73
111
|
);
|
|
74
112
|
}
|
|
75
113
|
|
|
114
|
+
// то же, что делает B5 для roomForm: выведенный контрол — не отказ, а
|
|
115
|
+
// предупреждение, иначе правило отвергало бы игру за возраст (И1)
|
|
116
|
+
if (violations.length === 0 && retired.length > 0) {
|
|
117
|
+
return verdict(
|
|
118
|
+
retired,
|
|
119
|
+
'retired controls still build and validate, via aliases',
|
|
120
|
+
WARN,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
76
124
|
return verdict(violations);
|
|
77
125
|
},
|
|
78
126
|
};
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// (spawn_scripted_actor/remove_scripted_actor), человек — только танк
|
|
11
11
|
// (spawn_actor/remove_actor).
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
|
|
14
|
+
import { readCoreAbi, dispatchCoreOp } from '../lib/coreAbi.js';
|
|
15
15
|
|
|
16
16
|
export default class GameCoreAdapter {
|
|
17
17
|
/**
|
|
@@ -28,14 +28,11 @@ export default class GameCoreAdapter {
|
|
|
28
28
|
this._onCoreEvent = onCoreEvent;
|
|
29
29
|
this._services = {}; // { vimp, panel } — инъекция как у Game.js
|
|
30
30
|
|
|
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: [] };
|
|
31
|
+
// Метода нет (или самоописание нечитаемо) — ядро собрано до появления
|
|
32
|
+
// механизма. Это не ошибка: поколение 0, ни одного опционального опкода
|
|
33
|
+
// (И2). Читается один раз здесь, а не при вызове: ветку упаковки, поле
|
|
34
|
+
// формы или ответ лобби движок выбирает заранее, а не посреди матча.
|
|
35
|
+
this._abi = readCoreAbi(core, 'game core');
|
|
39
36
|
}
|
|
40
37
|
|
|
41
38
|
/**
|
|
@@ -237,10 +234,10 @@ export default class GameCoreAdapter {
|
|
|
237
234
|
* @returns {Object|null}
|
|
238
235
|
*/
|
|
239
236
|
debugJson() {
|
|
240
|
-
const
|
|
237
|
+
const { handled, bytes } = this._op(ABI_OP_DEBUG_JSON);
|
|
241
238
|
|
|
242
|
-
if (
|
|
243
|
-
return JSON.parse(new TextDecoder().decode(
|
|
239
|
+
if (handled && bytes !== null) {
|
|
240
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
244
241
|
}
|
|
245
242
|
|
|
246
243
|
// ядро старше опкода, но с замороженным методом — метод не удаляется
|
|
@@ -257,18 +254,22 @@ export default class GameCoreAdapter {
|
|
|
257
254
|
* ядра: прямой вызов нового метода на `this._core` запрещён (И2) — у
|
|
258
255
|
* ядра, собранного год назад, его нет и не будет. ESLint стережёт это
|
|
259
256
|
* правилом no-restricted-syntax.
|
|
257
|
+
*
|
|
258
|
+
* Исходов ровно три, и они РАЗЛИЧИМЫ (соглашение `abi::dispatch_result`,
|
|
259
|
+
* core/src/abi.rs): пустой ответ — «опкод не понят», маркер `[0x00]` —
|
|
260
|
+
* «понят, ответа нет», иначе — полезные байты. Схлопывать первые два в
|
|
261
|
+
* один `null` нельзя: опкод-команда (ради них механизм и делался) отдавала
|
|
262
|
+
* бы вызывающему сырой `Uint8Array [0]`, который поехал бы в TextDecoder
|
|
263
|
+
* и JSON.parse.
|
|
260
264
|
* @param {string} op - Опкод из config/abiOps.js.
|
|
261
265
|
* @param {Uint8Array} [payload] - Полезная нагрузка опкода.
|
|
262
|
-
* @returns {Uint8Array|null}
|
|
266
|
+
* @returns {{handled: boolean, bytes: Uint8Array|null}} `handled: false` —
|
|
267
|
+
* ядро опкода не знает (вызывающий идёт по запасному пути);
|
|
268
|
+
* `handled: true, bytes: null` — обработано без ответа.
|
|
269
|
+
* @see lib/coreAbi.js — то же для клиентского ядра
|
|
263
270
|
*/
|
|
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;
|
|
271
|
+
_op(op, payload) {
|
|
272
|
+
return dispatchCoreOp(this._core, this._abi, op, payload);
|
|
272
273
|
}
|
|
273
274
|
|
|
274
275
|
// бот ли участник (спавн/удаление в ядре различаются)
|
|
@@ -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,149 @@
|
|
|
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
|
+
/**
|
|
67
|
+
* Зовёт необязательную возможность ядра опкодом. Единственное место, где
|
|
68
|
+
* движок вызывает `dispatch` — и игровой половины, и клиентской: таблица
|
|
69
|
+
* экспортов wasm заморожена (И1/И3), поэтому новая возможность приезжает
|
|
70
|
+
* строкой, а не новым символом.
|
|
71
|
+
*
|
|
72
|
+
* Исходов ровно три, и они РАЗЛИЧИМЫ (соглашение `abi::dispatch_result`,
|
|
73
|
+
* core/src/abi.rs): пустой ответ — «опкод не понят», маркер `[0x00]` —
|
|
74
|
+
* «понят, ответа нет», иначе — полезные байты. Схлопывать первые два в один
|
|
75
|
+
* `null` нельзя: опкод-команда (ради них механизм и делался) отдавала бы
|
|
76
|
+
* вызывающему сырой `Uint8Array [0]`, который поехал бы в TextDecoder и
|
|
77
|
+
* JSON.parse.
|
|
78
|
+
* @param {Object} core - Экземпляр wasm-ядра.
|
|
79
|
+
* @param {Object} abi - Результат readCoreAbi для этого ядра.
|
|
80
|
+
* @param {string} op - Опкод из config/abiOps.js.
|
|
81
|
+
* @param {Uint8Array} [payload] - Полезная нагрузка опкода.
|
|
82
|
+
* @returns {{handled: boolean, bytes: Uint8Array|null}} `handled: false` —
|
|
83
|
+
* ядро опкода не знает (вызывающий идёт по запасному пути);
|
|
84
|
+
* `handled: true, bytes: null` — обработано без ответа.
|
|
85
|
+
*/
|
|
86
|
+
export function dispatchCoreOp(core, abi, op, payload = EMPTY_PAYLOAD) {
|
|
87
|
+
// опкод вне реестра — дефект ДВИЖКА, а не старого ядра: имя, которого нет
|
|
88
|
+
// в config/abiOps.js, не попадает ни в слепок поверхности, ни в CHANGELOG.
|
|
89
|
+
// Падаем сразу, а не на игре, которая его однажды поймёт
|
|
90
|
+
if (!abiOps.has(op)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`dispatchCoreOp: unknown opcode "${op}" — declare it in ` +
|
|
93
|
+
'config/abiOps.js (append-only, И1)',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Направление резолва здесь ОБРАТНОЕ остальным реестрам, и это не описка.
|
|
98
|
+
// Имя контрола или сервиса пишет игра, а движок разрешает его вперёд, в
|
|
99
|
+
// активное. Имя опкода пишет ДВИЖОК, а понимать его должно уже
|
|
100
|
+
// опубликованное ядро, которое старше движка: разрешив 'debug.json' в
|
|
101
|
+
// будущее 'debug.dump', движок спросил бы у старого ядра имя, которого в
|
|
102
|
+
// нём нет, и молча потерял бы возможность на ровно тех ядрах, ради
|
|
103
|
+
// которых весь план и писался. Поэтому пробуется вся цепочка: активное
|
|
104
|
+
// имя первым, выведенные — как запасной путь для старого ядра
|
|
105
|
+
const known = abiOps
|
|
106
|
+
.chain(op)
|
|
107
|
+
.map(entry => entry.value)
|
|
108
|
+
.reverse()
|
|
109
|
+
.find(name => abi.ops.includes(name));
|
|
110
|
+
|
|
111
|
+
if (known === undefined) {
|
|
112
|
+
return NOT_HANDLED;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ядро с самоописанием, но без самого dispatch (ручное раскрытие макроса,
|
|
116
|
+
// обрезанная сборка, мок в тестах игры) — не краш движка, а ядро без
|
|
117
|
+
// опциональных возможностей: тот же исход, что «опкод не понят»
|
|
118
|
+
if (typeof core.dispatch !== 'function') {
|
|
119
|
+
return NOT_HANDLED;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const out = core.dispatch(known, payload);
|
|
123
|
+
|
|
124
|
+
// ответ ядра нормализуется так же, как самоописание: не-Uint8Array
|
|
125
|
+
// (undefined, строка, null) — это чужая форма, а не полезные байты, и
|
|
126
|
+
// TypeError на .length ушёл бы в конструктор адаптера или в async-обработчик
|
|
127
|
+
// PS_CONFIG_DATA невыловленным промисом
|
|
128
|
+
if (!(out instanceof Uint8Array)) {
|
|
129
|
+
if (out !== undefined && out !== null) {
|
|
130
|
+
console.warn(`dispatchCoreOp: "${known}" answered with a non-Uint8Array`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return NOT_HANDLED;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (out.length === 0) {
|
|
137
|
+
return NOT_HANDLED;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// [0x00] — «обработан, ответа нет»: полезных байтов у него нет
|
|
141
|
+
return {
|
|
142
|
+
handled: true,
|
|
143
|
+
bytes: out.length === 1 && out[0] === 0x00 ? null : out,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// дефолтный экспорт — чтение самоописания: с него начинается работа с
|
|
148
|
+
// любым ядром, dispatchCoreOp зовётся уже с его результатом
|
|
149
|
+
export default readCoreAbi;
|
|
@@ -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
|
@@ -41,9 +41,52 @@ export async function fetchGameManifest(url) {
|
|
|
41
41
|
// мастера, Node-загрузчик, браузерный клиент, standalone SDK) разная
|
|
42
42
|
// правильная реакция — каталог помечает игру недоступной и продолжает
|
|
43
43
|
// раздавать остальные, остальные три бросают.
|
|
44
|
+
|
|
45
|
+
/** Объявлено ли `requires` вообще (undefined/null = «ничего сверх базового»). */
|
|
46
|
+
const isDeclared = value => value !== undefined && value !== null;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Склеивает несколько объявлений `requires` в одно. Спредить их напрямую
|
|
50
|
+
* нельзя: строка разошлась бы посимвольно ('accolades' → 'a','c','c'...) и
|
|
51
|
+
* проехала бы проверку формы ниже, потому что каждый символ — валидная
|
|
52
|
+
* строка. Недоверенное значение возвращается КАК ЕСТЬ, чтобы о его форме
|
|
53
|
+
* говорила одна точка — checkPluginCompatibility, — а не место склейки.
|
|
54
|
+
* @param {...*} sources - Значения `requires` (половины плагина, опция SDK).
|
|
55
|
+
* @returns {Array<string>|*} Объединение объявленных списков либо первое
|
|
56
|
+
* значение неверной формы.
|
|
57
|
+
*/
|
|
58
|
+
export function mergeRequires(...sources) {
|
|
59
|
+
const declared = sources.filter(isDeclared);
|
|
60
|
+
const malformed = declared.find(source => !Array.isArray(source));
|
|
61
|
+
|
|
62
|
+
return malformed ?? [...new Set(declared.flat())];
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
export function checkPluginCompatibility(manifest) {
|
|
45
|
-
const wanted = manifest.requires
|
|
46
|
-
|
|
66
|
+
const wanted = manifest.requires;
|
|
67
|
+
|
|
68
|
+
// `requires` пишет ЧУЖОЙ репозиторий игры, и его форме нельзя доверять:
|
|
69
|
+
// строка или объект вместо массива давали здесь TypeError, который уходил
|
|
70
|
+
// из конструктора GameCatalog и не давал стартовать мастеру целиком —
|
|
71
|
+
// одна битая игра уносила весь каталог. Битый манифест обязан вести себя
|
|
72
|
+
// как несовместимый (вердикт), а не как краш движка
|
|
73
|
+
if (wanted !== undefined && wanted !== null) {
|
|
74
|
+
if (
|
|
75
|
+
!Array.isArray(wanted) ||
|
|
76
|
+
wanted.some(name => typeof name !== 'string')
|
|
77
|
+
) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: 'bad-manifest',
|
|
81
|
+
missing: [],
|
|
82
|
+
text:
|
|
83
|
+
`game "${manifest.id}": manifest.requires must be an array of ` +
|
|
84
|
+
'capability names — rebuild the game package',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const missing = (wanted ?? []).filter(name => !ENGINE_CAPABILITIES.has(name));
|
|
47
90
|
|
|
48
91
|
if (missing.length === 0) {
|
|
49
92
|
return { ok: true };
|
|
@@ -106,6 +106,74 @@ function assertPluginMatchesManifest(manifest, plugins) {
|
|
|
106
106
|
);
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
assertRequiresMatchManifest(manifest, plugins);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// `requires` пишут ТРИ независимых места одного пакета игры: скрипт сборки
|
|
114
|
+
// манифеста и обе половины плагина (последние — ради standalone SDK, у
|
|
115
|
+
// которого манифеста нет вовсе). Разъехавшись, они дают игру, которая в
|
|
116
|
+
// лобби честно отвергается, а в solo-режиме тихо недоигрывает — то есть
|
|
117
|
+
// именно тот молчаливый режим, ради отказа от которого возможности и
|
|
118
|
+
// заводились. Проверка — того же класса, что сверка engineApi выше: это
|
|
119
|
+
// рассинхрон сборки ВНУТРИ пакета, а не отказ по возрасту (И4).
|
|
120
|
+
//
|
|
121
|
+
// Половина, вовсе НЕ объявившая поле, из сверки исключена: старый пакет,
|
|
122
|
+
// собранный до его появления, обязан грузиться (И1/И2). Объявленное пустым
|
|
123
|
+
// (`requires: []`) — уже утверждение «игре ничего не нужно», и оно
|
|
124
|
+
// расходится с манифестом наравне с неполным списком.
|
|
125
|
+
function assertRequiresMatchManifest(manifest, plugins) {
|
|
126
|
+
const wanted = new Set(
|
|
127
|
+
Array.isArray(manifest.requires) ? manifest.requires : [],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
for (const [half, plugin] of Object.entries(plugins)) {
|
|
131
|
+
if (plugin.requires === undefined || plugin.requires === null) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!Array.isArray(plugin.requires)) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`game "${manifest.id}": ${half} plugin requires must be an array ` +
|
|
138
|
+
'of capability names',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const extra = plugin.requires.filter(name => !wanted.has(name));
|
|
143
|
+
|
|
144
|
+
if (extra.length) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`game "${manifest.id}": ${half} plugin requires ` +
|
|
147
|
+
`${extra.join(', ')}, which manifest.requires does not list — ` +
|
|
148
|
+
'stale dist/ (the manifest is what the lobby master reads)',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// обратная сторона: манифест просит возможность, о которой половины не
|
|
154
|
+
// знают. Половина, вовсе не объявившая поле, из сверки исключена — иначе
|
|
155
|
+
// правка ломала бы каждый уже опубликованный пакет. Объявленное пустым
|
|
156
|
+
// (`requires: []`) — это утверждение «ничего не нужно», и оно расходится
|
|
157
|
+
// с манифестом наравне с неполным списком
|
|
158
|
+
const halves = Object.values(plugins).filter(
|
|
159
|
+
plugin => plugin.requires !== undefined && plugin.requires !== null,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
if (halves.length === 0) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const declared = halves.flatMap(plugin => plugin.requires);
|
|
167
|
+
|
|
168
|
+
const missing = [...wanted].filter(name => !declared.includes(name));
|
|
169
|
+
|
|
170
|
+
if (missing.length) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`game "${manifest.id}": manifest.requires names ${missing.join(', ')}, ` +
|
|
173
|
+
'which neither plugin half declares — stale dist/ (the standalone ' +
|
|
174
|
+
'SDK reads the halves, there is no manifest in solo mode)',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
109
177
|
}
|
|
110
178
|
|
|
111
179
|
function importDefault(baseDir, entry, assetsBase) {
|
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 { formControls, 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,12 +32,49 @@ const OPTION_CONTROLS = ['select', 'radio'];
|
|
|
30
32
|
// умолчанию — text (тот же дефолт, что у билдера формы)
|
|
31
33
|
const isTextControl = control => control === 'text' || control === undefined;
|
|
32
34
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
// regExp — не дубль диапазона: сгенерированный сборкой паттерн (вроде
|
|
60
|
+
// "^([1-8])$") ловит то, что диапазон пропускает — дробное и ведущий
|
|
61
|
+
// ноль. Клиент проверяет числовое поле и тем, и другим (formBuilder.
|
|
62
|
+
// validateField), и сверяет паттерн с СЫРОЙ строкой поля, то есть со
|
|
63
|
+
// значением в единице отображения — здесь то же самое
|
|
64
|
+
if (options.regExp && !matchesPattern(options.regExp, String(shown))) {
|
|
65
|
+
return 'invalid format';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Сверяется только объявленный inline-список. String(): и <option>.value, и
|
|
72
|
+
// <input type=radio>.value — DOM-свойства, они всегда строки, так что
|
|
73
|
+
// options: [1, 2] форма отдаёт как '1'/'2'.
|
|
74
|
+
//
|
|
75
|
+
// Списка нет вовсе — ни массива, ни `source` (последний в auth-схеме не
|
|
76
|
+
// резолвится: форма строится с пустым ctx). Валидного значения у такого поля
|
|
77
|
+
// не существует, и форма это говорит прямо ('no options available'):
|
|
39
78
|
// пропустить здесь что угодно значило бы дать обошедшему форму клиенту
|
|
40
79
|
// больше прав, чем игроку, который войти не может вовсе
|
|
41
80
|
const isDeclaredOption = (list, value) =>
|
|
@@ -76,6 +115,26 @@ export const resolveValidator = (name, validators = {}) => {
|
|
|
76
115
|
return typeof fn === 'function' ? fn : undefined;
|
|
77
116
|
};
|
|
78
117
|
|
|
118
|
+
// игровой валидатор поля (authSchema.validators). Нерезолвнутое имя
|
|
119
|
+
// (опечатка) и не-функция ведут себя одинаково — поле проходит. Для клиента
|
|
120
|
+
// это норма (игровые валидаторы к нему не едут, авторитет проверки на
|
|
121
|
+
// хосте), для хоста — дефект схемы, о котором говорят C10 и console.error в
|
|
122
|
+
// PortMachine: звать что попало нельзя, TypeError отсюда уходит прямо в
|
|
123
|
+
// обработчик сообщения
|
|
124
|
+
const gameValidatorError = (options, value, validators) => {
|
|
125
|
+
if (options?.validator === undefined) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const validatorFn = resolveValidator(options.validator, validators);
|
|
130
|
+
|
|
131
|
+
return validatorFn && !validatorFn(value) ? 'not valid' : null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// null → ничего не добавляем: errors.push(...withName(...)) читается одной
|
|
135
|
+
// строкой на обеих ветках поля
|
|
136
|
+
const withName = (name, error) => (error === null ? [] : [{ name, error }]);
|
|
137
|
+
|
|
79
138
|
/**
|
|
80
139
|
* Валидирует объект с данными для авторизации.
|
|
81
140
|
* @param {object} data - Объект с данными для проверки.
|
|
@@ -87,13 +146,49 @@ export const resolveValidator = (name, validators = {}) => {
|
|
|
87
146
|
export const validateAuth = (data, authParams, validators = {}) => {
|
|
88
147
|
const errors = [];
|
|
89
148
|
|
|
90
|
-
for (const { name, options } of authParams) {
|
|
149
|
+
for (const { name, options: declared } of authParams) {
|
|
91
150
|
if (!(name in data)) {
|
|
92
151
|
return [{ name, error: `Property is missing` }];
|
|
93
152
|
}
|
|
94
153
|
|
|
154
|
+
// Тот же резолв алиаса, что делает билдер формы (client/lib/formBuilder.js:
|
|
155
|
+
// buildField, collectFormErrors, resolveForcedValue). Без него контрол,
|
|
156
|
+
// выведенный из эксплуатации в v3 ('segmented', 'number', 'range',
|
|
157
|
+
// 'toggle'), не совпадает ни с одним именем ниже, и клиент, обошедший
|
|
158
|
+
// форму, получает поле ВООБЩЕ без проверок — ровно то превосходство над
|
|
159
|
+
// заполнившим форму, которого здесь быть не должно. Алиасы стали
|
|
160
|
+
// достижимы вместе с реестром контролов (этап 3 plugin-forward-compat):
|
|
161
|
+
// до него такое поле не строилось у клиента и сюда не доезжало
|
|
162
|
+
const options = resolveDescriptor(declared);
|
|
95
163
|
const value = data[name];
|
|
96
164
|
|
|
165
|
+
// Контрол, которого нет в реестре, — дефект схемы (опечатка или игра из
|
|
166
|
+
// будущего), и билдер выносит ему тот же приговор: поле не строится
|
|
167
|
+
// (`unknown control`), в сабмит не попадает и до хоста не доезжает.
|
|
168
|
+
// Значит доехать оно может только от клиента, обошедшего форму, — и
|
|
169
|
+
// проверить его нечем: ниже не совпадёт ни одна ветка, останется лишь
|
|
170
|
+
// потолок длины. Отвергаем явно; раньше это ловит правило C10
|
|
171
|
+
if (options?.control !== undefined && !formControls.has(options.control)) {
|
|
172
|
+
errors.push({ name, error: 'unknown control' });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Числовое поле идёт своей веткой: форма отдаёт его числом, и общий
|
|
177
|
+
// путь ниже (длина, список вариантов, regExp) к нему не применим —
|
|
178
|
+
// проверяется диапазон, ровно как validateField делает на клиенте.
|
|
179
|
+
// Игровой валидатор зовётся для обоих видов поля одинаково
|
|
180
|
+
if (isNumericControl(options)) {
|
|
181
|
+
const error = numericError(options, value);
|
|
182
|
+
|
|
183
|
+
errors.push(
|
|
184
|
+
...withName(
|
|
185
|
+
name,
|
|
186
|
+
error ?? gameValidatorError(options, value, validators),
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
97
192
|
if (typeof value !== 'string') {
|
|
98
193
|
return [{ name, error: `Property must be a string` }];
|
|
99
194
|
}
|
|
@@ -103,10 +198,7 @@ export const validateAuth = (data, authParams, validators = {}) => {
|
|
|
103
198
|
// не должен получать больше прав, чем клиент, её заполнивший. Пустое
|
|
104
199
|
// значение пропускается ровно как на клиенте (required здесь не
|
|
105
200
|
// проверяется: solo-путь boot.autoAuth отвечает дефолтами схемы, среди
|
|
106
|
-
// которых бывает '') — пустота остаётся делом игрового
|
|
107
|
-
// min/max формы здесь не применяются и не нужны: числовое поле отдаёт из
|
|
108
|
-
// формы число, а нестроковое значение отбито выше, то есть числовых
|
|
109
|
-
// полей в authSchema не бывает вовсе
|
|
201
|
+
// которых бывает '') — пустота остаётся делом игрового валидатора
|
|
110
202
|
|
|
111
203
|
// длина — первой и безусловно: потолок ограничивает не столько ввод,
|
|
112
204
|
// сколько работу паттерна ниже (см. MAX_FIELD_LENGTH)
|
|
@@ -123,9 +215,16 @@ export const validateAuth = (data, authParams, validators = {}) => {
|
|
|
123
215
|
// членство в списке вариантов — самое жёсткое ограничение формы: без
|
|
124
216
|
// этой проверки поле-select без игрового валидатора принимает от
|
|
125
217
|
// обошедшего форму клиента любую строку
|
|
218
|
+
// `source` (спец-источник вариантов движка, например карты) в auth-схеме
|
|
219
|
+
// не работает вовсе: auth-форма строится с ПУСТЫМ ctx
|
|
220
|
+
// (client/components/view/Auth.js), поэтому у игрока такое поле
|
|
221
|
+
// резолвится в пустой список и получает 'no options available' — войти
|
|
222
|
+
// с ним нельзя. Пропустив его здесь, хост дал бы обошедшему форму
|
|
223
|
+
// клиенту произвольную строку там, где игрок не может ввести ничего:
|
|
224
|
+
// инвариант модуля наизнанку. Отвергаем как и любой не-вариант; раньше
|
|
225
|
+
// это ловит правило C10
|
|
126
226
|
if (
|
|
127
227
|
OPTION_CONTROLS.includes(options?.control) &&
|
|
128
|
-
!options.source &&
|
|
129
228
|
!isDeclaredOption(options.options, value)
|
|
130
229
|
) {
|
|
131
230
|
errors.push({ name, error: 'not an option' });
|
|
@@ -142,18 +241,9 @@ export const validateAuth = (data, authParams, validators = {}) => {
|
|
|
142
241
|
continue;
|
|
143
242
|
}
|
|
144
243
|
|
|
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
|
-
}
|
|
244
|
+
errors.push(
|
|
245
|
+
...withName(name, gameValidatorError(options, value, validators)),
|
|
246
|
+
);
|
|
157
247
|
}
|
|
158
248
|
|
|
159
249
|
return errors.length ? errors : undefined;
|
package/src/standalone/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertGameConfigShape,
|
|
3
3
|
checkPluginCompatibility,
|
|
4
|
+
mergeRequires,
|
|
4
5
|
} from '../lib/gamePlugin.js';
|
|
5
6
|
import { setBootConfig } from '../client/boot.js';
|
|
6
7
|
import { ensureGameShell } from '../client/views/gameShell.js';
|
|
@@ -36,6 +37,10 @@ import { ensureGameShell } from '../client/views/gameShell.js';
|
|
|
36
37
|
* голосований, например `['/bot 4']`.
|
|
37
38
|
* @param {Object} [options.room] - Переопределения комнаты (map, maxPlayers,
|
|
38
39
|
* roundTime, friendlyFire, seed).
|
|
40
|
+
* @param {Array<string>} [options.requires] - Возможности движка, без
|
|
41
|
+
* которых игра не запустится (аналог `GameManifest.requires`, которого в
|
|
42
|
+
* solo-режиме нет). По умолчанию — объединение `requires` обеих половин
|
|
43
|
+
* плагина.
|
|
39
44
|
* @param {boolean} [options.devMode] - room.isDevMode: рекордер и хостовый
|
|
40
45
|
* CONSOLE-лог.
|
|
41
46
|
* @returns {Promise<Object>} `{ stop() }` — останов матча.
|
|
@@ -52,6 +57,7 @@ export async function startStandaloneGame({
|
|
|
52
57
|
startupVotes = [],
|
|
53
58
|
startupCommands = [],
|
|
54
59
|
room = {},
|
|
60
|
+
requires,
|
|
55
61
|
devMode = false,
|
|
56
62
|
} = {}) {
|
|
57
63
|
requireOption(hostPlugin, 'hostPlugin');
|
|
@@ -59,9 +65,21 @@ export async function startStandaloneGame({
|
|
|
59
65
|
requireOption(wasmUrl, 'wasmUrl');
|
|
60
66
|
|
|
61
67
|
// возможность, которой в этой сборке движка нет, иначе всплыла бы где-то
|
|
62
|
-
// в середине хендшейка непрозрачной
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
// в середине хендшейка непрозрачной ошибкой. Мастера тут нет, а значит нет
|
|
69
|
+
// и GameManifest с его `requires` — источником служат сами половины
|
|
70
|
+
// плагина (необязательное поле `requires`, docs/en/plugin-api.md) либо
|
|
71
|
+
// опция вызова, если встраивающий знает лучше. Одна проверка вместо двух:
|
|
72
|
+
// сообщение адресовано разработчику встраивания, делить его по половинам
|
|
73
|
+
// незачем
|
|
74
|
+
requireCompatible({
|
|
75
|
+
id: hostPlugin.id,
|
|
76
|
+
// mergeRequires, а не спред: строка вместо массива ('accolades' —
|
|
77
|
+
// естественная опечатка в поле, которого у игры раньше не было)
|
|
78
|
+
// разошлась бы посимвольно и дала отказ со списком букв вместо внятного
|
|
79
|
+
// «должен быть массив», а объект уронил бы SDK «not iterable»
|
|
80
|
+
requires:
|
|
81
|
+
requires ?? mergeRequires(hostPlugin.requires, clientPlugin.requires),
|
|
82
|
+
});
|
|
65
83
|
|
|
66
84
|
// одна view на прогон: умолчания вместо обязательных полей (И2 этапа 2)
|
|
67
85
|
const configView = assertGameConfigShape(hostPlugin);
|
|
@@ -97,8 +115,8 @@ export async function startStandaloneGame({
|
|
|
97
115
|
};
|
|
98
116
|
}
|
|
99
117
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
118
|
+
// игра просит возможность, которой в этой сборке движка нет: подменить её в
|
|
119
|
+
// SDK нечем — матч не поднимется
|
|
102
120
|
function requireCompatible(plugin) {
|
|
103
121
|
const compat = checkPluginCompatibility(plugin);
|
|
104
122
|
|