miaoda-game-devkit 0.8.1 → 0.10.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/README.md CHANGED
@@ -44,6 +44,10 @@ scheduler、Worker 或未通过浏览器 timer 接入的引擎时,应传显式
44
44
  并在每个用例后清理;该 Canvas 适配不执行真实绘制,
45
45
  也不提供 Canvas/WebGL 像素证明;
46
46
 
47
+ - React Vitest 统一统计 `src/game/core/` 与 `src/game/runtime/` 的逐文件覆盖率;Core
48
+ 使用 50/40 分支基线,Runtime 使用 40/30 分支基线。模板通过 `pnpm test` 启用 V8
49
+ coverage,项目不能覆盖范围或降低阈值;
50
+
47
51
  - 支持 DOM 鼠标、键盘和触摸输入的确定性 Phaser 无头测试宿主;
48
52
  - 不依赖渲染器的运行时、交互区域和文字健康检查;
49
53
  - 只在 devkit 自身 CI 中运行、不会发布到业务项目的通用 Vitest 契约测试;
@@ -103,12 +107,19 @@ Devkit 发布统一的 `miaoda` 命令。裸 `miaoda-game-*` 包名默认从公
103
107
 
104
108
  ```bash
105
109
  pnpm exec miaoda mechanics --help
110
+ pnpm exec miaoda mechanics list
111
+ pnpm exec miaoda mechanics list --domain=grid --engine=react
106
112
  pnpm exec miaoda mechanics add miaoda-game-beam-core
107
113
  pnpm exec miaoda mechanics add miaoda-game-beam-core@1.2.3 \
108
114
  --source-index=https://public.example.com/game-mechanics/stable.json
109
115
  pnpm exec miaoda mechanics status
110
116
  ```
111
117
 
118
+ `list` 只列出稳定索引中实际可安装的包,并从 Devkit 自带的 capabilities JSON 动态汇总
119
+ domain 的 `owns` 能力;带筛选时会显示包负责和不负责的边界、使用指引及可测试性。它不会安装
120
+ 源码,也不会维护另一份容易过时的文字目录。先选择覆盖需求的最小包集合,再单独运行 `add`,
121
+ 成功后阅读生成的 `src/game-mechanics/README.md`。
122
+
112
123
  默认索引是 `https://resource-static.bj.bcebos.com/miaoda-game/stable.json`。只有调试、测试或
113
124
  私有镜像场景才需要通过 `--source-index` 或 `MIAODA_MECHANICS_INDEX_URL` 覆盖。
114
125
 
@@ -1493,6 +1493,33 @@ function defineReactGameVitestConfig(options) {
1493
1493
  } : {},
1494
1494
  restoreMocks: true,
1495
1495
  clearMocks: true,
1496
+ coverage: {
1497
+ provider: "v8",
1498
+ include: [
1499
+ "src/game/core/**/*.{ts,tsx}",
1500
+ "src/game/runtime/**/*.{ts,tsx}"
1501
+ ],
1502
+ exclude: [
1503
+ "src/game/{core,runtime}/**/*.d.ts",
1504
+ "src/game/{core,runtime}/**/*.{spec,test}.{ts,tsx}"
1505
+ ],
1506
+ reporter: ["text-summary"],
1507
+ thresholds: {
1508
+ perFile: true,
1509
+ "src/game/core/**/*.{ts,tsx}": {
1510
+ lines: 50,
1511
+ functions: 50,
1512
+ statements: 50,
1513
+ branches: 40
1514
+ },
1515
+ "src/game/runtime/**/*.{ts,tsx}": {
1516
+ lines: 40,
1517
+ functions: 40,
1518
+ statements: 40,
1519
+ branches: 30
1520
+ }
1521
+ }
1522
+ },
1496
1523
  testTimeout: options.testTimeout,
1497
1524
  hookTimeout: options.hookTimeout
1498
1525
  }
@@ -1459,6 +1459,33 @@ function defineReactGameVitestConfig(options) {
1459
1459
  } : {},
1460
1460
  restoreMocks: true,
1461
1461
  clearMocks: true,
1462
+ coverage: {
1463
+ provider: "v8",
1464
+ include: [
1465
+ "src/game/core/**/*.{ts,tsx}",
1466
+ "src/game/runtime/**/*.{ts,tsx}"
1467
+ ],
1468
+ exclude: [
1469
+ "src/game/{core,runtime}/**/*.d.ts",
1470
+ "src/game/{core,runtime}/**/*.{spec,test}.{ts,tsx}"
1471
+ ],
1472
+ reporter: ["text-summary"],
1473
+ thresholds: {
1474
+ perFile: true,
1475
+ "src/game/core/**/*.{ts,tsx}": {
1476
+ lines: 50,
1477
+ functions: 50,
1478
+ statements: 50,
1479
+ branches: 40
1480
+ },
1481
+ "src/game/runtime/**/*.{ts,tsx}": {
1482
+ lines: 40,
1483
+ functions: 40,
1484
+ statements: 40,
1485
+ branches: 30
1486
+ }
1487
+ }
1488
+ },
1462
1489
  testTimeout: options.testTimeout,
1463
1490
  hookTimeout: options.hookTimeout
1464
1491
  }
@@ -0,0 +1,254 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { listIndexedMechanics } from './resolve-game-mechanics-source-index.mjs';
3
+
4
+ const ENGINES = new Set(['neutral', 'react', 'phaser', 'cocos']);
5
+ const FILTER_NAMES = ['domain', 'engine', 'owns'];
6
+
7
+ function readCapabilityDocument(path) {
8
+ try {
9
+ return JSON.parse(readFileSync(path, 'utf8'));
10
+ } catch (error) {
11
+ const detail = error instanceof Error ? error.message : String(error);
12
+ throw new Error(`miaoda mechanics list: cannot read capabilities JSON: ${detail}`);
13
+ }
14
+ }
15
+
16
+ function optionValue(argv, name) {
17
+ const prefix = `--${name}=`;
18
+ const argument = argv.find((value) => value.startsWith(prefix));
19
+ return argument?.slice(prefix.length).trim();
20
+ }
21
+
22
+ export function parseMechanicListArguments(argv, defaultSourceIndexUrl) {
23
+ const allowed = new Set(['--json', '--help', '-h']);
24
+ for (const argument of argv.slice(1)) {
25
+ if (allowed.has(argument)) {
26
+ continue;
27
+ }
28
+ if (['--domain=', '--engine=', '--owns=', '--source-index='].some((prefix) => argument.startsWith(prefix))) {
29
+ continue;
30
+ }
31
+ throw new Error(
32
+ `miaoda mechanics list: unsupported argument ${JSON.stringify(argument)}. ` +
33
+ 'Use --domain=<domain>, --engine=<engine>, --owns=<capability>, or --json.',
34
+ );
35
+ }
36
+ const filters = Object.fromEntries(
37
+ FILTER_NAMES.map((name) => [name, optionValue(argv, name)]).filter(([, value]) => Boolean(value)),
38
+ );
39
+ if (filters.engine && !ENGINES.has(filters.engine)) {
40
+ throw new Error(
41
+ `miaoda mechanics list: engine must be neutral, react, phaser, or cocos; received ${filters.engine}.`,
42
+ );
43
+ }
44
+ return {
45
+ filters,
46
+ json: argv.includes('--json'),
47
+ sourceIndexUrl: optionValue(argv, 'source-index') || defaultSourceIndexUrl,
48
+ };
49
+ }
50
+
51
+ function packageMatches(entry, filters) {
52
+ if (!entry.annotated) {
53
+ return Object.keys(filters).length === 0;
54
+ }
55
+ if (filters.domain && !entry.domains.includes(filters.domain)) {
56
+ return false;
57
+ }
58
+ if (filters.owns && !entry.provides.includes(filters.owns)) {
59
+ return false;
60
+ }
61
+ if (filters.engine) {
62
+ const compatible =
63
+ filters.engine === 'neutral'
64
+ ? entry.engine === 'neutral'
65
+ : entry.engine === 'neutral' || entry.engine === filters.engine;
66
+ if (!compatible) {
67
+ return false;
68
+ }
69
+ }
70
+ return true;
71
+ }
72
+
73
+ function packageEntry(name, version, capability, installableNames) {
74
+ if (!capability) {
75
+ return { name, version, annotated: false };
76
+ }
77
+ return {
78
+ name,
79
+ version,
80
+ annotated: true,
81
+ engine: capability.engine,
82
+ domains: capability.domains,
83
+ provides: capability.owns,
84
+ leavesOutside: capability.doesNotOwn,
85
+ compatibleWith: capability.compatibleWith.filter((candidate) => installableNames.has(candidate)),
86
+ useFor: capability.guidance?.useFor ?? [],
87
+ keepOutside: capability.guidance?.keepOutside ?? [],
88
+ rules: capability.guidance?.rules ?? [],
89
+ useInsteadWhen: capability.useInsteadWhen,
90
+ testability: capability.testability,
91
+ persistence: capability.persistence,
92
+ };
93
+ }
94
+
95
+ function summarizeDomains(packages) {
96
+ const domains = new Map();
97
+ for (const entry of packages) {
98
+ if (!entry.annotated) {
99
+ continue;
100
+ }
101
+ for (const domain of entry.domains) {
102
+ const summary = domains.get(domain) ?? { name: domain, packageNames: new Set(), provides: new Set() };
103
+ summary.packageNames.add(entry.name);
104
+ for (const capability of entry.provides) {
105
+ summary.provides.add(capability);
106
+ }
107
+ domains.set(domain, summary);
108
+ }
109
+ }
110
+ return [...domains.values()]
111
+ .sort((left, right) => left.name.localeCompare(right.name))
112
+ .map((summary) => ({
113
+ name: summary.name,
114
+ packageCount: summary.packageNames.size,
115
+ provides: [...summary.provides].sort(),
116
+ }));
117
+ }
118
+
119
+ export function createMechanicCatalog(indexedPackages, capabilityDocument, filters = {}, sourceIndexUrl) {
120
+ const installableNames = new Set(indexedPackages.map((entry) => entry.name));
121
+ const allPackages = indexedPackages
122
+ .map(({ name, version }) =>
123
+ packageEntry(name, version, capabilityDocument.packages?.[name], installableNames),
124
+ )
125
+ .sort((left, right) => left.name.localeCompare(right.name));
126
+ const matchedPackages = allPackages.filter((entry) => packageMatches(entry, filters));
127
+ const matchedNames = new Set(matchedPackages.map((entry) => entry.name));
128
+ const packages = matchedPackages.map((entry) => {
129
+ if (!entry.annotated) {
130
+ return entry;
131
+ }
132
+ return {
133
+ ...entry,
134
+ compatibleWith: entry.compatibleWith.filter((candidate) => matchedNames.has(candidate)),
135
+ };
136
+ });
137
+ const filtersActive = Object.keys(filters).length > 0;
138
+ return {
139
+ schemaVersion: 1,
140
+ sourceIndexUrl,
141
+ filters,
142
+ installablePackageCount: allPackages.length,
143
+ unannotatedPackageCount: allPackages.filter((entry) => !entry.annotated).length,
144
+ domains: summarizeDomains(filtersActive ? packages : allPackages),
145
+ packages,
146
+ };
147
+ }
148
+
149
+ export async function listMechanics({ sourceIndexUrl, capabilityPath, filters }) {
150
+ const indexed = await listIndexedMechanics(sourceIndexUrl);
151
+ const capabilityDocument = readCapabilityDocument(capabilityPath);
152
+ return createMechanicCatalog(indexed.packages, capabilityDocument, filters, indexed.sourceIndexUrl);
153
+ }
154
+
155
+ function formatList(values) {
156
+ return values.length > 0 ? values.join(', ') : 'none declared';
157
+ }
158
+
159
+ function formatTestability(testability) {
160
+ if (!testability) {
161
+ return undefined;
162
+ }
163
+ return `observation=${testability.observation}; advance=${testability.advance}; ` +
164
+ `observe: ${testability.methods.observe}; exercise: ${testability.methods.advance}`;
165
+ }
166
+
167
+ function formatPackage(entry) {
168
+ if (!entry.annotated) {
169
+ return `${entry.name}@${entry.version}\n Capability metadata: unavailable; inspect its README after add.`;
170
+ }
171
+ const lines = [
172
+ `${entry.name}@${entry.version} [${entry.engine}]`,
173
+ ` Provides: ${formatList(entry.provides)}`,
174
+ ` Leaves outside: ${formatList(entry.leavesOutside)}`,
175
+ ];
176
+ if (entry.useFor.length > 0) {
177
+ lines.push(` Use for: ${entry.useFor.join(' ')}`);
178
+ }
179
+ if (entry.keepOutside.length > 0) {
180
+ lines.push(` Keep outside: ${entry.keepOutside.join(' ')}`);
181
+ }
182
+ if (entry.rules.length > 0) {
183
+ lines.push(` Rules: ${entry.rules.join(' ')}`);
184
+ }
185
+ if (entry.compatibleWith.length > 0) {
186
+ lines.push(` Works with: ${entry.compatibleWith.join(', ')}`);
187
+ }
188
+ for (const alternative of entry.useInsteadWhen) {
189
+ lines.push(` Use ${alternative.package} instead when: ${alternative.condition}`);
190
+ }
191
+ const testability = formatTestability(entry.testability);
192
+ if (testability) {
193
+ lines.push(` Testability: ${testability}`);
194
+ }
195
+ return lines.join('\n');
196
+ }
197
+
198
+ function formatFilters(filters) {
199
+ const values = Object.entries(filters).map(([name, value]) => `${name}=${value}`);
200
+ return values.length > 0 ? values.join(', ') : 'none';
201
+ }
202
+
203
+ export function formatMechanicList(catalog, { json = false } = {}) {
204
+ if (json) {
205
+ return JSON.stringify(catalog, null, 2);
206
+ }
207
+ const filtersActive = Object.keys(catalog.filters).length > 0;
208
+ if (!filtersActive) {
209
+ const domainLines = catalog.domains.map((domain) => {
210
+ const preview = domain.provides.slice(0, 3).join(', ');
211
+ const remaining = Math.max(0, domain.provides.length - 3);
212
+ const suffix = remaining > 0 ? `, +${remaining} more` : '';
213
+ return ` ${domain.name} (${domain.packageCount}) — can provide: ${preview}${suffix}`;
214
+ });
215
+ const annotationNote =
216
+ catalog.unannotatedPackageCount > 0
217
+ ? `\n${catalog.unannotatedPackageCount} installable packages lack capability annotations.`
218
+ : '';
219
+ return `Installable Miaoda mechanic domains (${catalog.domains.length} domains, ` +
220
+ `${catalog.installablePackageCount} packages):\n${domainLines.join('\n')}${annotationNote}\n\n` +
221
+ 'Next:\n' +
222
+ ' Rerun with --domain=<domain>, --engine=<react|phaser|cocos|neutral>, or --owns=<capability>.\n' +
223
+ ' Use --json for complete machine-readable facts. Do not install every match.';
224
+ }
225
+ if (catalog.packages.length === 0) {
226
+ return `No installable mechanics matched ${formatFilters(catalog.filters)}.\n` +
227
+ 'Run "pnpm exec miaoda mechanics list" to inspect available domains and exact capability names.';
228
+ }
229
+ return `Matched ${catalog.packages.length} installable mechanic packages (${formatFilters(catalog.filters)}):\n\n` +
230
+ `${catalog.packages.map(formatPackage).join('\n\n')}\n\n` +
231
+ 'Next:\n' +
232
+ ' Select the smallest set whose Provides entries own required mechanics, then run:\n' +
233
+ ' pnpm exec miaoda mechanics add <full miaoda-game-* names>\n' +
234
+ ' Run add by itself, then read src/game-mechanics/README.md before gameplay edits.';
235
+ }
236
+
237
+ export const LIST_HELP = `Usage: miaoda mechanics list [filters]
238
+
239
+ Discover packages that are currently installable through the source index. Package
240
+ meaning comes from the bundled capabilities JSON: owns becomes Provides, doesNotOwn
241
+ becomes Leaves outside, and guidance/testability are shown when annotated. This command
242
+ is read-only and never installs source.
243
+
244
+ Without filters, list prints domain summaries with real owned-capability previews.
245
+ With filters, it prints package versions and ownership boundaries. An engine filter
246
+ includes engine-neutral cores plus adapters for that engine.
247
+
248
+ Filters:
249
+ --domain=<domain> Match one exact domain from the summary
250
+ --engine=<engine> neutral, react, phaser, or cocos
251
+ --owns=<capability> Match one exact owned-capability token
252
+ --json Emit complete machine-readable JSON
253
+ --source-index=<url> Override the public stable.json URL
254
+ -h, --help Show this help`;
@@ -16,6 +16,12 @@ import {
16
16
  } from 'node:fs';
17
17
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
+ import {
20
+ formatMechanicList,
21
+ LIST_HELP,
22
+ listMechanics,
23
+ parseMechanicListArguments,
24
+ } from './list-game-mechanics-source.mjs';
19
25
  import {
20
26
  areIndexedPackageSpecs,
21
27
  cleanupIndexedMechanics,
@@ -657,6 +663,7 @@ Manage editable Miaoda game-mechanic TypeScript source in src/game-mechanics.
657
663
 
658
664
  Commands:
659
665
  add <package...> Resolve packages from the source index and add editable source
666
+ list Discover installable packages and their ownership boundaries
660
667
  status Show clean, modified, or missing source packages
661
668
  help Show this help
662
669
 
@@ -860,10 +867,24 @@ export async function main(argv = process.argv.slice(2)) {
860
867
  const mechanicsArguments = argv[0] === 'mechanics' ? argv.slice(1) : argv;
861
868
  const command = mechanicsArguments[0];
862
869
  const wantsHelp = mechanicsArguments.includes('--help') || mechanicsArguments.includes('-h');
863
- if (!command || command === 'help' || (wantsHelp && !['add', 'status'].includes(command))) {
870
+ if (!command || command === 'help' || (wantsHelp && !['add', 'list', 'status'].includes(command))) {
864
871
  console.log(ROOT_HELP);
865
872
  return;
866
873
  }
874
+ if (command === 'list') {
875
+ if (wantsHelp) {
876
+ console.log(LIST_HELP);
877
+ return;
878
+ }
879
+ const options = parseMechanicListArguments(mechanicsArguments, DEFAULT_SOURCE_INDEX_URL);
880
+ const catalog = await listMechanics({
881
+ sourceIndexUrl: options.sourceIndexUrl,
882
+ capabilityPath,
883
+ filters: options.filters,
884
+ });
885
+ console.log(formatMechanicList(catalog, { json: options.json }));
886
+ return catalog;
887
+ }
867
888
  if (command === 'add') {
868
889
  if (wantsHelp) {
869
890
  console.log(ADD_HELP);
@@ -312,6 +312,20 @@ export async function resolveIndexedMechanics({ indexUrl, previousRoots = {}, sp
312
312
  }
313
313
  }
314
314
 
315
+ export async function listIndexedMechanics(indexUrl) {
316
+ const loaded = await readIndex(indexUrl);
317
+ const packages = Object.keys(loaded.index.packages)
318
+ .sort()
319
+ .map((name) => {
320
+ const selected = selectVersion(loaded.index, name, undefined, loaded.indexUrl);
321
+ return { name: selected.name, version: selected.version };
322
+ });
323
+ return {
324
+ sourceIndexUrl: loaded.indexUrl,
325
+ packages,
326
+ };
327
+ }
328
+
315
329
  export function cleanupIndexedMechanics(resolution) {
316
330
  if (resolution?.temporaryRoot) {
317
331
  rmSync(resolution.temporaryRoot, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "Shared React and Phaser game lint plus deterministic testing tools for Miaoda games",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",