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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
collectSurface,
|
|
7
|
+
formatSurface,
|
|
8
|
+
diffSurface,
|
|
9
|
+
} from '../src/devtools/surface/collect.js';
|
|
10
|
+
|
|
11
|
+
// CLI слепка плагинной поверхности (этап 1 плана plugin-forward-compat):
|
|
12
|
+
// без флага печатает расхождение с закоммиченным contract/surface.json и
|
|
13
|
+
// возвращает 1 при нарушении И1/И3, с --write перезаписывает слепок.
|
|
14
|
+
// Внутренний инструмент репозитория движка: в поле `bin` публикуемого
|
|
15
|
+
// пакета он не значится.
|
|
16
|
+
|
|
17
|
+
const SURFACE_PATH = fileURLToPath(
|
|
18
|
+
new URL('../contract/surface.json', import.meta.url),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const USAGE = `Usage: vimp-surface [options]
|
|
22
|
+
|
|
23
|
+
--write rewrite contract/surface.json with the collected surface
|
|
24
|
+
--help
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
async function main(argv) {
|
|
28
|
+
if (argv.includes('--help')) {
|
|
29
|
+
process.stdout.write(USAGE);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const surface = await collectSurface();
|
|
34
|
+
|
|
35
|
+
if (argv.includes('--write')) {
|
|
36
|
+
await mkdir(path.dirname(SURFACE_PATH), { recursive: true });
|
|
37
|
+
await writeFile(SURFACE_PATH, formatSurface(surface));
|
|
38
|
+
process.stdout.write(`surface: written ${SURFACE_PATH}\n`);
|
|
39
|
+
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let committed;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
committed = JSON.parse(await readFile(SURFACE_PATH, 'utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
process.stderr.write(
|
|
49
|
+
`surface: no snapshot at ${SURFACE_PATH} — run ` +
|
|
50
|
+
'`npm run surface:update` to create it\n',
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { violations, additions } = diffSurface(committed, surface);
|
|
57
|
+
|
|
58
|
+
for (const addition of additions) {
|
|
59
|
+
process.stdout.write(`surface: добавлено ${addition}\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (additions.length > 0 && violations.length === 0) {
|
|
63
|
+
process.stdout.write(
|
|
64
|
+
'слепок устарел, запусти `npm run surface:update` (добавление ' +
|
|
65
|
+
'поверхности совместимость не ломает)\n',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const violation of violations) {
|
|
70
|
+
process.stderr.write(`${violation}\n\n`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (violations.length === 0 && additions.length === 0) {
|
|
74
|
+
process.stdout.write('surface: слепок совпадает\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return violations.length > 0 ? 1 : 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
main(process.argv.slice(2)).then(
|
|
81
|
+
code => {
|
|
82
|
+
process.exitCode = code;
|
|
83
|
+
},
|
|
84
|
+
err => {
|
|
85
|
+
process.stderr.write(`${err.message}\n`);
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
},
|
|
88
|
+
);
|
package/core/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "vimp-engine-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.1"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
|
|
6
6
|
license = "MIT"
|
package/package.json
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import { anchorPattern } from '../../lib/formPattern.js';
|
|
2
2
|
import { normalizeOptions } from '../../lib/formOptions.js';
|
|
3
|
+
import { resolveDescriptor } from '../../lib/formControls.js';
|
|
4
|
+
import { toDisplay, toStored, isNumericField } from '../../lib/formUnit.js';
|
|
3
5
|
|
|
4
6
|
// Общий билдер полей форм (room-форма и auth-форма используют один
|
|
5
7
|
// контракт дескрипторов — docs/en/plugin-api.md, раздел "Form schema").
|
|
6
8
|
// control: 'select'|'text'|'checkbox'|'radio'; все контролы — нативные
|
|
7
|
-
// элементы формы, без визуальной кастомизации.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
9
|
+
// элементы формы, без визуальной кастомизации.
|
|
10
|
+
//
|
|
11
|
+
// Имя контрола приезжает от игры и разрешается через append-only реестр
|
|
12
|
+
// (lib/formControls.js): имена, выведенные из эксплуатации в v3 ('range',
|
|
13
|
+
// 'number', 'toggle', 'segmented'), продолжают строиться алиасами своих
|
|
14
|
+
// нативных замен (И1). Тот же резолв обязана делать авторитетная валидация
|
|
15
|
+
// хоста (lib/validators.js) — иначе поле строится одним контролом, а
|
|
16
|
+
// проверяется как другой.
|
|
17
|
+
//
|
|
18
|
+
// Валидация — не нативная (никаких браузерных попапов):
|
|
19
|
+
// collectFormErrors/renderFormErrors ниже собирают и рисуют ошибки в
|
|
20
|
+
// разметку (#lobby-error/#auth-error).
|
|
10
21
|
// Проверяются только поля, у которых есть строка в DOM: ошибка на скрытом
|
|
11
22
|
// поле игроку не видна и исправить её нечем — она просто запирает форму.
|
|
12
23
|
|
|
@@ -14,12 +25,6 @@ import { normalizeOptions } from '../../lib/formOptions.js';
|
|
|
14
25
|
// resolveForcedValue не расходился с builders при добавлении нового
|
|
15
26
|
const OPTION_CONTROLS = ['select', 'radio'];
|
|
16
27
|
|
|
17
|
-
// числовое text-поле: unit задан или numeric:true. Правило одно на билдер и
|
|
18
|
-
// на валидацию, поэтому живёт в одном месте
|
|
19
|
-
function isNumeric(descriptor) {
|
|
20
|
-
return descriptor.numeric === true || descriptor.unit !== undefined;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
28
|
// 'source' — спец-источник вариантов из каталога движка (например карты),
|
|
24
29
|
// прокидывается вызывающей стороной через ctx.sources
|
|
25
30
|
function resolveOptions(descriptor, ctx) {
|
|
@@ -43,11 +48,14 @@ function forcedValue(options) {
|
|
|
43
48
|
// то же правило вне формы: solo-путь (boot.autoAuth) отвечает хосту без
|
|
44
49
|
// всякой формы и должен прийти ровно к тому же значению, что и она
|
|
45
50
|
export function resolveForcedValue(descriptor, ctx = {}) {
|
|
46
|
-
|
|
51
|
+
// после разрешения алиаса: 'segmented' — та же группа вариантов, что radio
|
|
52
|
+
const resolved = resolveDescriptor(descriptor);
|
|
53
|
+
|
|
54
|
+
if (!OPTION_CONTROLS.includes(resolved.control)) {
|
|
47
55
|
return undefined;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
|
-
const forced = forcedValue(resolveOptions(
|
|
58
|
+
const forced = forcedValue(resolveOptions(resolved, ctx));
|
|
51
59
|
|
|
52
60
|
// форма отдала бы строку: и <option>.value, и <input type=radio>.value —
|
|
53
61
|
// DOM-свойства, они всегда строки. Нестроковое значение (options: [1, 2])
|
|
@@ -56,15 +64,6 @@ export function resolveForcedValue(descriptor, ctx = {}) {
|
|
|
56
64
|
return forced === undefined ? undefined : String(forced);
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
// unit:'s' — значение хранится в мс, показывается/редактируется в секундах
|
|
60
|
-
function toDisplay(descriptor, value) {
|
|
61
|
-
return descriptor.unit === 's' ? value / 1000 : value;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function toStored(descriptor, value) {
|
|
65
|
-
return descriptor.unit === 's' ? value * 1000 : value;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
67
|
function buildSelect(descriptor, ctx) {
|
|
69
68
|
const el = document.createElement('select');
|
|
70
69
|
const options = resolveOptions(descriptor, ctx);
|
|
@@ -109,7 +108,7 @@ function buildSelect(descriptor, ctx) {
|
|
|
109
108
|
// вместо превращения его в 0 на сабмите
|
|
110
109
|
function buildText(descriptor) {
|
|
111
110
|
const el = document.createElement('input');
|
|
112
|
-
const numeric =
|
|
111
|
+
const numeric = isNumericField(descriptor);
|
|
113
112
|
|
|
114
113
|
el.type = 'text';
|
|
115
114
|
// выпадашка прошлых значений/автозаполнения кроет форму, а подставить в
|
|
@@ -265,18 +264,27 @@ const builders = {
|
|
|
265
264
|
radio: buildRadio,
|
|
266
265
|
};
|
|
267
266
|
|
|
267
|
+
// имена control, которые билдер умеет строить. Активный набор имён диктует
|
|
268
|
+
// реестр (lib/formControls.js), и совпадение этих двух списков проверяет
|
|
269
|
+
// tests/client/formControls.test.js: разъехавшись, они дали бы «unknown
|
|
270
|
+
// control» на имени, которое реестр считает живым
|
|
271
|
+
export const FORM_CONTROLS = Object.keys(builders);
|
|
272
|
+
|
|
268
273
|
// строит одно поле формы по дескриптору (docs/en/plugin-api.md, "Form schema")
|
|
269
274
|
export function buildField(descriptor, ctx = {}) {
|
|
270
|
-
|
|
275
|
+
// алиас выведенного контрола ('range' → numeric 'text') разрешается
|
|
276
|
+
// здесь же, где строится поле, и теми же правилами, что в валидации
|
|
277
|
+
const patched = resolveDescriptor(descriptor);
|
|
278
|
+
const build = builders[patched.control];
|
|
271
279
|
|
|
272
280
|
if (!build) {
|
|
273
281
|
throw new Error(`formBuilder: unknown control "${descriptor.control}"`);
|
|
274
282
|
}
|
|
275
283
|
|
|
276
|
-
const field = build(
|
|
284
|
+
const field = build(patched, ctx);
|
|
277
285
|
|
|
278
|
-
if (
|
|
279
|
-
field.setValue(
|
|
286
|
+
if (patched.default !== undefined) {
|
|
287
|
+
field.setValue(patched.default);
|
|
280
288
|
}
|
|
281
289
|
|
|
282
290
|
return field;
|
|
@@ -370,7 +378,7 @@ function validateField(descriptor, field) {
|
|
|
370
378
|
const isEmpty = isText
|
|
371
379
|
? raw === ''
|
|
372
380
|
: value === undefined || value === null || value === '';
|
|
373
|
-
const numeric = isText &&
|
|
381
|
+
const numeric = isText && isNumericField(descriptor);
|
|
374
382
|
|
|
375
383
|
// числовое поле всегда несёт число и «необязательным» быть не может:
|
|
376
384
|
// пустой ввод getValue() подменяет default'ом (чтобы не уехал нолём), и
|
|
@@ -441,7 +449,10 @@ export function collectFormErrors(descriptors, fields) {
|
|
|
441
449
|
return;
|
|
442
450
|
}
|
|
443
451
|
|
|
444
|
-
|
|
452
|
+
// тот же резолв алиаса, что и в buildField: валидация обязана смотреть
|
|
453
|
+
// на контрол, которым поле построено, иначе бывшее range-поле уезжает
|
|
454
|
+
// на хост без проверки диапазона
|
|
455
|
+
const error = validateField(resolveDescriptor(descriptor), field);
|
|
445
456
|
|
|
446
457
|
if (error) {
|
|
447
458
|
errors.push({
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Выбор активной игры из каталога мастера. Вынесено из client/main.js по той
|
|
2
|
+
// же причине, что и socketDispatch.js: main.js исполняется при импорте, и
|
|
3
|
+
// проверить ветвление внутри него нечем.
|
|
4
|
+
//
|
|
5
|
+
// Недоступная игра (manifest.compat.ok === false, этап 5 плана
|
|
6
|
+
// plugin-forward-compat) в СПИСОК лобби попадает — с причиной, — но активной
|
|
7
|
+
// быть не может: её плагин не загрузится, и вкладка встала бы на первой же
|
|
8
|
+
// игре каталога.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Доступна ли игра. Поле `compat` появляется у манифеста каталога мастера,
|
|
12
|
+
* только когда игра просит возможность, которой в этой сборке движка нет;
|
|
13
|
+
* манифест без него (все опубликованные до этапа 5) доступен по определению.
|
|
14
|
+
* @param {Object} manifest - GameManifest из каталога мастера.
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export function isGameAvailable(manifest) {
|
|
18
|
+
return manifest.compat?.ok !== false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Активная игра вкладки.
|
|
23
|
+
*
|
|
24
|
+
* Обе ветки фильтруются одинаково: сохранённый выбор игры (`gameId`) тоже мог
|
|
25
|
+
* указывать на игру, ставшую недоступной после обновления движка, и брать её
|
|
26
|
+
* «раз уж попросили» значило бы падать там, где можно объяснить.
|
|
27
|
+
* @param {Array<Object>} manifests - Каталог мастера.
|
|
28
|
+
* @param {string} [gameId] - Сохранённый выбор игры (boot.gameId).
|
|
29
|
+
* @returns {Object|undefined} Манифест активной игры.
|
|
30
|
+
* @throws {Error} Если каталог непустой, но играбельной игры в нём нет —
|
|
31
|
+
* с причиной по каждой строке.
|
|
32
|
+
*/
|
|
33
|
+
export function pickActiveGame(manifests, gameId) {
|
|
34
|
+
const active = gameId
|
|
35
|
+
? manifests.find(
|
|
36
|
+
manifest => manifest.id === gameId && isGameAvailable(manifest),
|
|
37
|
+
)
|
|
38
|
+
: manifests.find(isGameAvailable);
|
|
39
|
+
|
|
40
|
+
if (active) {
|
|
41
|
+
return active;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// непустой каталог без единой играбельной строки — сказать это одной
|
|
45
|
+
// понятной фразой, а не отдать наверх первую попавшуюся и упасть в
|
|
46
|
+
// loadClientPlugin непрозрачной ошибкой
|
|
47
|
+
if (manifests.length) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'no playable game in the lobby catalog: ' +
|
|
50
|
+
manifests
|
|
51
|
+
.map(manifest => manifest.compat?.text ?? `"${manifest.id}"`)
|
|
52
|
+
.join('; '),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default pickActiveGame;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Диспетчеризация входящего JSON-сообщения [portId, payload] по разрежённому
|
|
2
|
+
// массиву обработчиков (client/main.js: socketMethods).
|
|
3
|
+
//
|
|
4
|
+
// Порт без обработчика — не исключение, а тишина (этап 3 плана
|
|
5
|
+
// plugin-forward-compat). До этого `socketMethods[msg[0]](msg[1])` бросал
|
|
6
|
+
// `TypeError: ... is not a function` и ронял обработку сообщения целиком:
|
|
7
|
+
// получатель падал от того, что отправитель знает больше. Это не плагинная
|
|
8
|
+
// ось (хост и клиент комнаты — один бандл движка, их расхождение ловится
|
|
9
|
+
// codeVersion), но хрупкость ровно та же, и лечится она веткой по умолчанию.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Отдаёт сообщение обработчику своего порта.
|
|
13
|
+
* @param {Array<Function>} methods - Обработчики по номеру порта.
|
|
14
|
+
* @param {Array} msg - Сообщение [portId, payload].
|
|
15
|
+
* @returns {boolean} Было ли сообщение обработано.
|
|
16
|
+
*/
|
|
17
|
+
export function dispatchSocketMessage(methods, msg) {
|
|
18
|
+
const port = msg?.[0];
|
|
19
|
+
const method = methods?.[port];
|
|
20
|
+
|
|
21
|
+
if (typeof method !== 'function') {
|
|
22
|
+
// console.debug, а не error: неизвестный порт — штатная встреча со
|
|
23
|
+
// «стороной, которая знает больше», а не дефект
|
|
24
|
+
console.debug(`client: no handler for port ${port} — message ignored`);
|
|
25
|
+
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
method(msg[1]);
|
|
30
|
+
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default dispatchSocketMessage;
|
package/src/client/main.js
CHANGED
|
@@ -26,7 +26,11 @@ import StatCtrl from './components/controller/Stat.js';
|
|
|
26
26
|
import VoteModel from './components/model/Vote.js';
|
|
27
27
|
import VoteView from './components/view/Vote.js';
|
|
28
28
|
import VoteCtrl from './components/controller/Vote.js';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
buildForm,
|
|
31
|
+
mergeRoomDefaults,
|
|
32
|
+
bindLiveErrors,
|
|
33
|
+
} from './lib/formBuilder.js';
|
|
30
34
|
import { normalizeAuthParams } from './lib/authParams.js';
|
|
31
35
|
import { renderProjectLink } from './lib/footerLink.js';
|
|
32
36
|
import { createGameActivator } from './lib/gameActivator.js';
|
|
@@ -36,6 +40,10 @@ import { ensureGameShell, ensureCanvas } from './views/gameShell.js';
|
|
|
36
40
|
import { createContextTracker } from './lib/contextTracker.js';
|
|
37
41
|
import { createLocalPlayer } from './lib/localPlayer.js';
|
|
38
42
|
import { createAccolades } from './lib/accolades.js';
|
|
43
|
+
import { dispatchSocketMessage } from './lib/socketDispatch.js';
|
|
44
|
+
import { pickActiveGame, isGameAvailable } from './lib/pickActiveGame.js';
|
|
45
|
+
import { readCoreAbi, dispatchCoreOp, ABI_UNKNOWN } from '../lib/coreAbi.js';
|
|
46
|
+
import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
|
|
39
47
|
import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
|
|
40
48
|
import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
|
|
41
49
|
import {
|
|
@@ -146,9 +154,8 @@ try {
|
|
|
146
154
|
throw gamesManifest;
|
|
147
155
|
}
|
|
148
156
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
: gamesManifest[0];
|
|
157
|
+
// недоступная игра активной быть не может (lib/pickActiveGame.js)
|
|
158
|
+
activeGameManifest = pickActiveGame(gamesManifest, boot.gameId);
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
if (!activeGameManifest) {
|
|
@@ -278,6 +285,12 @@ const socketMethods = []; // методы для обработки сокет-
|
|
|
278
285
|
let clientCore = null;
|
|
279
286
|
let wasm = null;
|
|
280
287
|
|
|
288
|
+
// возможности загруженного клиентского ядра ({ abi, core, ops }) —
|
|
289
|
+
// читаются один раз при создании ядра, а не в момент вызова. Ядро старше
|
|
290
|
+
// самоописания даёт поколение 0 с пустым списком опкодов: это не ошибка,
|
|
291
|
+
// а игра, собранная до появления механизма (И2 плана plugin-forward-compat)
|
|
292
|
+
let clientCoreAbi = ABI_UNKNOWN;
|
|
293
|
+
|
|
281
294
|
// сервис пула зависимостей: «эта сущность моя или чужая?». Ядро читается
|
|
282
295
|
// геттером — оно создаётся позже пула сервисов (см. lib/localPlayer.js)
|
|
283
296
|
const localPlayer = createLocalPlayer(() => clientCore);
|
|
@@ -312,6 +325,7 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
312
325
|
|
|
313
326
|
clientCore = core;
|
|
314
327
|
wasm = { memory };
|
|
328
|
+
clientCoreAbi = readCoreAbi(core, 'client core');
|
|
315
329
|
|
|
316
330
|
// инициализация сущностей игры
|
|
317
331
|
for (const entity of Object.keys(entitiesOnCanvas)) {
|
|
@@ -1130,8 +1144,7 @@ const RESYNC_AFTER_HIDDEN_MS = 3000;
|
|
|
1130
1144
|
|
|
1131
1145
|
// вкладка могла быть скрыта уже в момент навешивания слушателя — события
|
|
1132
1146
|
// 'hidden' тогда не будет, а пауза всё равно идёт
|
|
1133
|
-
let hiddenAt =
|
|
1134
|
-
document.visibilityState === 'hidden' ? performance.now() : null;
|
|
1147
|
+
let hiddenAt = document.visibilityState === 'hidden' ? performance.now() : null;
|
|
1135
1148
|
|
|
1136
1149
|
// обработчик видимости вкладки
|
|
1137
1150
|
function handleVisibilityChange() {
|
|
@@ -1170,11 +1183,34 @@ function handleVisibilityChange() {
|
|
|
1170
1183
|
}
|
|
1171
1184
|
}
|
|
1172
1185
|
|
|
1173
|
-
debugLog('clientCore',
|
|
1186
|
+
debugLog('clientCore', clientCoreDebug());
|
|
1174
1187
|
}
|
|
1175
1188
|
}
|
|
1176
1189
|
}
|
|
1177
1190
|
|
|
1191
|
+
// дамп клиентского ядра: сначала опкод dispatch, затем замороженный метод.
|
|
1192
|
+
// Метод не удаляется никогда (И1), поэтому запасной путь остаётся навсегда:
|
|
1193
|
+
// ядро, собранное до появления dispatch, отдаёт дамп по-старому.
|
|
1194
|
+
// dispatchCoreOp — та же точка вызова, что у хостового GameCoreAdapter._op:
|
|
1195
|
+
// имя опкода читается из реестра, три исхода ответа различимы
|
|
1196
|
+
function clientCoreDebug() {
|
|
1197
|
+
if (!clientCore) {
|
|
1198
|
+
return undefined;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const { handled, bytes } = dispatchCoreOp(
|
|
1202
|
+
clientCore,
|
|
1203
|
+
clientCoreAbi,
|
|
1204
|
+
ABI_OP_DEBUG_JSON,
|
|
1205
|
+
);
|
|
1206
|
+
|
|
1207
|
+
if (handled && bytes !== null) {
|
|
1208
|
+
return new TextDecoder().decode(bytes);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
return clientCore.debug_json?.();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1178
1214
|
// единая точка управления рендер-циклом: Ticker.add дубликаты не отсеивает,
|
|
1179
1215
|
// а добавить renderTick могут и runModules, и восстановление контекста
|
|
1180
1216
|
function startRenderLoop() {
|
|
@@ -1270,10 +1306,9 @@ function handleMessage(data) {
|
|
|
1270
1306
|
return;
|
|
1271
1307
|
}
|
|
1272
1308
|
|
|
1273
|
-
// JSON-сообщение [portId, payload]
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
socketMethods[msg[0]](msg[1]);
|
|
1309
|
+
// JSON-сообщение [portId, payload]; порт без обработчика игнорируется, а
|
|
1310
|
+
// не роняет обработку (lib/socketDispatch.js)
|
|
1311
|
+
dispatchSocketMessage(socketMethods, unpacking(data));
|
|
1277
1312
|
}
|
|
1278
1313
|
|
|
1279
1314
|
// разрыв P2P: выход хоста = смерть комнаты (host-migration нет). Останавливаем
|
|
@@ -1373,7 +1408,9 @@ if (isDevBuild) {
|
|
|
1373
1408
|
reportUrl: lobbyConfig.debugReportUrl,
|
|
1374
1409
|
});
|
|
1375
1410
|
|
|
1376
|
-
debugLog(
|
|
1411
|
+
debugLog(
|
|
1412
|
+
'window.__vimpDebug is available: dump, startRecording, stopRecording, divergence',
|
|
1413
|
+
);
|
|
1377
1414
|
}
|
|
1378
1415
|
|
|
1379
1416
|
// WebRTC обязателен для P2P-игры. В Firefox RTCPeerConnection может
|
|
@@ -1904,7 +1941,9 @@ function populateRoomForm(manifest) {
|
|
|
1904
1941
|
|
|
1905
1942
|
// каталог манифестов по id: форма и leaderboard селектора игр, а также
|
|
1906
1943
|
// активация игры перед созданием комнаты и входом в чужую
|
|
1907
|
-
const gamesById = new Map(
|
|
1944
|
+
const gamesById = new Map(
|
|
1945
|
+
gamesManifest.map(manifest => [manifest.id, manifest]),
|
|
1946
|
+
);
|
|
1908
1947
|
|
|
1909
1948
|
// ClientPlugin выбранной игры грузится в момент клика (создание комнаты /
|
|
1910
1949
|
// вход в комнату), а не при смене селектора: просмотр каталога не должен
|
|
@@ -1965,9 +2004,17 @@ function populateGameSelect() {
|
|
|
1965
2004
|
|
|
1966
2005
|
gamesManifest.forEach(manifest => {
|
|
1967
2006
|
const option = document.createElement('option');
|
|
2007
|
+
const available = isGameAvailable(manifest);
|
|
1968
2008
|
|
|
1969
2009
|
option.value = manifest.id;
|
|
1970
|
-
|
|
2010
|
+
// недоступная игра остаётся видимой (раньше мастер выкидывал её из
|
|
2011
|
+
// каталога, и игрок видел пустое лобби без причины), но выбрать её
|
|
2012
|
+
// нельзя: комнату по ней всё равно не поднять
|
|
2013
|
+
option.textContent = available
|
|
2014
|
+
? manifest.title
|
|
2015
|
+
: `${manifest.title} — unavailable`;
|
|
2016
|
+
option.disabled = !available;
|
|
2017
|
+
option.title = available ? '' : manifest.compat.text;
|
|
1971
2018
|
gameSelect.appendChild(option);
|
|
1972
2019
|
});
|
|
1973
2020
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createRegistry } from '../lib/registry.js';
|
|
2
|
+
|
|
3
|
+
// Реестр опкодов `dispatch` (этап 4 плана plugin-forward-compat). Таблица
|
|
4
|
+
// экспортов wasm-ядра заморожена: символ, которого нет в уже опубликованном
|
|
5
|
+
// `.wasm`, там не появится никогда, и никакой адаптер его не синтезирует.
|
|
6
|
+
// Поэтому КАЖДАЯ будущая возможность ядра приезжает опкодом — строкой,
|
|
7
|
+
// которую ядро либо понимает, либо нет, — а не новым методом.
|
|
8
|
+
//
|
|
9
|
+
// Реестр append-only (И1): опкод, который могла начать понимать игра,
|
|
10
|
+
// существует вечно. Удаление опкода = удаление возможности, на которую
|
|
11
|
+
// игра уже могла опереться.
|
|
12
|
+
//
|
|
13
|
+
// Форма записи: `payload`/`response` фиксируют кодировку байтов конкретного
|
|
14
|
+
// опкода (сам `dispatch` их не знает — он возит сырые `Uint8Array`).
|
|
15
|
+
// Пустой ответ ядра — «опкод не обработан»; `[0x00]` — «обработан, ответа
|
|
16
|
+
// нет» (соглашение `abi::dispatch_result` в крейте).
|
|
17
|
+
export const abiOps = createRegistry('abiOps', [
|
|
18
|
+
// курированный дамп мира ядра; дублирует замороженный метод debug_json,
|
|
19
|
+
// с которого началась проверка механизма (этап 4.6)
|
|
20
|
+
{
|
|
21
|
+
value: 'debug.json',
|
|
22
|
+
since: 4,
|
|
23
|
+
payload: 'none',
|
|
24
|
+
response: 'json',
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// плоский список активных опкодов — то, что вправе позвать движок
|
|
29
|
+
export const ABI_OPS = abiOps.values();
|
|
30
|
+
|
|
31
|
+
// Именованные константы вместо литералов в местах вызова: строка 'debug.json',
|
|
32
|
+
// написанная руками у хоста и у клиента, обходила бы реестр молча — новый
|
|
33
|
+
// опкод уехал бы в прод, не попав ни в слепок поверхности, ни в
|
|
34
|
+
// CHANGELOG. Имя опкода читается ТОЛЬКО отсюда.
|
|
35
|
+
export const ABI_OP_DEBUG_JSON = 'debug.json';
|
|
36
|
+
|
|
37
|
+
export default abiOps;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createRegistry } from '../lib/registry.js';
|
|
2
|
+
|
|
3
|
+
// Реестр движковых сервисов клиентского пула (этап 3 плана
|
|
4
|
+
// plugin-forward-compat). Пул ими не исчерпывается: игра доливает туда свои
|
|
5
|
+
// через ClientPlugin.hooks.services(core) — движок о них не знает ничего.
|
|
6
|
+
//
|
|
7
|
+
// Реестр append-only (И1): имя, которое игра написала в
|
|
8
|
+
// componentDependencies, существует вечно. Ключевое поведение, на которое
|
|
9
|
+
// опирается совместимость: пул отдаёт ЗАПРОШЕННОЕ, а не всё подряд
|
|
10
|
+
// (client/providers/DependencyProvider.js), поэтому шестой сервис ничего не
|
|
11
|
+
// требует от старых игр — они его не просят и не получают. Незнакомое имя —
|
|
12
|
+
// не отказ загрузки, а undefined в парте; ловит его правило контракта C4.
|
|
13
|
+
export const clientServices = createRegistry('clientServices', [
|
|
14
|
+
{ value: 'renderer', since: 1 },
|
|
15
|
+
{ value: 'soundManager', since: 1 },
|
|
16
|
+
// база ассетов пакета игры (`${assetsBase}img/...`)
|
|
17
|
+
{ value: 'assetsBase', since: 3 },
|
|
18
|
+
// «свой ли это персонаж» (lib/localPlayer.js)
|
|
19
|
+
{ value: 'localPlayer', since: 3 },
|
|
20
|
+
// места в глобальном топе (lib/accolades.js)
|
|
21
|
+
{ value: 'accolades', since: 4 },
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// плоский список имён — то, чем правило C4 проверяет componentDependencies
|
|
25
|
+
export const SERVICES = clientServices.values();
|
|
26
|
+
|
|
27
|
+
export default clientServices;
|
package/src/config/opcodes.js
CHANGED
|
@@ -3,8 +3,17 @@
|
|
|
3
3
|
// живут в Rust-ядре: packages/engine/core/src/snapshot.rs (pack) и packages/engine/core/src/client/unpack.rs.
|
|
4
4
|
|
|
5
5
|
// версия контракта движок ↔ игра-плагин (GameManifest, HostPlugin,
|
|
6
|
-
// ClientPlugin, Wasm Host ABI — docs/{en,ru}/plugin-api.md)
|
|
7
|
-
//
|
|
6
|
+
// ClientPlugin, Wasm Host ABI — docs/{en,ru}/plugin-api.md).
|
|
7
|
+
//
|
|
8
|
+
// ЗАМОРОЖЕНО на 4. Больше не гейт совместимости и больше не бампается:
|
|
9
|
+
// после этапов 1-4 плана plugin-forward-compat плагинная поверхность
|
|
10
|
+
// append-only, и отвергать плагин за возраст стало не за что. Остаётся как
|
|
11
|
+
// метка поколения контракта в манифестах и диагностике; удалять нельзя —
|
|
12
|
+
// константу импортируют скрипты сборки всех уже существующих игр
|
|
13
|
+
// (build-game-manifest.js) и правило контракта B2. Совместимость решается
|
|
14
|
+
// переговорами о возможностях, см. lib/capabilities.js.
|
|
15
|
+
//
|
|
16
|
+
// История поколений (для чтения манифестов постарше):
|
|
8
17
|
// v2: явная схема форм (roomForm/authSchema.params[].options — Form schema
|
|
9
18
|
// в plugin-api.md), движок больше не выводит контролы из типа значения
|
|
10
19
|
// v3: набор control сокращён до нативных элементов — 'select'|'text'|
|
package/src/config/wsports.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
// Номера портов JSON-канала (docs/en/network.md). Реестр append-only, как и
|
|
2
|
+
// остальные словари плагинной поверхности (этап 3 плана
|
|
3
|
+
// plugin-forward-compat): номер НИКОГДА не переиспользуется и не
|
|
4
|
+
// перенумеровывается — уже опубликованная сборка игры шлёт и слушает те
|
|
5
|
+
// номера, что видела при сборке, и её dist больше никто не тронет.
|
|
6
|
+
// Выведенный из эксплуатации порт остаётся в этом файле со своим номером и
|
|
7
|
+
// пометкой `// retired in vN`, чтобы номер не был выдан повторно. За
|
|
8
|
+
// механикой следит раздел `ports` слепка поверхности
|
|
9
|
+
// (contract/surface.json): удаление имени или смена номера валит тест.
|
|
1
10
|
export default {
|
|
2
11
|
// порты получения данные от сервера
|
|
3
12
|
server: {
|
|
@@ -2,6 +2,7 @@ import { access, readFile, readdir, stat } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { ENGINE_API_VERSION } from '../../config/opcodes.js';
|
|
5
|
+
import { createGameConfigView } from '../../lib/gameConfigView.js';
|
|
5
6
|
|
|
6
7
|
// Сбор контекста статической проверки: по каталогу пакета игры собирается
|
|
7
8
|
// всё, что удалось прочитать, и помечается недостающее. Ни одна неудача не
|
|
@@ -53,12 +54,30 @@ export async function loadContext(gameDir) {
|
|
|
53
54
|
ctx.clientPlugin = await loadHalf(ctx, 'client', CLIENT_ENTRIES);
|
|
54
55
|
|
|
55
56
|
ctx.gameConfig = ctx.hostPlugin?.gameConfig ?? null;
|
|
57
|
+
// то же представление, что построит движок при загрузке (этап 2 плана
|
|
58
|
+
// plugin-forward-compat): правило, которое спрашивает «что получится»,
|
|
59
|
+
// а не «что объявлено», обязано смотреть сюда — иначе оно краснеет на
|
|
60
|
+
// конфиге, который движок принимает. Невалидный конфиг о себе скажет
|
|
61
|
+
// правилом B3, поэтому здесь падение вырождается в исходный объект
|
|
62
|
+
ctx.gameConfigView = resolveConfigView(ctx);
|
|
56
63
|
ctx.authSchema = ctx.hostPlugin?.authSchema ?? null;
|
|
57
64
|
ctx.clientConfig = buildClientConfig(ctx);
|
|
58
65
|
|
|
59
66
|
return ctx;
|
|
60
67
|
}
|
|
61
68
|
|
|
69
|
+
function resolveConfigView(ctx) {
|
|
70
|
+
if (!ctx.gameConfig) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
return createGameConfigView(ctx.gameConfig, ctx.hostPlugin?.id);
|
|
76
|
+
} catch {
|
|
77
|
+
return ctx.gameConfig;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
// Половина плагина: сначала исходник (он свежее сборки), при неудаче —
|
|
63
82
|
// собранный entry из манифеста. Клиентский исходник в Node обычно не
|
|
64
83
|
// импортируется вовсе (`import './game.css'` — расширение Vite, не Node),
|
|
@@ -156,7 +175,9 @@ async function readEngineCoreVersion() {
|
|
|
156
175
|
// Разбор обязан совпадать — правьте обе или ни одной (см. тест
|
|
157
176
|
// tests/scaffold/versions.test.js).
|
|
158
177
|
export function parseCrateVersion(text) {
|
|
159
|
-
const section = text
|
|
178
|
+
const section = text
|
|
179
|
+
.split(/^\s*\[/m)
|
|
180
|
+
.find(part => part.startsWith('package]'));
|
|
160
181
|
|
|
161
182
|
return section?.match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1] ?? null;
|
|
162
183
|
}
|
|
@@ -226,7 +247,9 @@ async function listFiles(root) {
|
|
|
226
247
|
// симлинк-каталог (обычная dev-раскладка: dist/img -> ../assets/img)
|
|
227
248
|
// у readdir не isDirectory(); без разыменования он попал бы в список
|
|
228
249
|
// файлом, и правила ассетов дали бы ложный отказ
|
|
229
|
-
if (
|
|
250
|
+
if (
|
|
251
|
+
entry.isSymbolicLink() ? await isDirectory(full) : entry.isDirectory()
|
|
252
|
+
) {
|
|
230
253
|
await walk(full, rel);
|
|
231
254
|
} else {
|
|
232
255
|
found.add(rel);
|