vimp-engine 0.24.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/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/devtools/contract/rules/b2-engine-api.js +70 -1
- package/src/devtools/contract/rules/c10-auth-schema.js +17 -2
- package/src/host/GameCoreAdapter.js +3 -2
- package/src/lib/coreAbi.js +38 -5
- package/src/lib/gamePlugin.js +21 -0
- package/src/lib/loadGamePackage.js +68 -0
- package/src/lib/validators.js +36 -8
- package/src/standalone/index.js +7 -4
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
|
@@ -17,11 +17,16 @@ import {
|
|
|
17
17
|
//
|
|
18
18
|
// Возможности из `manifest.requires` проверяются по реестру установленного
|
|
19
19
|
// движка: имени, которого нет, движок дать не может — игра просит будущее.
|
|
20
|
+
// `requires` при этом живёт в трёх местах пакета (манифест + обе половины
|
|
21
|
+
// плагина), и их расхождение — такой же рассинхрон сборки, как расхождение
|
|
22
|
+
// `engineApi`.
|
|
20
23
|
export default {
|
|
21
24
|
id: 'B2',
|
|
22
25
|
name: 'engineApiVersion',
|
|
23
26
|
level: ERROR,
|
|
24
|
-
title:
|
|
27
|
+
title:
|
|
28
|
+
'engineApi is consistent, requires names existing capabilities and ' +
|
|
29
|
+
'agrees between the manifest and both plugin halves',
|
|
25
30
|
|
|
26
31
|
check(ctx) {
|
|
27
32
|
// манифест первый: он — то, что о поколении пакета читает движок,
|
|
@@ -66,6 +71,8 @@ export default {
|
|
|
66
71
|
const requires = ctx.manifest?.requires;
|
|
67
72
|
|
|
68
73
|
if (requires === undefined || requires === null) {
|
|
74
|
+
violations.push(...halfViolations(ctx, []));
|
|
75
|
+
|
|
69
76
|
return finish(violations, retired);
|
|
70
77
|
}
|
|
71
78
|
|
|
@@ -103,10 +110,72 @@ export default {
|
|
|
103
110
|
}
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
violations.push(...halfViolations(ctx, requires));
|
|
114
|
+
|
|
106
115
|
return finish(violations, retired);
|
|
107
116
|
},
|
|
108
117
|
};
|
|
109
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
|
+
|
|
110
179
|
// то же, что делают B5 и C10: выведенное имя — не отказ, а предупреждение.
|
|
111
180
|
// Отвергать за него значило бы отвергать игру за возраст (И1)
|
|
112
181
|
function finish(violations, retired) {
|
|
@@ -18,8 +18,8 @@ export default {
|
|
|
18
18
|
name: 'authSchema',
|
|
19
19
|
level: ERROR,
|
|
20
20
|
title:
|
|
21
|
-
'authSchema: fieldsId, no nickname field, the model field,
|
|
22
|
-
'validators',
|
|
21
|
+
'authSchema: fieldsId, no nickname field, the model field, inline ' +
|
|
22
|
+
'options, resolvable validators',
|
|
23
23
|
|
|
24
24
|
check(ctx) {
|
|
25
25
|
if (!ctx.authSchema) {
|
|
@@ -71,6 +71,21 @@ export default {
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
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
|
+
|
|
74
89
|
const validatorName = field.options?.validator;
|
|
75
90
|
|
|
76
91
|
// опечатка в имени (как и не-функция под верным именем) = поле не
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
|
|
2
|
-
import { readCoreAbi, dispatchCoreOp } from '../lib/coreAbi.js';
|
|
3
1
|
// Адаптер Rust-ядра (GameCore) под интерфейс, который потребляют мета-модули
|
|
4
2
|
// хоста (RoundManager, SocketManager) и host-фасад. За поверхностью Game.js
|
|
5
3
|
// (+ упаковка снапшотов) стоит WASM-ядро; события ядра (take_events) несут
|
|
@@ -12,6 +10,9 @@ import { readCoreAbi, dispatchCoreOp } from '../lib/coreAbi.js';
|
|
|
12
10
|
// (spawn_scripted_actor/remove_scripted_actor), человек — только танк
|
|
13
11
|
// (spawn_actor/remove_actor).
|
|
14
12
|
|
|
13
|
+
import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
|
|
14
|
+
import { readCoreAbi, dispatchCoreOp } from '../lib/coreAbi.js';
|
|
15
|
+
|
|
15
16
|
export default class GameCoreAdapter {
|
|
16
17
|
/**
|
|
17
18
|
* @param {GameCore} core - экземпляр WASM-ядра.
|
package/src/lib/coreAbi.js
CHANGED
|
@@ -63,8 +63,6 @@ export function readCoreAbi(core, label = 'core') {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
export default readCoreAbi;
|
|
67
|
-
|
|
68
66
|
/**
|
|
69
67
|
* Зовёт необязательную возможность ядра опкодом. Единственное место, где
|
|
70
68
|
* движок вызывает `dispatch` — и игровой половины, и клиентской: таблица
|
|
@@ -96,13 +94,44 @@ export function dispatchCoreOp(core, abi, op, payload = EMPTY_PAYLOAD) {
|
|
|
96
94
|
);
|
|
97
95
|
}
|
|
98
96
|
|
|
99
|
-
|
|
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
|
+
}
|
|
100
114
|
|
|
101
|
-
|
|
115
|
+
// ядро с самоописанием, но без самого dispatch (ручное раскрытие макроса,
|
|
116
|
+
// обрезанная сборка, мок в тестах игры) — не краш движка, а ядро без
|
|
117
|
+
// опциональных возможностей: тот же исход, что «опкод не понят»
|
|
118
|
+
if (typeof core.dispatch !== 'function') {
|
|
102
119
|
return NOT_HANDLED;
|
|
103
120
|
}
|
|
104
121
|
|
|
105
|
-
const out = core.dispatch(
|
|
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
|
+
}
|
|
106
135
|
|
|
107
136
|
if (out.length === 0) {
|
|
108
137
|
return NOT_HANDLED;
|
|
@@ -114,3 +143,7 @@ export function dispatchCoreOp(core, abi, op, payload = EMPTY_PAYLOAD) {
|
|
|
114
143
|
bytes: out.length === 1 && out[0] === 0x00 ? null : out,
|
|
115
144
|
};
|
|
116
145
|
}
|
|
146
|
+
|
|
147
|
+
// дефолтный экспорт — чтение самоописания: с него начинается работа с
|
|
148
|
+
// любым ядром, dispatchCoreOp зовётся уже с его результатом
|
|
149
|
+
export default readCoreAbi;
|
package/src/lib/gamePlugin.js
CHANGED
|
@@ -41,6 +41,27 @@ 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
66
|
const wanted = manifest.requires;
|
|
46
67
|
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
import { anchorPattern } from './formPattern.js';
|
|
2
2
|
import { normalizeOptions } from './formOptions.js';
|
|
3
|
-
import { resolveDescriptor } from './formControls.js';
|
|
3
|
+
import { formControls, resolveDescriptor } from './formControls.js';
|
|
4
4
|
import { toDisplay, isNumericField } from './formUnit.js';
|
|
5
5
|
|
|
6
6
|
const NAME_REGEXP = new RegExp('^[a-zA-Z]([\\w\\s#]{0,13})[\\w]{1}$');
|
|
@@ -56,15 +56,25 @@ const numericError = (options, value) => {
|
|
|
56
56
|
return `must be <= ${options.max}`;
|
|
57
57
|
}
|
|
58
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
|
+
|
|
59
68
|
return null;
|
|
60
69
|
};
|
|
61
70
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// Списка нет вовсе
|
|
67
|
-
//
|
|
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'):
|
|
68
78
|
// пропустить здесь что угодно значило бы дать обошедшему форму клиенту
|
|
69
79
|
// больше прав, чем игроку, который войти не может вовсе
|
|
70
80
|
const isDeclaredOption = (list, value) =>
|
|
@@ -152,6 +162,17 @@ export const validateAuth = (data, authParams, validators = {}) => {
|
|
|
152
162
|
const options = resolveDescriptor(declared);
|
|
153
163
|
const value = data[name];
|
|
154
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
|
+
|
|
155
176
|
// Числовое поле идёт своей веткой: форма отдаёт его числом, и общий
|
|
156
177
|
// путь ниже (длина, список вариантов, regExp) к нему не применим —
|
|
157
178
|
// проверяется диапазон, ровно как validateField делает на клиенте.
|
|
@@ -194,9 +215,16 @@ export const validateAuth = (data, authParams, validators = {}) => {
|
|
|
194
215
|
// членство в списке вариантов — самое жёсткое ограничение формы: без
|
|
195
216
|
// этой проверки поле-select без игрового валидатора принимает от
|
|
196
217
|
// обошедшего форму клиента любую строку
|
|
218
|
+
// `source` (спец-источник вариантов движка, например карты) в auth-схеме
|
|
219
|
+
// не работает вовсе: auth-форма строится с ПУСТЫМ ctx
|
|
220
|
+
// (client/components/view/Auth.js), поэтому у игрока такое поле
|
|
221
|
+
// резолвится в пустой список и получает 'no options available' — войти
|
|
222
|
+
// с ним нельзя. Пропустив его здесь, хост дал бы обошедшему форму
|
|
223
|
+
// клиенту произвольную строку там, где игрок не может ввести ничего:
|
|
224
|
+
// инвариант модуля наизнанку. Отвергаем как и любой не-вариант; раньше
|
|
225
|
+
// это ловит правило C10
|
|
197
226
|
if (
|
|
198
227
|
OPTION_CONTROLS.includes(options?.control) &&
|
|
199
|
-
!options.source &&
|
|
200
228
|
!isDeclaredOption(options.options, value)
|
|
201
229
|
) {
|
|
202
230
|
errors.push({ name, error: 'not an option' });
|
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';
|
|
@@ -72,10 +73,12 @@ export async function startStandaloneGame({
|
|
|
72
73
|
// незачем
|
|
73
74
|
requireCompatible({
|
|
74
75
|
id: hostPlugin.id,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
// mergeRequires, а не спред: строка вместо массива ('accolades' —
|
|
77
|
+
// естественная опечатка в поле, которого у игры раньше не было)
|
|
78
|
+
// разошлась бы посимвольно и дала отказ со списком букв вместо внятного
|
|
79
|
+
// «должен быть массив», а объект уронил бы SDK «not iterable»
|
|
80
|
+
requires:
|
|
81
|
+
requires ?? mergeRequires(hostPlugin.requires, clientPlugin.requires),
|
|
79
82
|
});
|
|
80
83
|
|
|
81
84
|
// одна view на прогон: умолчания вместо обязательных полей (И2 этапа 2)
|