vimp-engine 0.22.1 → 0.23.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 +27 -8
- package/src/client/lib/socketDispatch.js +34 -0
- package/src/client/main.js +55 -7
- package/src/config/abiOps.js +31 -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 +31 -8
- 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/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 +14 -2
- package/src/lib/capabilities.js +32 -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/gameConfigView.js +211 -0
- package/src/lib/gamePlugin.js +65 -87
- package/src/lib/loadGamePackage.js +9 -2
- package/src/lib/registry.js +94 -0
- package/src/standalone/index.js +27 -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
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
* @throws {Error} Если нет поля из REQUIRED_GAME_CONFIG_PATHS или конфиг
|
|
131
|
+
* внутренне противоречив (spectatorTeam вне teams, noSpectators при двух
|
|
132
|
+
* командах).
|
|
133
|
+
*/
|
|
134
|
+
export function createGameConfigView(gameConfig, gameId = 'unknown') {
|
|
135
|
+
const source = gameConfig ?? {};
|
|
136
|
+
|
|
137
|
+
// null проходил бы проверку присутствия, хотя ни одно из этих полей не
|
|
138
|
+
// бывает пустым по контракту: движок разыменовывает их сразу, и гейт,
|
|
139
|
+
// заведённый ради текста вместо TypeError, сам отвечал бы TypeError
|
|
140
|
+
const missing = REQUIRED_GAME_CONFIG_PATHS.filter(path => {
|
|
141
|
+
const value = getPath(source, path);
|
|
142
|
+
|
|
143
|
+
return value === undefined || value === null;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (missing.length > 0) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`game "${gameId}": gameConfig is missing required field(s): ` +
|
|
149
|
+
missing.join(', '),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const view = { ...source };
|
|
154
|
+
|
|
155
|
+
for (const [path, spec] of Object.entries(FIELDS)) {
|
|
156
|
+
const value = getPath(source, path);
|
|
157
|
+
|
|
158
|
+
if (value !== undefined && value !== null) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
setPath(
|
|
163
|
+
view,
|
|
164
|
+
path,
|
|
165
|
+
spec.derive ? spec.derive(source, gameId) : spec.default,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
assertConsistent(view, source, gameId);
|
|
170
|
+
|
|
171
|
+
return Object.freeze(view);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Проверки внутренней согласованности того, что игра УЖЕ прислала. Это не
|
|
175
|
+
// новые требования (И2): поле, которого нет, здесь не проверяется вовсе.
|
|
176
|
+
function assertConsistent(view, source, gameId) {
|
|
177
|
+
const { teams } = view;
|
|
178
|
+
|
|
179
|
+
// noSpectators: связывать нечего — зато команда обязана быть ровно одна.
|
|
180
|
+
// Вторая играющая команда без наблюдателей означала бы вход «куда-нибудь»,
|
|
181
|
+
// а ParticipantManager выбирает команду входа однозначно
|
|
182
|
+
if (view.noSpectators === true) {
|
|
183
|
+
if (Object.keys(teams).length !== 1) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`game "${gameId}": noSpectators requires exactly one team, ` +
|
|
186
|
+
`got ${Object.keys(teams).length} (${Object.keys(teams).join(', ')})`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// spectatorTeam — имя ключа внутри teams, и опечатка даёт spectatorId ===
|
|
194
|
+
// undefined, после чего участник заходит в несуществующую команду
|
|
195
|
+
// (ParticipantManager.createHuman валится на её счётчике). Проверяем
|
|
196
|
+
// только объявленное игрой: выведенное значение уже согласовано
|
|
197
|
+
const declared = source.spectatorTeam;
|
|
198
|
+
|
|
199
|
+
if (
|
|
200
|
+
declared !== undefined &&
|
|
201
|
+
declared !== null &&
|
|
202
|
+
teams[declared] === undefined
|
|
203
|
+
) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`game "${gameId}": spectatorTeam '${declared}' is not a ` +
|
|
206
|
+
`key of teams (${Object.keys(teams).join(', ')})`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
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,79 @@ 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
|
+
const missing = wanted.filter(name => !ENGINE_CAPABILITIES.has(name));
|
|
47
|
+
|
|
48
|
+
if (missing.length === 0) {
|
|
49
|
+
return { ok: true };
|
|
39
50
|
}
|
|
40
|
-
}
|
|
41
51
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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);
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
reason: 'engine-too-old',
|
|
55
|
+
missing,
|
|
56
|
+
// текст обязан называть сторону, которую надо обновить: это единственный
|
|
57
|
+
// оставшийся режим отказа, и он должен быть однозначным
|
|
58
|
+
text:
|
|
59
|
+
`game "${manifest.id}" needs engine capabilities this build does ` +
|
|
60
|
+
`not have: ${missing.join(', ')} — update the engine`,
|
|
61
|
+
};
|
|
67
62
|
}
|
|
68
63
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
const missing = required.filter(p => {
|
|
80
|
-
const value = getPath(hostPlugin.gameConfig, p);
|
|
81
|
-
|
|
82
|
-
return value === undefined || value === null;
|
|
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
|
-
);
|
|
90
|
-
}
|
|
64
|
+
// Имя, на которое ссылается существующий код и тесты (И1 действует и на
|
|
65
|
+
// экспорты движка): та же проверка, но бросающая.
|
|
66
|
+
export function assertEngineApiCompatible(manifest) {
|
|
67
|
+
const compat = checkPluginCompatibility(manifest);
|
|
91
68
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// undefined, после чего участник заходит в несуществующую команду
|
|
95
|
-
// (ParticipantManager.createHuman валится на её счётчике)
|
|
96
|
-
const { teams, spectatorTeam } = hostPlugin.gameConfig;
|
|
97
|
-
|
|
98
|
-
// noSpectators: связывать нечего — зато команда обязана быть ровно одна.
|
|
99
|
-
// Вторая играющая команда без наблюдателей означала бы вход «куда-нибудь»,
|
|
100
|
-
// а ParticipantManager выбирает команду входа однозначно
|
|
101
|
-
if (noSpectators) {
|
|
102
|
-
if (Object.keys(teams).length !== 1) {
|
|
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
|
-
}
|
|
108
|
-
|
|
109
|
-
return;
|
|
69
|
+
if (!compat.ok) {
|
|
70
|
+
throw new Error(compat.text);
|
|
110
71
|
}
|
|
72
|
+
}
|
|
111
73
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
74
|
+
// Обязательные поля gameConfig и умолчания для всего остального живут в
|
|
75
|
+
// lib/gameConfigView.js (этап 2 плана plugin-forward-compat) — здесь только
|
|
76
|
+
// имена, на которые мог сослаться чужой код, и тонкая обёртка над view.
|
|
77
|
+
export { REQUIRED_GAME_CONFIG_PATHS } from './gameConfigView.js';
|
|
78
|
+
|
|
79
|
+
// spectatorTeam перестал быть обязательным (у него есть умолчание) —
|
|
80
|
+
// константа остаётся именем пути, а не требованием
|
|
81
|
+
export const SPECTATOR_CONFIG_PATH = 'spectatorTeam';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Проверяет gameConfig плагина и возвращает представление с умолчаниями.
|
|
85
|
+
* Гейт стоит сразу после import — рядом с engineApi-гейтом: недостающее
|
|
86
|
+
* обязательное поле иначе валится непрозрачной ошибкой глубоко в onInit.
|
|
87
|
+
* @param {Object} hostPlugin - Загруженный HostPlugin игры.
|
|
88
|
+
* @returns {Object} Результат createGameConfigView (одна view на прогон).
|
|
89
|
+
*/
|
|
90
|
+
export function assertGameConfigShape(hostPlugin) {
|
|
91
|
+
return createGameConfigView(hostPlugin.gameConfig, hostPlugin.id);
|
|
118
92
|
}
|
|
119
93
|
|
|
120
|
-
// динамический import ClientPlugin игры (client-entry её сборки).
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
94
|
+
// динамический import ClientPlugin игры (client-entry её сборки). Сначала —
|
|
95
|
+
// вердикт совместимости по манифесту (дешевле: до сетевого import): игра,
|
|
96
|
+
// требующая возможности, которой в этой сборке нет, не заработает и после
|
|
97
|
+
// загрузки бандла. Сверка engineApi манифеста с плагином ниже — про
|
|
98
|
+
// рассинхрон сборки внутри пакета, а не про версию движка
|
|
125
99
|
export async function loadClientPlugin(manifest) {
|
|
126
|
-
|
|
100
|
+
const compat = checkPluginCompatibility(manifest);
|
|
101
|
+
|
|
102
|
+
if (!compat.ok) {
|
|
103
|
+
throw new Error(compat.text);
|
|
104
|
+
}
|
|
127
105
|
|
|
128
106
|
const module = await import(/* @vite-ignore */ manifest.entries.client);
|
|
129
107
|
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(
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Append-only реестр (И1 плана plugin-forward-compat, этап 3). Движок держит
|
|
2
|
+
// закрытые словари, из которых игра выбирает значения: контролы формы, имена
|
|
3
|
+
// клиентских сервисов. Запись НИКОГДА не удаляется: игра, собранная два года
|
|
4
|
+
// назад, могла её написать, и её dist больше никто не тронет — сокращение
|
|
5
|
+
// словаря отвергает такую игру молча. Вывод из эксплуатации =
|
|
6
|
+
// { alias: 'новое-имя' } + запись в CHANGELOG, но не удаление строки.
|
|
7
|
+
//
|
|
8
|
+
// Именно так сломался v2 → v3: набор `control` урезали до четырёх нативных
|
|
9
|
+
// элементов, и `range`/`number`/`toggle`/`segmented` перестали строиться.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Создаёт append-only реестр имён плагинной поверхности.
|
|
13
|
+
* @param {string} name - Имя реестра (в тексте ошибок).
|
|
14
|
+
* @param {Array<Object>} entries - Записи `{ value, since, alias?,
|
|
15
|
+
* retiredIn?, note?, ...полезная нагрузка }`. Запись с `alias` — выведенное
|
|
16
|
+
* из эксплуатации имя: оно продолжает работать, разрешаясь в указанное.
|
|
17
|
+
* @returns {Object} Реестр: `has`, `get`, `resolve`, `chain`, `isRetired`,
|
|
18
|
+
* `list`, `values`.
|
|
19
|
+
*/
|
|
20
|
+
export function createRegistry(name, entries) {
|
|
21
|
+
const byValue = new Map();
|
|
22
|
+
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
if (byValue.has(entry.value)) {
|
|
25
|
+
throw new Error(`registry ${name}: duplicate entry "${entry.value}"`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
byValue.set(entry.value, Object.freeze({ ...entry }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// цепочка алиасов от имени к активной записи; пустая, если имени нет
|
|
32
|
+
const chain = value => {
|
|
33
|
+
const trail = [];
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
let current = byValue.get(value);
|
|
36
|
+
|
|
37
|
+
while (current !== undefined) {
|
|
38
|
+
if (seen.has(current.value)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`registry ${name}: alias cycle at "${current.value}" — ` +
|
|
41
|
+
'a retired name must resolve to an active one',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
seen.add(current.value);
|
|
46
|
+
trail.push(current);
|
|
47
|
+
|
|
48
|
+
if (current.alias === undefined) {
|
|
49
|
+
return trail;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const next = byValue.get(current.alias);
|
|
53
|
+
|
|
54
|
+
if (next === undefined) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`registry ${name}: "${current.value}" is aliased to unknown ` +
|
|
57
|
+
`"${current.alias}"`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
current = next;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return trail;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// битый реестр — дефект движка, а не плагина: пусть падает на загрузке
|
|
68
|
+
// модуля, а не на первой игре, которая напишет выведенное имя
|
|
69
|
+
for (const value of byValue.keys()) {
|
|
70
|
+
chain(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
name,
|
|
75
|
+
has: value => byValue.has(value),
|
|
76
|
+
get: value => byValue.get(value),
|
|
77
|
+
// разрешает имя в активное (проходит цепочку алиасов). undefined — имя
|
|
78
|
+
// неизвестно; это ВСЕГДА ошибка плагина (он попросил будущее), никогда
|
|
79
|
+
// не ошибка движка за то, что у него список длиннее
|
|
80
|
+
resolve: value => chain(value).at(-1)?.value,
|
|
81
|
+
chain,
|
|
82
|
+
isRetired: value => byValue.get(value)?.alias !== undefined,
|
|
83
|
+
// все записи, включая выведенные: слепок поверхности (этап 1) не считает
|
|
84
|
+
// вывод из эксплуатации удалением
|
|
85
|
+
list: () => [...byValue.values()],
|
|
86
|
+
// только активные имена — то, что вправе написать НОВАЯ игра
|
|
87
|
+
values: () =>
|
|
88
|
+
[...byValue.values()]
|
|
89
|
+
.filter(e => e.alias === undefined)
|
|
90
|
+
.map(e => e.value),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default createRegistry;
|
package/src/standalone/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
assertGameConfigShape,
|
|
3
|
+
checkPluginCompatibility,
|
|
4
|
+
} from '../lib/gamePlugin.js';
|
|
2
5
|
import { setBootConfig } from '../client/boot.js';
|
|
3
6
|
import { ensureGameShell } from '../client/views/gameShell.js';
|
|
4
7
|
|
|
@@ -55,21 +58,24 @@ export async function startStandaloneGame({
|
|
|
55
58
|
requireOption(clientPlugin, 'clientPlugin');
|
|
56
59
|
requireOption(wasmUrl, 'wasmUrl');
|
|
57
60
|
|
|
58
|
-
//
|
|
59
|
-
// хендшейка непрозрачной ошибкой
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// возможность, которой в этой сборке движка нет, иначе всплыла бы где-то
|
|
62
|
+
// в середине хендшейка непрозрачной ошибкой
|
|
63
|
+
requireCompatible(hostPlugin);
|
|
64
|
+
requireCompatible(clientPlugin);
|
|
65
|
+
|
|
66
|
+
// одна view на прогон: умолчания вместо обязательных полей (И2 этапа 2)
|
|
67
|
+
const configView = assertGameConfigShape(hostPlugin);
|
|
62
68
|
|
|
63
69
|
ensureGameShell(container);
|
|
64
70
|
|
|
65
71
|
setBootConfig({
|
|
66
72
|
mode: 'solo',
|
|
67
73
|
container,
|
|
68
|
-
manifest: buildManifest(hostPlugin, { wasmUrl, assetsBase }),
|
|
74
|
+
manifest: buildManifest(hostPlugin, configView, { wasmUrl, assetsBase }),
|
|
69
75
|
clientPlugin,
|
|
70
76
|
hostPlugin,
|
|
71
77
|
room: {
|
|
72
|
-
...
|
|
78
|
+
...configView.roomDefaults,
|
|
73
79
|
...room,
|
|
74
80
|
isDevMode: devMode,
|
|
75
81
|
},
|
|
@@ -91,6 +97,16 @@ export async function startStandaloneGame({
|
|
|
91
97
|
};
|
|
92
98
|
}
|
|
93
99
|
|
|
100
|
+
// половина плагина просит возможность, которой в этой сборке движка нет:
|
|
101
|
+
// подменить её в SDK нечем — матч не поднимется
|
|
102
|
+
function requireCompatible(plugin) {
|
|
103
|
+
const compat = checkPluginCompatibility(plugin);
|
|
104
|
+
|
|
105
|
+
if (!compat.ok) {
|
|
106
|
+
throw new Error(compat.text);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
// незаданное поле — это «взять дефолт схемы», а не «отправить undefined»:
|
|
95
111
|
// JSON.stringify ключ с undefined выбрасывает, и хост отвечает
|
|
96
112
|
// 'Property is missing' (main.js накрывает autoAuth поверх дефолтов схемы)
|
|
@@ -103,18 +119,16 @@ function pruneUndefined(source) {
|
|
|
103
119
|
// манифест-подобный объект в памяти: мастера нет, а клиент читает из
|
|
104
120
|
// манифеста id/version, entries.wasm и assetsBase. entries.client/host не
|
|
105
121
|
// нужны — оба плагина уже живые объекты
|
|
106
|
-
function buildManifest(hostPlugin, { wasmUrl, assetsBase }) {
|
|
107
|
-
const { gameConfig } = hostPlugin;
|
|
108
|
-
|
|
122
|
+
function buildManifest(hostPlugin, configView, { wasmUrl, assetsBase }) {
|
|
109
123
|
return {
|
|
110
124
|
id: hostPlugin.id,
|
|
111
125
|
engineApi: hostPlugin.engineApi,
|
|
112
126
|
version: 'dev',
|
|
113
|
-
title:
|
|
127
|
+
title: configView.title ?? hostPlugin.id,
|
|
114
128
|
entries: { wasm: wasmUrl },
|
|
115
129
|
assetsBase,
|
|
116
|
-
maps: { version: 'dev', list: Object.keys(
|
|
117
|
-
roomDefaults:
|
|
130
|
+
maps: { version: 'dev', list: Object.keys(configView.maps) },
|
|
131
|
+
roomDefaults: configView.roomDefaults,
|
|
118
132
|
};
|
|
119
133
|
}
|
|
120
134
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# gen-api3 — ЗАМОРОЖЕНО
|
|
2
|
+
|
|
3
|
+
Снимок плагина 2026-08-29, поколение `engineApi: 3`.
|
|
4
|
+
Не править: правка фикстуры маскирует слом совместимости
|
|
5
|
+
(этап 1 плана `plan/plugin-forward-compat`). Новое поколение
|
|
6
|
+
добавляется копированием текущего `../../miniGame`, а не правкой
|
|
7
|
+
этого каталога.
|