vimp-engine 0.22.1 → 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 +88 -0
- package/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/lib/formBuilder.js +38 -27
- package/src/client/lib/pickActiveGame.js +59 -0
- package/src/client/lib/socketDispatch.js +34 -0
- package/src/client/main.js +61 -14
- package/src/config/abiOps.js +37 -0
- package/src/config/clientServices.js +27 -0
- package/src/config/opcodes.js +11 -2
- package/src/config/wsports.js +9 -0
- package/src/devtools/contract/loadContext.js +25 -2
- package/src/devtools/contract/rules/b10-respawns.js +5 -3
- package/src/devtools/contract/rules/b2-engine-api.js +81 -10
- package/src/devtools/contract/rules/b3-game-config-shape.js +46 -5
- package/src/devtools/contract/rules/b4-teams.js +6 -3
- package/src/devtools/contract/rules/b5-room-form.js +40 -8
- package/src/devtools/contract/rules/c10-auth-schema.js +34 -1
- package/src/devtools/contract/rules/c4-component-dependencies.js +10 -15
- package/src/devtools/surface/abiParse.js +175 -0
- package/src/devtools/surface/collect.js +413 -0
- package/src/host/GameCoreAdapter.js +49 -0
- package/src/lib/applyRoomOverrides.js +16 -2
- package/src/lib/capabilities.js +32 -0
- package/src/lib/coreAbi.js +116 -0
- package/src/lib/coreConfig.js +3 -3
- package/src/lib/createHostRuntime.js +5 -3
- package/src/lib/formControls.js +89 -0
- package/src/lib/formUnit.js +25 -0
- package/src/lib/gameConfigView.js +214 -0
- package/src/lib/gamePlugin.js +86 -86
- package/src/lib/loadGamePackage.js +9 -2
- package/src/lib/registry.js +94 -0
- package/src/lib/validators.js +79 -17
- package/src/standalone/index.js +42 -13
- package/tests/fixtures/generations/gen-api3/README.md +7 -0
- package/tests/fixtures/generations/gen-api3/client/fakeClientCore.js +307 -0
- package/tests/fixtures/generations/gen-api3/client/index.js +38 -0
- package/tests/fixtures/generations/gen-api3/client/parts/Actor.js +18 -0
- package/tests/fixtures/generations/gen-api3/client/parts/ActorRadar.js +7 -0
- package/tests/fixtures/generations/gen-api3/config/auth.js +41 -0
- package/tests/fixtures/generations/gen-api3/config/client.js +151 -0
- package/tests/fixtures/generations/gen-api3/config/game.js +167 -0
- package/tests/fixtures/generations/gen-api3/core/pkg-node/core.js +4 -0
- package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +129 -0
- package/tests/fixtures/generations/gen-api3/host/createModules.js +6 -0
- package/tests/fixtures/generations/gen-api3/host/fakeCore.js +263 -0
- package/tests/fixtures/generations/gen-api3/host/index.js +30 -0
- package/tests/fixtures/generations/gen-api3/host/spawnCommand.js +14 -0
- package/tests/fixtures/generations/gen-api3/host/systemMessages.js +6 -0
- package/tests/fixtures/generations/gen-api3/manifest.json +25 -0
- package/tests/fixtures/generations/gen-api4/README.md +7 -0
- package/tests/fixtures/generations/gen-api4/client/fakeClientCore.js +307 -0
- package/tests/fixtures/generations/gen-api4/client/index.js +38 -0
- package/tests/fixtures/generations/gen-api4/client/parts/Actor.js +18 -0
- package/tests/fixtures/generations/gen-api4/client/parts/ActorRadar.js +7 -0
- package/tests/fixtures/generations/gen-api4/config/auth.js +41 -0
- package/tests/fixtures/generations/gen-api4/config/client.js +151 -0
- package/tests/fixtures/generations/gen-api4/config/game.js +156 -0
- package/tests/fixtures/generations/gen-api4/core/pkg-node/core.js +4 -0
- package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +129 -0
- package/tests/fixtures/generations/gen-api4/host/createModules.js +6 -0
- package/tests/fixtures/generations/gen-api4/host/fakeCore.js +263 -0
- package/tests/fixtures/generations/gen-api4/host/index.js +30 -0
- package/tests/fixtures/generations/gen-api4/host/spawnCommand.js +14 -0
- package/tests/fixtures/generations/gen-api4/host/systemMessages.js +6 -0
- package/tests/fixtures/generations/gen-api4/manifest.json +17 -0
- package/tests/fixtures/miniGame/client/fakeClientCore.js +17 -0
- package/tests/fixtures/miniGame/host/fakeCore.js +38 -8
package/src/lib/coreConfig.js
CHANGED
|
@@ -11,9 +11,9 @@ import wsports from '../config/wsports.js';
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Собирает объект конфигурации ядра.
|
|
14
|
-
* @param {Object} gameConfig - HostPlugin.gameConfig
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* @param {Object} gameConfig - Представление HostPlugin.gameConfig
|
|
15
|
+
* (lib/gameConfigView.js): поля игры плюс движковые умолчания. Прямой
|
|
16
|
+
* gameConfig сюда не передаётся — он обходит умолчания (И2).
|
|
17
17
|
* @param {Object} [overrides] - Переопределения плоским объектом (например,
|
|
18
18
|
* seed для воспроизводимых прогонов или friendlyFire) — распределяются
|
|
19
19
|
* по движковой/игровой половине автоматически.
|
|
@@ -40,9 +40,11 @@ export async function createHostRuntime(room, options = {}) {
|
|
|
40
40
|
|
|
41
41
|
const hostPlugin = await loadHostPlugin(room);
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// одна view на прогон: она же валидирует обязательные поля gameConfig и
|
|
44
|
+
// подставляет умолчания для всего остального (lib/gameConfigView.js)
|
|
45
|
+
const configView = assertGameConfigShape(hostPlugin);
|
|
44
46
|
|
|
45
|
-
const game = applyRoomOverrides(room, hostPlugin);
|
|
47
|
+
const game = applyRoomOverrides(room, hostPlugin, configView);
|
|
46
48
|
|
|
47
49
|
if (overrideGameConfig) {
|
|
48
50
|
overrideGameConfig(game);
|
|
@@ -57,7 +59,7 @@ export async function createHostRuntime(room, options = {}) {
|
|
|
57
59
|
|
|
58
60
|
const core = await hostPlugin.createCore(
|
|
59
61
|
JSON.stringify(
|
|
60
|
-
buildCoreConfig(
|
|
62
|
+
buildCoreConfig(configView, {
|
|
61
63
|
friendlyFire: game.parts.friendlyFire,
|
|
62
64
|
seed,
|
|
63
65
|
}),
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createRegistry } from './registry.js';
|
|
2
|
+
|
|
3
|
+
// Реестр значений `control` дескриптора формы (этап 3 плана
|
|
4
|
+
// plugin-forward-compat). Активных контролов четыре — все нативные элементы
|
|
5
|
+
// формы, их строит client/lib/formBuilder.js. Остальные четыре выведены из
|
|
6
|
+
// эксплуатации в v3, но продолжают работать вечно (И1): игра, собранная под
|
|
7
|
+
// v2, написала их в манифесте, и её dist больше никто не тронет.
|
|
8
|
+
//
|
|
9
|
+
// `patch` — то, чем алиас доливает дескриптор: без `numeric: true` бывшее
|
|
10
|
+
// числовое поле стало бы свободным текстом, и валидация пропустила бы в
|
|
11
|
+
// комнату строку вместо числа.
|
|
12
|
+
export const formControls = createRegistry('formControls', [
|
|
13
|
+
{ value: 'select', since: 1 },
|
|
14
|
+
{ value: 'text', since: 1 },
|
|
15
|
+
{ value: 'checkbox', since: 1 },
|
|
16
|
+
{ value: 'radio', since: 1 },
|
|
17
|
+
{
|
|
18
|
+
value: 'range',
|
|
19
|
+
since: 1,
|
|
20
|
+
alias: 'text',
|
|
21
|
+
retiredIn: 3,
|
|
22
|
+
patch: { numeric: true },
|
|
23
|
+
note: 'нативного range нет; рисуется как numeric text',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
value: 'number',
|
|
27
|
+
since: 1,
|
|
28
|
+
alias: 'text',
|
|
29
|
+
retiredIn: 3,
|
|
30
|
+
patch: { numeric: true },
|
|
31
|
+
note: 'нативного number нет; рисуется как numeric text',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
value: 'toggle',
|
|
35
|
+
since: 1,
|
|
36
|
+
alias: 'checkbox',
|
|
37
|
+
retiredIn: 3,
|
|
38
|
+
note: 'то же поведение, нативный checkbox',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
value: 'segmented',
|
|
42
|
+
since: 1,
|
|
43
|
+
alias: 'radio',
|
|
44
|
+
retiredIn: 3,
|
|
45
|
+
note: 'то же поведение, группа нативных radio',
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Разрешает `descriptor.control` в контрол, который умеет строить билдер.
|
|
51
|
+
* @param {string} control - Значение `control` из дескриптора.
|
|
52
|
+
* @returns {{control: string, patch: Object}|undefined} Активный контрол и
|
|
53
|
+
* накладка на дескриптор, либо undefined для неизвестного имени.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveControl(control) {
|
|
56
|
+
const chain = formControls.chain(control);
|
|
57
|
+
|
|
58
|
+
if (chain.length === 0) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// накладки копятся по всей цепочке: алиас на алиас обязан донести обе
|
|
63
|
+
return {
|
|
64
|
+
control: chain.at(-1).value,
|
|
65
|
+
patch: Object.assign({}, ...chain.map(entry => entry.patch ?? {})),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Приводит дескриптор поля к активному контролу (разрешает алиас).
|
|
71
|
+
* @param {Object} descriptor - Дескриптор поля формы.
|
|
72
|
+
* @returns {Object} Тот же объект, если контрол активен или неизвестен
|
|
73
|
+
* (последнее скажет билдер), иначе копия с активным `control` и накладкой
|
|
74
|
+
* алиаса под явными полями игры.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveDescriptor(descriptor) {
|
|
77
|
+
const resolved = resolveControl(descriptor?.control);
|
|
78
|
+
|
|
79
|
+
if (!resolved || resolved.control === descriptor.control) {
|
|
80
|
+
return descriptor;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// накладка идёт ПОД дескриптором: она уточняет контрол, а не переписывает
|
|
84
|
+
// схему игры (явный numeric:false остаётся за игрой)
|
|
85
|
+
return { ...resolved.patch, ...descriptor, control: resolved.control };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// имена, которые вправе написать новая игра (контракт-чекер, документация)
|
|
89
|
+
export const ACTIVE_FORM_CONTROLS = formControls.values();
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import hostDefaults from '../config/hostDefaults.js';
|
|
2
|
+
|
|
3
|
+
// Единственная точка чтения HostPlugin.gameConfig движком (этап 2 плана
|
|
4
|
+
// plugin-forward-compat). До неё движок разыменовывал конфиг игры россыпью
|
|
5
|
+
// по коду, и каждое новое поле немедленно становилось обязательным —
|
|
6
|
+
// прямое нарушение И2 («ничто новое не обязательно»).
|
|
7
|
+
//
|
|
8
|
+
// View — обычный объект (не класс): applyRoomOverrides делает по нему
|
|
9
|
+
// structuredClone и spread, а те не переживают прототипы и геттеры-ловушки.
|
|
10
|
+
// Поля игры, о которых движок не знает, копируются как есть — игра вправе
|
|
11
|
+
// держать в gameConfig что угодно сверх контракта.
|
|
12
|
+
|
|
13
|
+
// Каждая строка ниже — обещание совместимости: поле, которого нет в конфиге
|
|
14
|
+
// старой игры, отдаёт умолчание, а не роняет загрузку (И2). Умолчание
|
|
15
|
+
// обязано быть безопасным для игры, которая о поле не знает.
|
|
16
|
+
//
|
|
17
|
+
// Ключ — путь через точку, значение — { default } либо { derive(config) }.
|
|
18
|
+
// Список может только РАСТИ: перенос поля отсюда в REQUIRED отверг бы уже
|
|
19
|
+
// опубликованные игры.
|
|
20
|
+
const FIELDS = {
|
|
21
|
+
// название в лобби; null → вызывающий берёт plugin.id
|
|
22
|
+
title: { default: null },
|
|
23
|
+
|
|
24
|
+
// карты везёт мастер (room.maps), у игры их может не быть вовсе
|
|
25
|
+
maps: { default: {} },
|
|
26
|
+
currentMap: { default: null },
|
|
27
|
+
// сколько карт попадает в голосование; 1 — минимальный осмысленный набор
|
|
28
|
+
mapsInVote: { default: 1 },
|
|
29
|
+
mapScale: { default: 1 },
|
|
30
|
+
mapSetId: { default: null },
|
|
31
|
+
|
|
32
|
+
// лимит комнаты: без него берётся движковый (hostDefaults.maxPlayers)
|
|
33
|
+
'roomDefaults.maxPlayers': { default: hostDefaults.maxPlayers },
|
|
34
|
+
|
|
35
|
+
// игра без оружия — не экзотика: @vimp-games/snakes уже такая
|
|
36
|
+
'parts.weapons': { default: {} },
|
|
37
|
+
// огонь по своим — опция игры; «нет опции» значит «выключено»
|
|
38
|
+
'parts.friendlyFire': { default: false },
|
|
39
|
+
|
|
40
|
+
// пустая панель рисуется корректно: Panel читает её через Object.keys.
|
|
41
|
+
// fields — словарь «имя → { key, value }», поэтому пусто здесь это {}
|
|
42
|
+
'panel.fields': { default: {} },
|
|
43
|
+
'panel.activeKey': { default: null },
|
|
44
|
+
|
|
45
|
+
// таблица статистики и стартовые данные участника — целиком игровые
|
|
46
|
+
stat: { default: {} },
|
|
47
|
+
scripted: { default: {} },
|
|
48
|
+
playerState: { default: {} },
|
|
49
|
+
|
|
50
|
+
soundCues: { default: {} },
|
|
51
|
+
// голосование на входе (обычно 'teamChange'); null — входим молча
|
|
52
|
+
initialVote: { default: null },
|
|
53
|
+
|
|
54
|
+
// чем игра занимает экран по Tab: движковая таблица или свой лидерборд
|
|
55
|
+
statMode: { default: 'table' },
|
|
56
|
+
// opt-in-флаги: их отсутствие всегда означало «выключено»
|
|
57
|
+
noSpectators: { default: false },
|
|
58
|
+
endlessRound: { default: false },
|
|
59
|
+
|
|
60
|
+
spectatorTeam: { derive: deriveSpectatorTeam },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Все пути gameConfig, которые движок читает: объявленные игрой поля плюс
|
|
64
|
+
// те, за которые он подставляет умолчание. Раздел слепка поверхности
|
|
65
|
+
// (contract/surface.json → gameConfigFields): исчезнувший отсюда путь —
|
|
66
|
+
// нарушение И1, потому что игра могла его написать.
|
|
67
|
+
export const KNOWN_GAME_CONFIG_PATHS = Object.keys(FIELDS);
|
|
68
|
+
|
|
69
|
+
// ЗАМОРОЖЕНО (И2). Этот список может только СОКРАЩАТЬСЯ.
|
|
70
|
+
// Добавление сюда отвергнет все ранее опубликованные игры — вместо этого
|
|
71
|
+
// заведи поле в FIELDS с умолчанием. Страж: tests/devtools/surface.test.js.
|
|
72
|
+
export const REQUIRED_GAME_CONFIG_PATHS = [
|
|
73
|
+
'parts.models', // из чего состоит участник; синтезировать нечем
|
|
74
|
+
'playerKeys', // без них ядро не знает ввода
|
|
75
|
+
'snapshot', // раскладка кадра; движок её не придумывает
|
|
76
|
+
'teams', // ParticipantManager выбирает команду входа
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// Единственный признак «команда наблюдателей», который есть в данных, —
|
|
80
|
+
// её имя: teams это словарь «имя → id», флагов в нём нет. Игра, объявившая
|
|
81
|
+
// наблюдателей как-то иначе, обязана назвать spectatorTeam явно.
|
|
82
|
+
const SPECTATOR_TEAM_NAME = 'spectators';
|
|
83
|
+
|
|
84
|
+
function deriveSpectatorTeam(config, gameId) {
|
|
85
|
+
// наблюдателей нет как концепции — связывать нечего
|
|
86
|
+
if (config.noSpectators === true) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (Object.hasOwn(config.teams ?? {}, SPECTATOR_TEAM_NAME)) {
|
|
91
|
+
return SPECTATOR_TEAM_NAME;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// null — рабочее значение (ParticipantManager заводит участника в первую
|
|
95
|
+
// команду), но почти наверняка не то, чего хотела игра: предупреждаем
|
|
96
|
+
console.warn(
|
|
97
|
+
`game "${gameId}": gameConfig.spectatorTeam is not set and teams has ` +
|
|
98
|
+
`no "${SPECTATOR_TEAM_NAME}" key — everyone joins the first team ` +
|
|
99
|
+
`(${Object.keys(config.teams ?? {})[0] ?? '—'}); declare ` +
|
|
100
|
+
'spectatorTeam or noSpectators to say what you meant',
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getPath(source, dottedPath) {
|
|
107
|
+
return dottedPath.split('.').reduce((value, key) => value?.[key], source);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// copy-on-write: умолчание вкладывается в копию ветки, объект игры не
|
|
111
|
+
// правится — gameConfig принадлежит плагину и переживает перезапуск матча
|
|
112
|
+
function setPath(target, dottedPath, value) {
|
|
113
|
+
const keys = dottedPath.split('.');
|
|
114
|
+
let node = target;
|
|
115
|
+
|
|
116
|
+
for (const key of keys.slice(0, -1)) {
|
|
117
|
+
node[key] =
|
|
118
|
+
node[key] === undefined || node[key] === null ? {} : { ...node[key] };
|
|
119
|
+
node = node[key];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
node[keys.at(-1)] = value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Строит представление gameConfig с умолчаниями и проверяет обязательное.
|
|
127
|
+
* @param {Object} gameConfig - HostPlugin.gameConfig игры как есть.
|
|
128
|
+
* @param {string} [gameId] - id плагина; попадает в текст ошибок.
|
|
129
|
+
* @returns {Object} Конфиг: поля игры плюс умолчания движка. Заморожен
|
|
130
|
+
* ПОВЕРХНОСТНО — вложенные ветки (parts, roomDefaults) правятся; глубокая
|
|
131
|
+
* заморозка стоила бы обхода всего конфига игры на каждом старте матча, а
|
|
132
|
+
* единственный потребитель (applyRoomOverrides) и так делает structuredClone.
|
|
133
|
+
* @throws {Error} Если нет поля из REQUIRED_GAME_CONFIG_PATHS или конфиг
|
|
134
|
+
* внутренне противоречив (spectatorTeam вне teams, noSpectators при двух
|
|
135
|
+
* командах).
|
|
136
|
+
*/
|
|
137
|
+
export function createGameConfigView(gameConfig, gameId = 'unknown') {
|
|
138
|
+
const source = gameConfig ?? {};
|
|
139
|
+
|
|
140
|
+
// null проходил бы проверку присутствия, хотя ни одно из этих полей не
|
|
141
|
+
// бывает пустым по контракту: движок разыменовывает их сразу, и гейт,
|
|
142
|
+
// заведённый ради текста вместо TypeError, сам отвечал бы TypeError
|
|
143
|
+
const missing = REQUIRED_GAME_CONFIG_PATHS.filter(path => {
|
|
144
|
+
const value = getPath(source, path);
|
|
145
|
+
|
|
146
|
+
return value === undefined || value === null;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (missing.length > 0) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`game "${gameId}": gameConfig is missing required field(s): ` +
|
|
152
|
+
missing.join(', '),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const view = { ...source };
|
|
157
|
+
|
|
158
|
+
for (const [path, spec] of Object.entries(FIELDS)) {
|
|
159
|
+
const value = getPath(source, path);
|
|
160
|
+
|
|
161
|
+
if (value !== undefined && value !== null) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
setPath(
|
|
166
|
+
view,
|
|
167
|
+
path,
|
|
168
|
+
spec.derive ? spec.derive(source, gameId) : spec.default,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
assertConsistent(view, source, gameId);
|
|
173
|
+
|
|
174
|
+
return Object.freeze(view);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Проверки внутренней согласованности того, что игра УЖЕ прислала. Это не
|
|
178
|
+
// новые требования (И2): поле, которого нет, здесь не проверяется вовсе.
|
|
179
|
+
function assertConsistent(view, source, gameId) {
|
|
180
|
+
const { teams } = view;
|
|
181
|
+
|
|
182
|
+
// noSpectators: связывать нечего — зато команда обязана быть ровно одна.
|
|
183
|
+
// Вторая играющая команда без наблюдателей означала бы вход «куда-нибудь»,
|
|
184
|
+
// а ParticipantManager выбирает команду входа однозначно
|
|
185
|
+
if (view.noSpectators === true) {
|
|
186
|
+
if (Object.keys(teams).length !== 1) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`game "${gameId}": noSpectators requires exactly one team, ` +
|
|
189
|
+
`got ${Object.keys(teams).length} (${Object.keys(teams).join(', ')})`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// spectatorTeam — имя ключа внутри teams, и опечатка даёт spectatorId ===
|
|
197
|
+
// undefined, после чего участник заходит в несуществующую команду
|
|
198
|
+
// (ParticipantManager.createHuman валится на её счётчике). Проверяем
|
|
199
|
+
// только объявленное игрой: выведенное значение уже согласовано
|
|
200
|
+
const declared = source.spectatorTeam;
|
|
201
|
+
|
|
202
|
+
if (
|
|
203
|
+
declared !== undefined &&
|
|
204
|
+
declared !== null &&
|
|
205
|
+
teams[declared] === undefined
|
|
206
|
+
) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`game "${gameId}": spectatorTeam '${declared}' is not a ` +
|
|
209
|
+
`key of teams (${Object.keys(teams).join(', ')})`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export default createGameConfigView;
|
package/src/lib/gamePlugin.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ENGINE_CAPABILITIES } from './capabilities.js';
|
|
2
|
+
import { createGameConfigView } from './gameConfigView.js';
|
|
2
3
|
|
|
3
4
|
// Динамическая загрузка игры по GameManifest мастера (Этап 6.3): клиент
|
|
4
5
|
// больше не импортирует игру статически (gameRegistry.static.js) — вместо
|
|
@@ -28,102 +29,101 @@ export async function fetchGameManifest(url) {
|
|
|
28
29
|
return res.json();
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
// Совместимость плагина с этой сборкой движка (этап 5 плана
|
|
33
|
+
// plugin-forward-compat). Числа больше не сравниваются: `engineApi` заморожен
|
|
34
|
+
// на 4 и остался меткой поколения контракта, а не гейтом. Плагин отвергается,
|
|
35
|
+
// только если просит возможность, которой в этой сборке нет (то есть он
|
|
36
|
+
// НОВЕЕ движка) — движок не может выдать того, чего в нём не существует.
|
|
37
|
+
// Плагин любого возраста принимается: поверхность append-only (И1), и имя,
|
|
38
|
+
// которое он написал, работает вечно.
|
|
39
|
+
//
|
|
40
|
+
// Функция возвращает вердикт, а не бросает: у четырёх входов (каталог
|
|
41
|
+
// мастера, Node-загрузчик, браузерный клиент, standalone SDK) разная
|
|
42
|
+
// правильная реакция — каталог помечает игру недоступной и продолжает
|
|
43
|
+
// раздавать остальные, остальные три бросают.
|
|
44
|
+
export function checkPluginCompatibility(manifest) {
|
|
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
|
+
}
|
|
39
66
|
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// поля gameConfig, которые движок читает до какой-либо игровой логики
|
|
43
|
-
// (applyRoomOverrides/coreConfig/buildClientConfig) — недостающее валится
|
|
44
|
-
// непрозрачной ошибкой глубоко в onInit; проверяем контракт §HostPlugin API
|
|
45
|
-
// (docs/en/plugin-api.md) сразу после import, рядом с engineApi-гейтом
|
|
46
|
-
const REQUIRED_GAME_CONFIG_PATHS = [
|
|
47
|
-
'roomDefaults.maxPlayers',
|
|
48
|
-
'snapshot',
|
|
49
|
-
'parts.models',
|
|
50
|
-
'parts.weapons',
|
|
51
|
-
'parts.friendlyFire',
|
|
52
|
-
'panel.fields',
|
|
53
|
-
'playerKeys',
|
|
54
|
-
// без них HostGame разыменовывает undefined (this._teams[spectatorTeam])
|
|
55
|
-
// и игра умирает тремя разными сообщениями вместо одного контрактного
|
|
56
|
-
'teams',
|
|
57
|
-
];
|
|
58
|
-
|
|
59
|
-
// spectatorTeam обязателен ровно до тех пор, пока игра не объявила
|
|
60
|
-
// noSpectators: там наблюдателей нет как концепции и ключа тоже нет
|
|
61
|
-
const SPECTATOR_CONFIG_PATH = 'spectatorTeam';
|
|
62
|
-
|
|
63
|
-
function getPath(obj, dottedPath) {
|
|
64
|
-
return dottedPath
|
|
65
|
-
.split('.')
|
|
66
|
-
.reduce((value, key) => value?.[key], obj);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// бросает при отсутствии обязательных полей HostPlugin.gameConfig
|
|
70
|
-
export function assertGameConfigShape(hostPlugin) {
|
|
71
|
-
// null проходил бы проверку присутствия, хотя ни одно из этих полей не
|
|
72
|
-
// бывает пустым по контракту: движок разыменовывает их сразу, и гейт,
|
|
73
|
-
// заведённый ради текста вместо TypeError, сам отвечал бы TypeError
|
|
74
|
-
const noSpectators = hostPlugin.gameConfig?.noSpectators === true;
|
|
75
|
-
const required = noSpectators
|
|
76
|
-
? REQUIRED_GAME_CONFIG_PATHS
|
|
77
|
-
: [...REQUIRED_GAME_CONFIG_PATHS, SPECTATOR_CONFIG_PATH];
|
|
78
67
|
|
|
79
|
-
const missing =
|
|
80
|
-
const value = getPath(hostPlugin.gameConfig, p);
|
|
68
|
+
const missing = (wanted ?? []).filter(name => !ENGINE_CAPABILITIES.has(name));
|
|
81
69
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (missing.length > 0) {
|
|
86
|
-
throw new Error(
|
|
87
|
-
`game "${hostPlugin.id}": gameConfig is missing required field(s): ` +
|
|
88
|
-
missing.join(', '),
|
|
89
|
-
);
|
|
70
|
+
if (missing.length === 0) {
|
|
71
|
+
return { ok: true };
|
|
90
72
|
}
|
|
91
73
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
throw new Error(
|
|
104
|
-
`game "${hostPlugin.id}": noSpectators requires exactly one team, ` +
|
|
105
|
-
`got ${Object.keys(teams).length} (${Object.keys(teams).join(', ')})`,
|
|
106
|
-
);
|
|
107
|
-
}
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
reason: 'engine-too-old',
|
|
77
|
+
missing,
|
|
78
|
+
// текст обязан называть сторону, которую надо обновить: это единственный
|
|
79
|
+
// оставшийся режим отказа, и он должен быть однозначным
|
|
80
|
+
text:
|
|
81
|
+
`game "${manifest.id}" needs engine capabilities this build does ` +
|
|
82
|
+
`not have: ${missing.join(', ')} — update the engine`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
108
85
|
|
|
109
|
-
|
|
110
|
-
|
|
86
|
+
// Имя, на которое ссылается существующий код и тесты (И1 действует и на
|
|
87
|
+
// экспорты движка): та же проверка, но бросающая.
|
|
88
|
+
export function assertEngineApiCompatible(manifest) {
|
|
89
|
+
const compat = checkPluginCompatibility(manifest);
|
|
111
90
|
|
|
112
|
-
if (
|
|
113
|
-
throw new Error(
|
|
114
|
-
`game "${hostPlugin.id}": spectatorTeam '${spectatorTeam}' is not a ` +
|
|
115
|
-
`key of teams (${Object.keys(teams).join(', ')})`,
|
|
116
|
-
);
|
|
91
|
+
if (!compat.ok) {
|
|
92
|
+
throw new Error(compat.text);
|
|
117
93
|
}
|
|
118
94
|
}
|
|
119
95
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
96
|
+
// Обязательные поля gameConfig и умолчания для всего остального живут в
|
|
97
|
+
// lib/gameConfigView.js (этап 2 плана plugin-forward-compat) — здесь только
|
|
98
|
+
// имена, на которые мог сослаться чужой код, и тонкая обёртка над view.
|
|
99
|
+
export { REQUIRED_GAME_CONFIG_PATHS } from './gameConfigView.js';
|
|
100
|
+
|
|
101
|
+
// spectatorTeam перестал быть обязательным (у него есть умолчание) —
|
|
102
|
+
// константа остаётся именем пути, а не требованием
|
|
103
|
+
export const SPECTATOR_CONFIG_PATH = 'spectatorTeam';
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Проверяет gameConfig плагина и возвращает представление с умолчаниями.
|
|
107
|
+
* Гейт стоит сразу после import — рядом с engineApi-гейтом: недостающее
|
|
108
|
+
* обязательное поле иначе валится непрозрачной ошибкой глубоко в onInit.
|
|
109
|
+
* @param {Object} hostPlugin - Загруженный HostPlugin игры.
|
|
110
|
+
* @returns {Object} Результат createGameConfigView (одна view на прогон).
|
|
111
|
+
*/
|
|
112
|
+
export function assertGameConfigShape(hostPlugin) {
|
|
113
|
+
return createGameConfigView(hostPlugin.gameConfig, hostPlugin.id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// динамический import ClientPlugin игры (client-entry её сборки). Сначала —
|
|
117
|
+
// вердикт совместимости по манифесту (дешевле: до сетевого import): игра,
|
|
118
|
+
// требующая возможности, которой в этой сборке нет, не заработает и после
|
|
119
|
+
// загрузки бандла. Сверка engineApi манифеста с плагином ниже — про
|
|
120
|
+
// рассинхрон сборки внутри пакета, а не про версию движка
|
|
125
121
|
export async function loadClientPlugin(manifest) {
|
|
126
|
-
|
|
122
|
+
const compat = checkPluginCompatibility(manifest);
|
|
123
|
+
|
|
124
|
+
if (!compat.ok) {
|
|
125
|
+
throw new Error(compat.text);
|
|
126
|
+
}
|
|
127
127
|
|
|
128
128
|
const module = await import(/* @vite-ignore */ manifest.entries.client);
|
|
129
129
|
const plugin = module.default;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { access, readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
|
-
import {
|
|
4
|
+
import { checkPluginCompatibility } from './gamePlugin.js';
|
|
5
5
|
|
|
6
6
|
// Загрузка пакета игры в Node по его собранному dist/ (Этап 4 плана
|
|
7
7
|
// standalone-sdk). В браузере плагин грузится по URL из GameManifest мастера;
|
|
@@ -32,7 +32,14 @@ export async function loadGamePackage(distDir, { core = null } = {}) {
|
|
|
32
32
|
const baseDir = path.dirname(manifestPath);
|
|
33
33
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// игра одна и подменить её нечем — вердикт несовместимости здесь
|
|
36
|
+
// терминальный (в отличие от каталога мастера, который помечает игру
|
|
37
|
+
// недоступной и продолжает раздавать остальные)
|
|
38
|
+
const compat = checkPluginCompatibility(manifest);
|
|
39
|
+
|
|
40
|
+
if (!compat.ok) {
|
|
41
|
+
throw new Error(`${manifestPath}: ${compat.text}`);
|
|
42
|
+
}
|
|
36
43
|
|
|
37
44
|
const { assetsBase } = manifest;
|
|
38
45
|
const hostPlugin = await importDefault(
|