vimp-engine 0.38.2 → 0.38.3

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.
Files changed (56) hide show
  1. package/core/Cargo.toml +1 -1
  2. package/package.json +3 -1
  3. package/src/client/boot.js +30 -7
  4. package/src/client/components/controller/ClientReports.js +1 -1
  5. package/src/client/components/controller/Games.js +1 -1
  6. package/src/client/components/model/ClientReports.js +1 -1
  7. package/src/client/components/model/Games.js +24 -0
  8. package/src/client/components/view/ClientReports.js +1 -1
  9. package/src/client/components/view/Games.js +19 -1
  10. package/src/client/components/view/Lobby.js +1 -7
  11. package/src/client/debug.js +1 -1
  12. package/src/client/lib/authApi.js +1 -1
  13. package/src/client/lib/diagnostics.js +19 -4
  14. package/src/client/lib/engineVersion.js +0 -6
  15. package/src/client/lib/footerLink.js +15 -14
  16. package/src/client/lib/retryOnNetworkError.js +60 -0
  17. package/src/client/main.js +35 -18
  18. package/src/client/network/HiddenHostHealthLog.js +40 -3
  19. package/src/client/network/HostController.js +4 -4
  20. package/src/client/network/HostHealthPolicy.js +41 -0
  21. package/src/client/network/InlineHostBridge.js +8 -1
  22. package/src/client/session/HandoffFlow.js +5 -0
  23. package/src/client/session/HostRole.js +5 -2
  24. package/src/client/style.css +5 -6
  25. package/src/client/views/gameShell.js +3 -3
  26. package/src/client/views/includes/auth.pug +4 -4
  27. package/src/client/views/includes/lobby.pug +4 -4
  28. package/src/client/views/includes/reports.pug +1 -1
  29. package/src/config/authClient.js +3 -3
  30. package/src/config/lobby.js +4 -5
  31. package/src/config/opcodes.js +2 -1
  32. package/src/devtools/contract/loadContext.js +2 -2
  33. package/src/devtools/contract/rules/a7-package-repository.js +7 -8
  34. package/src/devtools/contract/rules/index.js +1 -1
  35. package/src/devtools/invariants.js +1 -1
  36. package/src/host/DebugRecorder.js +1 -1
  37. package/src/host/HostGame.js +35 -15
  38. package/src/host/commandFailure.js +140 -0
  39. package/src/host/host.worker.js +8 -2
  40. package/src/host/meta/SocketManager.js +2 -2
  41. package/src/host/meta/core/CommandProcessor.js +37 -7
  42. package/src/lib/applyRoomOverrides.js +1 -1
  43. package/src/lib/capabilities.js +1 -1
  44. package/src/lib/checkpointCodec.js +1 -2
  45. package/src/lib/coreConfig.js +1 -1
  46. package/src/lib/createHostRuntime.js +1 -1
  47. package/src/lib/gameId.js +1 -1
  48. package/src/lib/gamePlugin.js +6 -2
  49. package/src/lib/jwt.js +1 -1
  50. package/src/lib/packageLink.js +18 -61
  51. package/src/lib/validators.js +3 -3
  52. package/src/standalone/index.js +1 -1
  53. package/tests/fixtures/generations/gen-api3/README.md +2 -2
  54. package/tests/fixtures/generations/gen-api4/README.md +2 -2
  55. package/src/devtools/surface/abiParse.js +0 -175
  56. package/src/devtools/surface/collect.js +0 -413
@@ -1,175 +0,0 @@
1
- // Разбор `core/src/abi.rs` — единственный раздел слепка плагинной
2
- // поверхности, который нельзя собрать импортом: методы wasm-ABI живут внутри
3
- // `macro_rules!`, раскрывающихся уже в крейте игры. Слепок читает файл как
4
- // текст и вытаскивает имена и нормализованные сигнатуры `pub fn`.
5
- //
6
- // Разбор обязан ПАДАТЬ, а не возвращать пустоту: молчаливо пустой раздел
7
- // слепка пропустит любое нарушение И1/И3 (plan/plugin-forward-compat).
8
-
9
- // имя макроса → раздел слепка (`abi.game` / `abi.client`)
10
- const MACRO_SECTIONS = {
11
- // имена макросов Rust — строками: camelCase к ним неприменим
12
- 'export_game_core_abi': 'game',
13
- 'export_client_core_abi': 'client',
14
- };
15
-
16
- /**
17
- * @param {string} source - Содержимое `core/src/abi.rs`.
18
- * @returns {{game: Object[], client: Object[]}} Методы ABI по разделам,
19
- * каждый — { name, args, ret }, отсортированные по имени.
20
- */
21
- export function parseAbi(source) {
22
- const blocks = splitMacros(source);
23
-
24
- if (blocks.length === 0) {
25
- throw new Error(
26
- 'abiParse: no macro_rules! block found in core/src/abi.rs — the file ' +
27
- 'was restructured; fix the parser instead of letting the surface ' +
28
- 'snapshot go silently empty',
29
- );
30
- }
31
-
32
- const sections = {};
33
-
34
- for (const block of blocks) {
35
- const section = MACRO_SECTIONS[block.macro];
36
-
37
- if (!section) {
38
- continue; // макрос вне ABI плагина — не часть поверхности
39
- }
40
-
41
- const methods = parseMethods(block.body);
42
-
43
- if (methods.length === 0) {
44
- throw new Error(
45
- `abiParse: macro_rules! ${block.macro} yielded no "pub fn" — the ` +
46
- 'file was restructured; fix the parser',
47
- );
48
- }
49
-
50
- sections[section] = methods.sort((a, b) => a.name.localeCompare(b.name));
51
- }
52
-
53
- for (const [macro, section] of Object.entries(MACRO_SECTIONS)) {
54
- if (!sections[section]) {
55
- throw new Error(
56
- `abiParse: macro_rules! ${macro} not found in core/src/abi.rs`,
57
- );
58
- }
59
- }
60
-
61
- return sections;
62
- }
63
-
64
- // тело каждого `macro_rules! <name> { … }` — от имени до парной скобке
65
- function splitMacros(source) {
66
- const re = /macro_rules!\s+([a-z_0-9]+)\s*\{/g;
67
- const blocks = [];
68
- let match;
69
-
70
- while ((match = re.exec(source)) !== null) {
71
- const open = re.lastIndex - 1;
72
- const close = matchBrace(source, open, '{', '}');
73
-
74
- blocks.push({
75
- macro: match[1],
76
- body: source.slice(open + 1, close),
77
- });
78
- }
79
-
80
- return blocks;
81
- }
82
-
83
- // `pub fn name(args) -> ret` внутри тела макроса; тело метода пропускается
84
- function parseMethods(body) {
85
- const re = /pub\s+fn\s+([a-z_0-9]+)\s*\(/g;
86
- const methods = [];
87
- let match;
88
-
89
- while ((match = re.exec(body)) !== null) {
90
- const open = re.lastIndex - 1;
91
- const close = matchBrace(body, open, '(', ')');
92
- const tail = body.slice(close + 1);
93
- const arrow = /^\s*->([^{;]+)/.exec(tail);
94
-
95
- methods.push({
96
- name: match[1],
97
- args: splitArgs(body.slice(open + 1, close)).map(normalizeType),
98
- ret: arrow ? normalizeType(arrow[1]) : '()',
99
- });
100
- }
101
-
102
- return methods;
103
- }
104
-
105
- // индекс скобки, парной открывающей на позиции `open`
106
- function matchBrace(source, open, openChar, closeChar) {
107
- let depth = 0;
108
-
109
- for (let i = open; i < source.length; i += 1) {
110
- if (source[i] === openChar) {
111
- depth += 1;
112
- } else if (source[i] === closeChar) {
113
- depth -= 1;
114
-
115
- if (depth === 0) {
116
- return i;
117
- }
118
- }
119
- }
120
-
121
- throw new Error(`abiParse: unbalanced ${openChar} at offset ${open}`);
122
- }
123
-
124
- // список аргументов по запятым верхнего уровня; получатель (`&self`,
125
- // `&mut self`) в сигнатуру не входит — он не часть бинарного контракта
126
- function splitArgs(list) {
127
- const args = [];
128
- let depth = 0;
129
- let current = '';
130
-
131
- for (const ch of list) {
132
- if (ch === '<' || ch === '(' || ch === '[') {
133
- depth += 1;
134
- } else if (ch === '>' || ch === ')' || ch === ']') {
135
- depth -= 1;
136
- }
137
-
138
- if (ch === ',' && depth === 0) {
139
- args.push(current);
140
- current = '';
141
- continue;
142
- }
143
-
144
- current += ch;
145
- }
146
-
147
- args.push(current);
148
-
149
- return args
150
- .map(arg => arg.trim())
151
- .filter(arg => arg.length > 0 && !/^&?\s*(mut\s+)?self$/.test(arg))
152
- .map(arg => {
153
- const colon = arg.indexOf(':');
154
-
155
- // аргумент без имени в этих макросах не встречается: имя есть всегда
156
- return colon === -1 ? arg : arg.slice(colon + 1);
157
- });
158
- }
159
-
160
- // Стабильная строка типа: меняется тогда и только тогда, когда меняется
161
- // бинарный контракт. Ссылка/`mut`/пробелы/путь до wasm-bindgen на него не
162
- // влияют; `Result<T, JsError>` сворачивается в `Result<T>` — вариант ошибки
163
- // у всех методов один.
164
- function normalizeType(type) {
165
- const flat = type
166
- .replaceAll('::wasm_bindgen::', '')
167
- .replaceAll('wasm_bindgen::', '')
168
- .replace(/\bmut\b/g, '')
169
- .replaceAll('&', '')
170
- .replace(/\s+/g, '');
171
-
172
- return flat.replace(/^Result<(.*),JsError>$/, 'Result<$1>');
173
- }
174
-
175
- export default parseAbi;
@@ -1,413 +0,0 @@
1
- import { readFile, readdir } from 'node:fs/promises';
2
- import { fileURLToPath } from 'node:url';
3
- import path from 'node:path';
4
- import wsports from '../../config/wsports.js';
5
- import {
6
- KNOWN_GAME_CONFIG_PATHS,
7
- REQUIRED_GAME_CONFIG_PATHS,
8
- } from '../../lib/gameConfigView.js';
9
- import { formControls } from '../../lib/formControls.js';
10
- import { ENGINE_CAPABILITIES } from '../../lib/capabilities.js';
11
- import { clientServices } from '../../config/clientServices.js';
12
- import { abiOps } from '../../config/abiOps.js';
13
- import { SNAPSHOT_FORMAT_VERSION } from '../../config/opcodes.js';
14
- import { parseAbi } from './abiParse.js';
15
-
16
- // Слепок плагинной поверхности (этап 1 плана plugin-forward-compat).
17
- // Поверхность — всё, что игра может написать или прочитать: поля gameConfig,
18
- // имена сервисов, контролы форм, номера портов, поля манифеста, члены
19
- // объектов плагина, методы wasm-ABI. Инвариант И1 говорит, что ни одно из
20
- // этих имён не исчезает и не переименовывается, И3 — что форма данных
21
- // (сигнатура ABI, номер порта) не меняется.
22
- //
23
- // Слепок ничего не объявляет сам: каждый раздел собирается из существующего
24
- // модуля движка — импортом там, где имя есть значением, и разбором текста
25
- // там, где его нет (методы внутри `macro_rules!`, поля манифеста, которые
26
- // движок читает точечно). Продублированный руками список устарел бы молча.
27
- //
28
- // Живёт в devtools/, потому что в бандл приложения devtools не попадает
29
- // (граница из CLAUDE.md).
30
-
31
- const SRC_DIR = fileURLToPath(new URL('../../', import.meta.url));
32
- const ABI_PATH = fileURLToPath(
33
- new URL('../../../core/src/abi.rs', import.meta.url),
34
- );
35
-
36
- // модули, читающие GameManifest: поля манифеста — их точечные обращения
37
- const MANIFEST_READERS = [
38
- 'master/GameCatalog.js',
39
- 'lib/loadGamePackage.js',
40
- 'lib/gamePlugin.js',
41
- ];
42
-
43
- /**
44
- * Собирает поверхность плагинного контракта.
45
- * @returns {Promise<Object>} JSON-объект слепка (см. contract/surface.json).
46
- */
47
- export async function collectSurface() {
48
- const abi = parseAbi(await readFile(ABI_PATH, 'utf8'));
49
- const sources = await readSources();
50
-
51
- return sortDeep({
52
- abi,
53
- // опкоды dispatch: append-only на тех же правилах, что имена (И1) —
54
- // удаление опкода отнимает возможность, на которую игра уже оперлась
55
- abiOps: registrySection(abiOps),
56
- clientPluginMembers: collectMembers(sources, 'clientPlugin'),
57
- // словари-реестры отдают и активные записи, и выведенные из
58
- // эксплуатации: вывод алиасом — не удаление (И1), и слепок обязан
59
- // отличать одно от другого
60
- clientServices: registrySection(clientServices),
61
- // возможности движка (этап 5): имя отсюда игра пишет в
62
- // manifest.requires — исчезнувшее имя отвергает уже опубликованную игру
63
- // ровно так же, как исчезнувший контрол формы
64
- engineCapabilities: registrySection(ENGINE_CAPABILITIES),
65
- formControls: registrySection(formControls),
66
- // поля gameConfig с умолчаниями (И1): движок читает их через view, а не
67
- // точечно с плагина, поэтому в hostPluginMembers они уже не видны
68
- gameConfigFields: [
69
- ...KNOWN_GAME_CONFIG_PATHS,
70
- ...REQUIRED_GAME_CONFIG_PATHS,
71
- ],
72
- hostPluginMembers: collectMembers(sources, 'hostPlugin'),
73
- manifestFields: collectManifestFields(sources),
74
- ports: { server: { ...wsports.server }, client: { ...wsports.client } },
75
- requiredGameConfig: [...REQUIRED_GAME_CONFIG_PATHS],
76
- // движковая (не плагинная) величина: плагин её не читает, но смена
77
- // раскладки кадра разводит хост и клиент разных сборок движка — пусть
78
- // попадает в diff и меняется осознанно
79
- snapshotFormatVersion: SNAPSHOT_FORMAT_VERSION,
80
- });
81
- }
82
-
83
- // запись реестра в слепке: имя плюс механика вывода из эксплуатации.
84
- // `note` не пишем — это пояснение для человека, а не форма данных
85
- function registrySection(registry) {
86
- return registry.list().map(({ value, since, alias, retiredIn }) => ({
87
- name: value,
88
- since,
89
- ...(alias === undefined ? {} : { alias, retiredIn }),
90
- }));
91
- }
92
-
93
- // текст всех модулей движка без комментариев и строковых литералов: путь
94
- // 'manifest.json' в строке — не чтение поля манифеста, и попади он в слепок,
95
- // раздел жил бы своей жизнью
96
- async function readSources() {
97
- const files = await listJsFiles(SRC_DIR);
98
- const sources = new Map();
99
-
100
- for (const file of files) {
101
- const rel = path.relative(SRC_DIR, file).replaceAll(path.sep, '/');
102
-
103
- sources.set(rel, stripNoise(await readFile(file, 'utf8')));
104
- }
105
-
106
- return sources;
107
- }
108
-
109
- async function listJsFiles(dir) {
110
- const entries = await readdir(dir, { withFileTypes: true });
111
- const files = [];
112
-
113
- for (const entry of entries) {
114
- // `_`-префикс — черновики, которые не коммитятся (CLAUDE.md)
115
- if (entry.name.startsWith('_')) {
116
- continue;
117
- }
118
-
119
- const full = path.join(dir, entry.name);
120
-
121
- if (entry.isDirectory()) {
122
- files.push(...(await listJsFiles(full)));
123
- } else if (entry.name.endsWith('.js')) {
124
- files.push(full);
125
- }
126
- }
127
-
128
- return files;
129
- }
130
-
131
- // комментарии и литералы вырезаются одним проходом, в порядке появления:
132
- // раздельные проходы спаривают апостроф внутри шаблонной строки с чужим
133
- // апострофом и молча съедают код между ними
134
- const NOISE =
135
- /\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`/g;
136
-
137
- function stripNoise(source) {
138
- return source.replace(NOISE, ' ');
139
- }
140
-
141
- // имена, которые движок читает с объекта плагина: точечные обращения
142
- // (`hostPlugin.gameConfig`, `clientPlugin.hooks.onAuth`) и деструктуризация
143
- function collectMembers(sources, holder) {
144
- const names = new Set();
145
-
146
- for (const source of sources.values()) {
147
- for (const name of readPaths(source, holder)) {
148
- names.add(name);
149
- }
150
- }
151
-
152
- if (names.size === 0) {
153
- throw new Error(
154
- `collectSurface: no member of "${holder}" is read anywhere in ` +
155
- 'src/ — the engine was restructured; fix the collector instead of ' +
156
- 'letting the surface snapshot go silently empty',
157
- );
158
- }
159
-
160
- return [...names];
161
- }
162
-
163
- function collectManifestFields(sources) {
164
- const names = new Set();
165
-
166
- for (const file of MANIFEST_READERS) {
167
- const source = sources.get(file);
168
-
169
- if (source === undefined) {
170
- throw new Error(
171
- `collectSurface: manifest reader ${file} is gone — the engine was ` +
172
- 'restructured; fix the collector',
173
- );
174
- }
175
-
176
- for (const name of readPaths(source, 'manifest')) {
177
- names.add(name);
178
- }
179
- }
180
-
181
- if (names.size === 0) {
182
- throw new Error(
183
- 'collectSurface: no GameManifest field is read anywhere in ' +
184
- `${MANIFEST_READERS.join(', ')} — fix the collector`,
185
- );
186
- }
187
-
188
- return [...names];
189
- }
190
-
191
- // пути, которые модуль читает с объекта `holder`: `holder.a.b` → 'a.b',
192
- // `const { a, b } = holder` → 'a', 'b'
193
- function readPaths(source, holder) {
194
- const names = [];
195
- const dotted = new RegExp(
196
- `\\b${holder}\\??\\.([A-Za-z_$][\\w$]*(?:\\??\\.[A-Za-z_$][\\w$]*)*)`,
197
- 'g',
198
- );
199
- let match;
200
-
201
- while ((match = dotted.exec(source)) !== null) {
202
- names.push(match[1].replaceAll('?.', '.'));
203
- }
204
-
205
- // ровно `= holder`, а не `= holder.gameConfig`: во втором случае имена
206
- // принадлежат не плагину, а его конфигу — у того свой раздел слепка
207
- const destructured = new RegExp(
208
- `\\{([^{}]*)\\}\\s*=\\s*${holder}(?![\\w$.])`,
209
- 'g',
210
- );
211
-
212
- while ((match = destructured.exec(source)) !== null) {
213
- for (const part of match[1].split(',')) {
214
- const name = part
215
- .split(':')[0]
216
- .trim()
217
- .replace(/^\.\.\./, '');
218
-
219
- if (/^[A-Za-z_$][\w$]*$/.test(name)) {
220
- names.push(name);
221
- }
222
- }
223
- }
224
-
225
- return names;
226
- }
227
-
228
- // списки, порядок которых сам по себе является контрактом: аргументы
229
- // ABI-метода читаются позиционно, и алфавит здесь тихо переписал бы
230
- // сигнатуру
231
- const ORDERED_LISTS = ['args'];
232
-
233
- // стабильный порядок: ключи объектов и строковые списки — по алфавиту,
234
- // записи ABI — по имени. Слепок читается диффом, а не глазами
235
- function sortDeep(value, key = null) {
236
- if (Array.isArray(value)) {
237
- const items = value.map(item => sortDeep(item));
238
-
239
- if (ORDERED_LISTS.includes(key)) {
240
- return items;
241
- }
242
-
243
- return items.every(item => typeof item === 'string')
244
- ? items.sort()
245
- : items.sort((a, b) => String(a?.name).localeCompare(String(b?.name)));
246
- }
247
-
248
- if (value !== null && typeof value === 'object') {
249
- return Object.fromEntries(
250
- Object.keys(value)
251
- .sort()
252
- .map(name => [name, sortDeep(value[name], name)]),
253
- );
254
- }
255
-
256
- return value;
257
- }
258
-
259
- /**
260
- * Сериализация слепка для записи в contract/surface.json.
261
- * @param {Object} surface - Результат collectSurface().
262
- * @returns {string} JSON с отступом 2 и переводом строки в конце.
263
- */
264
- export function formatSurface(surface) {
265
- return `${JSON.stringify(sortDeep(surface), null, 2)}\n`;
266
- }
267
-
268
- const INVARIANT_1 =
269
- 'Инвариант И1 (plan/plugin-forward-compat/README.md): имя, которое игра\n' +
270
- 'могла написать, существует вечно. Выведи его из эксплуатации алиасом,\n' +
271
- 'а не удалением. Если это осознанный security-фикс — удали строку из\n' +
272
- 'contract/surface.json тем же коммитом и опиши в CHANGELOG под\n' +
273
- '⚠️ Breaking + Migration.';
274
-
275
- const INVARIANT_2 =
276
- 'Инвариант И2 (plan/plugin-forward-compat/README.md): ничто новое не\n' +
277
- 'обязательно. Новое обязательное поле gameConfig отвергает КАЖДУЮ уже\n' +
278
- 'опубликованную игру, которая о нём не знает. Заведи поле в FIELDS\n' +
279
- 'модуля lib/gameConfigView.js с безопасным умолчанием — список REQUIRED\n' +
280
- 'может только сокращаться.';
281
-
282
- const INVARIANT_3 =
283
- 'Инвариант И3 (plan/plugin-forward-compat/README.md): форма данных\n' +
284
- 'неизменна — рядом добавляется новая. Уже опубликованная сборка игры\n' +
285
- 'вызывает старую форму и не пересоберётся. Если это осознанный слом —\n' +
286
- 'поправь строку в contract/surface.json тем же коммитом и опиши в\n' +
287
- 'CHANGELOG под ⚠️ Breaking + Migration.';
288
-
289
- /**
290
- * Сравнение закоммиченного слепка с собранным.
291
- * @param {Object} committed - Слепок из contract/surface.json.
292
- * @param {Object} collected - Результат collectSurface().
293
- * @returns {{violations: string[], additions: string[]}} Нарушения И1/И3 и
294
- * добавления (добавление поверхности совместимость не ломает).
295
- */
296
- export function diffSurface(committed, collected) {
297
- const violations = [];
298
- const additions = [];
299
-
300
- walk(committed, collected, [], violations, additions);
301
-
302
- return { violations, additions };
303
- }
304
-
305
- function walk(before, after, trail, violations, additions) {
306
- const where = trail.join('.') || 'surface';
307
-
308
- if (Array.isArray(before)) {
309
- walkList(before, after ?? [], where, violations, additions);
310
- return;
311
- }
312
-
313
- if (before !== null && typeof before === 'object') {
314
- for (const key of Object.keys(before)) {
315
- if (after?.[key] === undefined) {
316
- violations.push(`surface: '${key}' исчез из ${where}.\n${INVARIANT_1}`);
317
- continue;
318
- }
319
-
320
- walk(before[key], after[key], [...trail, key], violations, additions);
321
- }
322
-
323
- for (const key of Object.keys(after ?? {})) {
324
- if (before[key] === undefined) {
325
- additions.push(`${where}.${key}`);
326
- }
327
- }
328
-
329
- return;
330
- }
331
-
332
- if (before !== after) {
333
- violations.push(
334
- `surface: ${where} изменился: ${JSON.stringify(before)} → ` +
335
- `${JSON.stringify(after)}.\n` +
336
- (ENGINE_VALUES.has(where) ? ENGINE_VALUE_NOTE : INVARIANT_3),
337
- );
338
- }
339
- }
340
-
341
- // Движковые величины: плагин их не читает, поэтому И3 к ним не относится —
342
- // но менять их молча всё равно нельзя.
343
- const ENGINE_VALUES = new Set(['snapshotFormatVersion']);
344
-
345
- const ENGINE_VALUE_NOTE =
346
- 'Это движковая величина, а не плагинная: игра её не читает и от неё не\n' +
347
- 'зависит. Но раскладка кадра разводит хост и клиент разных сборок\n' +
348
- 'движка — если смена осознанна, поправь contract/surface.json тем же\n' +
349
- 'коммитом и убедись, что расхождение ловит codeVersion.';
350
-
351
- // Разделы с инвертированным правилом: у них расширение — это слом, а
352
- // сокращение — норма. Пока такой один: список обязательных полей
353
- // gameConfig (И2).
354
- const SHRINK_ONLY = new Set(['requiredGameConfig']);
355
-
356
- // требование, которого раньше не было, отвергает старые игры — а снятое
357
- // требование не ломает никого: правило зеркально общему
358
- function walkShrinkOnly(before, after, where, violations, additions) {
359
- const known = new Set(before);
360
-
361
- for (const name of after) {
362
- if (!known.has(name)) {
363
- violations.push(
364
- `surface: '${name}' добавлен в ${where}.\n${INVARIANT_2}`,
365
- );
366
- }
367
- }
368
-
369
- const kept = new Set(after);
370
-
371
- for (const name of before) {
372
- if (!kept.has(name)) {
373
- additions.push(`${where}: снято требование ${name}`);
374
- }
375
- }
376
- }
377
-
378
- function walkList(before, after, where, violations, additions) {
379
- if (SHRINK_ONLY.has(where)) {
380
- walkShrinkOnly(before, after, where, violations, additions);
381
- return;
382
- }
383
-
384
- const key = item => (typeof item === 'string' ? item : item?.name);
385
- const seen = new Map(after.map(item => [key(item), item]));
386
-
387
- for (const item of before) {
388
- const name = key(item);
389
- const found = seen.get(name);
390
-
391
- if (found === undefined) {
392
- violations.push(`surface: '${name}' исчез из ${where}.\n${INVARIANT_1}`);
393
- continue;
394
- }
395
-
396
- if (JSON.stringify(found) !== JSON.stringify(item)) {
397
- violations.push(
398
- `surface: сигнатура '${name}' в ${where} изменилась: ` +
399
- `${JSON.stringify(item)} → ${JSON.stringify(found)}.\n${INVARIANT_3}`,
400
- );
401
- }
402
- }
403
-
404
- const known = new Set(before.map(key));
405
-
406
- for (const item of after) {
407
- if (!known.has(key(item))) {
408
- additions.push(`${where}: ${key(item)}`);
409
- }
410
- }
411
- }
412
-
413
- export default collectSurface;