miaoda-game-devkit 0.9.0 → 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
@@ -107,12 +107,19 @@ Devkit 发布统一的 `miaoda` 命令。裸 `miaoda-game-*` 包名默认从公
107
107
 
108
108
  ```bash
109
109
  pnpm exec miaoda mechanics --help
110
+ pnpm exec miaoda mechanics list
111
+ pnpm exec miaoda mechanics list --domain=grid --engine=react
110
112
  pnpm exec miaoda mechanics add miaoda-game-beam-core
111
113
  pnpm exec miaoda mechanics add miaoda-game-beam-core@1.2.3 \
112
114
  --source-index=https://public.example.com/game-mechanics/stable.json
113
115
  pnpm exec miaoda mechanics status
114
116
  ```
115
117
 
118
+ `list` 只列出稳定索引中实际可安装的包,并从 Devkit 自带的 capabilities JSON 动态汇总
119
+ domain 的 `owns` 能力;带筛选时会显示包负责和不负责的边界、使用指引及可测试性。它不会安装
120
+ 源码,也不会维护另一份容易过时的文字目录。先选择覆盖需求的最小包集合,再单独运行 `add`,
121
+ 成功后阅读生成的 `src/game-mechanics/README.md`。
122
+
116
123
  默认索引是 `https://resource-static.bj.bcebos.com/miaoda-game/stable.json`。只有调试、测试或
117
124
  私有镜像场景才需要通过 `--source-index` 或 `MIAODA_MECHANICS_INDEX_URL` 覆盖。
118
125
 
@@ -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.9.0",
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",