vimp-engine 0.22.0 → 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.
Files changed (65) hide show
  1. package/bin/vimp-surface.js +88 -0
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/lib/formBuilder.js +27 -8
  5. package/src/client/lib/socketDispatch.js +34 -0
  6. package/src/client/main.js +55 -7
  7. package/src/config/abiOps.js +31 -0
  8. package/src/config/clientServices.js +27 -0
  9. package/src/config/opcodes.js +11 -2
  10. package/src/config/wsports.js +9 -0
  11. package/src/devtools/contract/loadContext.js +25 -2
  12. package/src/devtools/contract/rules/b10-respawns.js +5 -3
  13. package/src/devtools/contract/rules/b2-engine-api.js +31 -8
  14. package/src/devtools/contract/rules/b3-game-config-shape.js +46 -5
  15. package/src/devtools/contract/rules/b4-teams.js +6 -3
  16. package/src/devtools/contract/rules/b5-room-form.js +40 -8
  17. package/src/devtools/contract/rules/c4-component-dependencies.js +10 -15
  18. package/src/devtools/surface/abiParse.js +175 -0
  19. package/src/devtools/surface/collect.js +413 -0
  20. package/src/host/GameCoreAdapter.js +49 -0
  21. package/src/host/meta/core/RoundManager.js +14 -1
  22. package/src/lib/applyRoomOverrides.js +14 -2
  23. package/src/lib/capabilities.js +32 -0
  24. package/src/lib/coreConfig.js +3 -3
  25. package/src/lib/createHostRuntime.js +5 -3
  26. package/src/lib/formControls.js +89 -0
  27. package/src/lib/gameConfigView.js +211 -0
  28. package/src/lib/gamePlugin.js +65 -87
  29. package/src/lib/loadGamePackage.js +9 -2
  30. package/src/lib/registry.js +94 -0
  31. package/src/standalone/index.js +27 -13
  32. package/tests/fixtures/generations/gen-api3/README.md +7 -0
  33. package/tests/fixtures/generations/gen-api3/client/fakeClientCore.js +307 -0
  34. package/tests/fixtures/generations/gen-api3/client/index.js +38 -0
  35. package/tests/fixtures/generations/gen-api3/client/parts/Actor.js +18 -0
  36. package/tests/fixtures/generations/gen-api3/client/parts/ActorRadar.js +7 -0
  37. package/tests/fixtures/generations/gen-api3/config/auth.js +41 -0
  38. package/tests/fixtures/generations/gen-api3/config/client.js +151 -0
  39. package/tests/fixtures/generations/gen-api3/config/game.js +167 -0
  40. package/tests/fixtures/generations/gen-api3/core/pkg-node/core.js +4 -0
  41. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +129 -0
  42. package/tests/fixtures/generations/gen-api3/host/createModules.js +6 -0
  43. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +263 -0
  44. package/tests/fixtures/generations/gen-api3/host/index.js +30 -0
  45. package/tests/fixtures/generations/gen-api3/host/spawnCommand.js +14 -0
  46. package/tests/fixtures/generations/gen-api3/host/systemMessages.js +6 -0
  47. package/tests/fixtures/generations/gen-api3/manifest.json +25 -0
  48. package/tests/fixtures/generations/gen-api4/README.md +7 -0
  49. package/tests/fixtures/generations/gen-api4/client/fakeClientCore.js +307 -0
  50. package/tests/fixtures/generations/gen-api4/client/index.js +38 -0
  51. package/tests/fixtures/generations/gen-api4/client/parts/Actor.js +18 -0
  52. package/tests/fixtures/generations/gen-api4/client/parts/ActorRadar.js +7 -0
  53. package/tests/fixtures/generations/gen-api4/config/auth.js +41 -0
  54. package/tests/fixtures/generations/gen-api4/config/client.js +151 -0
  55. package/tests/fixtures/generations/gen-api4/config/game.js +156 -0
  56. package/tests/fixtures/generations/gen-api4/core/pkg-node/core.js +4 -0
  57. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +129 -0
  58. package/tests/fixtures/generations/gen-api4/host/createModules.js +6 -0
  59. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +263 -0
  60. package/tests/fixtures/generations/gen-api4/host/index.js +30 -0
  61. package/tests/fixtures/generations/gen-api4/host/spawnCommand.js +14 -0
  62. package/tests/fixtures/generations/gen-api4/host/systemMessages.js +6 -0
  63. package/tests/fixtures/generations/gen-api4/manifest.json +17 -0
  64. package/tests/fixtures/miniGame/client/fakeClientCore.js +17 -0
  65. package/tests/fixtures/miniGame/host/fakeCore.js +38 -8
@@ -1,11 +1,31 @@
1
- import { assertGameConfigShape } from '../../../lib/gamePlugin.js';
2
- import { ERROR, skip, verdict } from '../result.js';
1
+ import {
2
+ REQUIRED_GAME_CONFIG_PATHS,
3
+ createGameConfigView,
4
+ } from '../../../lib/gameConfigView.js';
5
+ import { ERROR, WARN, skip, verdict } from '../result.js';
3
6
 
4
7
  // Тот же гейт, что стоит на боевом пути загрузки плагина
5
- // (lib/gamePlugin.js): обязательные пути gameConfig плюс связь
8
+ // (lib/gameConfigView.js): обязательные пути gameConfig плюс связь
6
9
  // spectatorTeam ↔ teams (под noSpectators — «ровно одна команда»).
7
10
  // Дублировать его список здесь нельзя — он обязан эволюционировать в одном
8
11
  // месте.
12
+ //
13
+ // Всё, что не в REQUIRED, движок подставляет сам (И2 этапа 2 плана
14
+ // plugin-forward-compat) — это не отказ. Но полагаться на движковое
15
+ // умолчание разработчик должен осознанно, поэтому необъявленные поля
16
+ // перечисляются предупреждением.
17
+ const DEFAULTED = [
18
+ 'roomDefaults.maxPlayers',
19
+ 'parts.weapons',
20
+ 'parts.friendlyFire',
21
+ 'panel.fields',
22
+ 'spectatorTeam',
23
+ ];
24
+
25
+ function getPath(source, dottedPath) {
26
+ return dottedPath.split('.').reduce((value, key) => value?.[key], source);
27
+ }
28
+
9
29
  export default {
10
30
  id: 'B3',
11
31
  name: 'gameConfigShape',
@@ -18,11 +38,32 @@ export default {
18
38
  }
19
39
 
20
40
  try {
21
- assertGameConfigShape(ctx.hostPlugin);
41
+ createGameConfigView(ctx.hostPlugin.gameConfig, ctx.hostPlugin.id);
22
42
  } catch (error) {
23
43
  return verdict([error.message]);
24
44
  }
25
45
 
26
- return verdict([]);
46
+ // noSpectators — своя ветка контракта: spectatorTeam там не бывает
47
+ const declared = ctx.hostPlugin.gameConfig;
48
+ const paths =
49
+ declared?.noSpectators === true
50
+ ? DEFAULTED.filter(path => path !== 'spectatorTeam')
51
+ : DEFAULTED;
52
+
53
+ const relied = paths.filter(path => {
54
+ const value = getPath(declared, path);
55
+
56
+ return value === undefined || value === null;
57
+ });
58
+
59
+ return verdict(
60
+ relied.map(
61
+ path =>
62
+ `gameConfig.${path} is not declared — the engine falls back to ` +
63
+ 'its own default; declare it if the game means something else',
64
+ ),
65
+ `required: ${REQUIRED_GAME_CONFIG_PATHS.join(', ')}`,
66
+ WARN,
67
+ );
27
68
  },
28
69
  };
@@ -17,7 +17,10 @@ export default {
17
17
  return skip('no gameConfig.teams');
18
18
  }
19
19
 
20
- const { spectatorTeam } = ctx.gameConfig;
20
+ // эффективное значение, а не объявленное: движок выводит spectatorTeam
21
+ // из команды `spectators`, и игра вправе его не писать (И2)
22
+ const spectatorTeam = ctx.gameConfigView?.spectatorTeam ?? null;
23
+ const declared = ctx.gameConfig.spectatorTeam;
21
24
  const violations = [];
22
25
 
23
26
  if (ctx.gameConfig.noSpectators === true) {
@@ -33,9 +36,9 @@ export default {
33
36
  return verdict(violations);
34
37
  }
35
38
 
36
- if (teams[spectatorTeam] === undefined) {
39
+ if (declared !== undefined && teams[declared] === undefined) {
37
40
  violations.push(
38
- `spectatorTeam "${spectatorTeam}" is not a key of teams ` +
41
+ `spectatorTeam "${declared}" is not a key of teams ` +
39
42
  `(${Object.keys(teams).join(', ')})`,
40
43
  );
41
44
  }
@@ -1,13 +1,26 @@
1
- import { ERROR, skip, verdict } from '../result.js';
1
+ import { ERROR, WARN, skip, verdict } from '../result.js';
2
2
  import { anchorPattern } from '../../../lib/formPattern.js';
3
+ import {
4
+ formControls,
5
+ ACTIVE_FORM_CONTROLS,
6
+ } from '../../../lib/formControls.js';
3
7
 
4
8
  // roomForm. Поле вне белого списка форма показывает, лобби отправляет, а
5
9
  // хост молча выбрасывает — правило игры, построенное на своей настройке
6
- // комнаты, не работает и ничего об этом не сообщает. Контролов в v3 ровно
7
- // четыре: неизвестный пропускается с console.error. `regExp` едет в манифесте
8
- // строкой и компилируется уже у игрока некомпилируемая ловится здесь.
9
- const HONOURED = ['maps', 'maxPlayers', 'map', 'roundTime', 'mapTime', 'friendlyFire'];
10
- const CONTROLS = ['text', 'select', 'checkbox', 'radio'];
10
+ // комнаты, не работает и ничего об этом не сообщает. Контрол проверяется по
11
+ // реестру (lib/formControls.js): неизвестный пропускается с console.error,
12
+ // выведенный из эксплуатации строится алиасом и потому только WARN — новая
13
+ // игра его писать не должна, старая продолжает работать. `regExp` едет в
14
+ // манифесте строкой и компилируется уже у игрока — некомпилируемая ловится
15
+ // здесь.
16
+ const HONOURED = [
17
+ 'maps',
18
+ 'maxPlayers',
19
+ 'map',
20
+ 'roundTime',
21
+ 'mapTime',
22
+ 'friendlyFire',
23
+ ];
11
24
 
12
25
  export default {
13
26
  id: 'B5',
@@ -23,6 +36,7 @@ export default {
23
36
  }
24
37
 
25
38
  const violations = [];
39
+ const retired = [];
26
40
 
27
41
  for (const field of roomForm) {
28
42
  if (!HONOURED.includes(field.name)) {
@@ -32,10 +46,20 @@ export default {
32
46
  );
33
47
  }
34
48
 
35
- if (!CONTROLS.includes(field.control)) {
49
+ if (!formControls.has(field.control)) {
36
50
  violations.push(
37
51
  `roomForm field "${field.name}": control "${field.control}" does ` +
38
- `not exist (${CONTROLS.join(', ')})`,
52
+ `not exist (${ACTIVE_FORM_CONTROLS.join(', ')})`,
53
+ );
54
+ } else if (formControls.isRetired(field.control)) {
55
+ const entry = formControls.get(field.control);
56
+
57
+ retired.push(
58
+ `roomForm field "${field.name}": control "${field.control}" was ` +
59
+ `retired in plugin API v${entry.retiredIn} — it still works ` +
60
+ `(the engine builds it as "${formControls.resolve(field.control)}" ` +
61
+ `forever), but a new game should declare ` +
62
+ `"${formControls.resolve(field.control)}" itself`,
39
63
  );
40
64
  }
41
65
 
@@ -54,6 +78,14 @@ export default {
54
78
  }
55
79
  }
56
80
 
81
+ if (violations.length === 0 && retired.length > 0) {
82
+ return verdict(
83
+ retired,
84
+ 'retired controls still build, via aliases',
85
+ WARN,
86
+ );
87
+ }
88
+
57
89
  return verdict(violations);
58
90
  },
59
91
  };
@@ -1,20 +1,15 @@
1
1
  import { ERROR, WARN, skip, verdict } from '../result.js';
2
+ import { SERVICES } from '../../../config/clientServices.js';
2
3
 
3
- // Движковых сервисов ровно пять (client/main.js), но пул ими не
4
- // исчерпывается: игра доливает туда свои через ClientPlugin.hooks.services(core)
5
- // — например геометрию предсказанной динамики карты
6
- // (docs/en/plugin-api.md «hooks.services»). Незнакомое имя не ошибка для
7
- // движка: part получает undefined и рисует пустоту — карта без assetsBase
8
- // выглядит как чистый холст без единой строки в консоли, ради этого правило
9
- // и существует.
10
- const SERVICES = [
11
- 'renderer',
12
- 'soundManager',
13
- 'assetsBase',
14
- 'localPlayer',
15
- // места в глобальном топе (snakes-v3 этап 4)
16
- 'accolades',
17
- ];
4
+ // Движковый пул сервисов держит реестр (config/clientServices.js), но пул
5
+ // ими не исчерпывается: игра доливает туда свои через
6
+ // ClientPlugin.hooks.services(core) — например геометрию предсказанной
7
+ // динамики карты (docs/en/plugin-api.md «hooks.services»). Незнакомое имя не
8
+ // ошибка для движка: part получает undefined и рисует пустоту — карта без
9
+ // assetsBase выглядит как чистый холст без единой строки в консоли, ради
10
+ // этого правило и существует. Правило остаётся ERROR: оно работает на этапе
11
+ // разработки игры, а не в рантайме.
12
+ export { SERVICES };
18
13
 
19
14
  export default {
20
15
  id: 'C4',
@@ -0,0 +1,175 @@
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;