miaoda-game-devkit 0.9.0 → 0.10.1

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
 
@@ -1,8 +1,92 @@
1
1
  // src/lint/setup.ts
2
2
  import { afterEach } from "vitest";
3
3
 
4
- // src/testing/jsdom-canvas.ts
4
+ // src/testing/jsdom-webgl.ts
5
+ var CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/;
6
+ var TEXTURE_UNIT_LIMIT = 32;
7
+ var TEXTURE_SIZE_LIMIT = 4096;
8
+ var VIEWPORT_LIMIT = 4096;
9
+ function parameterValue(name) {
10
+ if (name === "VERSION") return "WebGL 2.0 (miaoda-game-devkit stub)";
11
+ if (name === "SHADING_LANGUAGE_VERSION") return "WebGL GLSL ES 3.00 (stub)";
12
+ if (name === "VENDOR" || name === "RENDERER") return "miaoda-game-devkit";
13
+ if (name === "MAX_VIEWPORT_DIMS") {
14
+ return new Int32Array([VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
15
+ }
16
+ if (name === "VIEWPORT" || name === "SCISSOR_BOX") {
17
+ return new Int32Array([0, 0, VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
18
+ }
19
+ if (name.includes("MAX_") && name.includes("SIZE")) return TEXTURE_SIZE_LIMIT;
20
+ if (name.startsWith("MAX_")) return TEXTURE_UNIT_LIMIT;
21
+ if (name.endsWith("_BITS")) return 8;
22
+ return 0;
23
+ }
24
+ function createWebGLContext(canvas, attributes) {
25
+ const constants = /* @__PURE__ */ new Map();
26
+ const constantNames = /* @__PURE__ */ new Map();
27
+ const state = {
28
+ canvas,
29
+ drawingBufferWidth: canvas.width,
30
+ drawingBufferHeight: canvas.height
31
+ };
32
+ const method = (name) => {
33
+ if (name === "getContextAttributes") return () => attributes;
34
+ if (name === "getExtension") return () => null;
35
+ if (name === "getSupportedExtensions") return () => [];
36
+ if (name === "getParameter") {
37
+ return (pname) => parameterValue(constantNames.get(pname) ?? "UNKNOWN");
38
+ }
39
+ if (name === "getShaderPrecisionFormat") {
40
+ return () => ({ rangeMin: 127, rangeMax: 127, precision: 23 });
41
+ }
42
+ if (name === "getShaderInfoLog" || name === "getProgramInfoLog") return () => "";
43
+ if (name === "getError") return () => 0;
44
+ if (name === "isContextLost") return () => false;
45
+ if (name.startsWith("get") && name.endsWith("Parameter")) return () => true;
46
+ if (name.startsWith("get")) return () => null;
47
+ if (name.startsWith("create") || name.startsWith("getUniformLocation")) {
48
+ return () => ({});
49
+ }
50
+ return () => void 0;
51
+ };
52
+ return new Proxy(state, {
53
+ get(target, property) {
54
+ if (property in target) return target[property];
55
+ if (typeof property !== "string") return void 0;
56
+ if (CONSTANT_NAME.test(property)) {
57
+ const existing = constants.get(property);
58
+ if (existing !== void 0) return existing;
59
+ const value = constants.size + 1;
60
+ constants.set(property, value);
61
+ constantNames.set(value, property);
62
+ return value;
63
+ }
64
+ const implementation = method(property);
65
+ target[property] = implementation;
66
+ return implementation;
67
+ }
68
+ });
69
+ }
5
70
  var contexts = /* @__PURE__ */ new WeakMap();
71
+ function getJSDOMWebGLContext(canvas, options) {
72
+ const existing = contexts.get(canvas);
73
+ if (existing) return existing;
74
+ const attributes = {
75
+ alpha: true,
76
+ antialias: false,
77
+ depth: true,
78
+ premultipliedAlpha: true,
79
+ preserveDrawingBuffer: false,
80
+ stencil: false,
81
+ ...typeof options === "object" && options !== null ? options : {}
82
+ };
83
+ const context = createWebGLContext(canvas, attributes);
84
+ contexts.set(canvas, context);
85
+ return context;
86
+ }
87
+
88
+ // src/testing/jsdom-canvas.ts
89
+ var contexts2 = /* @__PURE__ */ new WeakMap();
6
90
  function createCanvasContext(canvas) {
7
91
  const imageData = (width = 1, height = 1) => ({
8
92
  data: new Uint8ClampedArray(width * height * 4),
@@ -49,12 +133,15 @@ function createCanvasContext(canvas) {
49
133
  return context;
50
134
  }
51
135
  function installJSDOMCanvasContext() {
52
- HTMLCanvasElement.prototype.getContext = function getContext(contextId) {
136
+ HTMLCanvasElement.prototype.getContext = function getContext(contextId, options) {
137
+ if (contextId === "webgl" || contextId === "webgl2") {
138
+ return getJSDOMWebGLContext(this, options);
139
+ }
53
140
  if (contextId !== "2d") return null;
54
- const existing = contexts.get(this);
141
+ const existing = contexts2.get(this);
55
142
  if (existing) return existing;
56
143
  const context = createCanvasContext(this);
57
- contexts.set(this, context);
144
+ contexts2.set(this, context);
58
145
  return context;
59
146
  };
60
147
  }
@@ -1452,7 +1452,36 @@ function resolvePhaser3BrowserEntry(projectRoot) {
1452
1452
  return void 0;
1453
1453
  }
1454
1454
  }
1455
+ var COVERAGE_INCLUDE = [
1456
+ "src/game/core/**/*.{ts,tsx}",
1457
+ "src/game/runtime/**/*.{ts,tsx}"
1458
+ ];
1459
+ var COVERAGE_EXCLUDE = [
1460
+ "src/game/{core,runtime}/**/*.d.ts",
1461
+ "src/game/{core,runtime}/**/*.{spec,test}.{ts,tsx}"
1462
+ ];
1463
+ var GATE_SCAN_EXCLUDE = [
1464
+ "src/**/*.d.ts",
1465
+ "src/**/*.{spec,test}.{ts,tsx}",
1466
+ "src/game/example/**"
1467
+ ];
1468
+ function assertCoverageScopeIsPopulated(projectRoot) {
1469
+ const scan = (pattern) => (0, import_node_fs3.globSync)(pattern, { cwd: projectRoot, exclude: GATE_SCAN_EXCLUDE });
1470
+ if (COVERAGE_INCLUDE.flatMap(scan).length > 0) return;
1471
+ const misplaced = scan("src/**/{core,runtime}/**/*.{ts,tsx}");
1472
+ if (misplaced.length === 0) return;
1473
+ throw new Error(
1474
+ [
1475
+ "The coverage gate matched no source file, so it proves nothing.",
1476
+ "Deterministic rules belong in src/game/core/; the loop, scheduling,",
1477
+ "Controller, and Telemetry belong in src/game/runtime/.",
1478
+ "Move these files under those two directories:",
1479
+ ...misplaced.slice(0, 10).map((file) => ` ${file}`)
1480
+ ].join("\n")
1481
+ );
1482
+ }
1455
1483
  function defineReactGameVitestConfig(options) {
1484
+ assertCoverageScopeIsPopulated(options.projectRoot);
1456
1485
  const phaser3BrowserEntry = resolvePhaser3BrowserEntry(options.projectRoot);
1457
1486
  return (0, import_config.defineConfig)({
1458
1487
  // Keep discovery and dependency resolution anchored to the generated app even
@@ -1495,14 +1524,8 @@ function defineReactGameVitestConfig(options) {
1495
1524
  clearMocks: true,
1496
1525
  coverage: {
1497
1526
  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
- ],
1527
+ include: COVERAGE_INCLUDE,
1528
+ exclude: COVERAGE_EXCLUDE,
1506
1529
  reporter: ["text-summary"],
1507
1530
  thresholds: {
1508
1531
  perFile: true,
@@ -1,5 +1,5 @@
1
1
  // src/react-vitest-config.ts
2
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
2
+ import { existsSync as existsSync3, globSync, readFileSync as readFileSync2 } from "fs";
3
3
  import { dirname as dirname2, resolve as resolve3 } from "path";
4
4
  import { defineConfig } from "vitest/config";
5
5
 
@@ -1418,7 +1418,36 @@ function resolvePhaser3BrowserEntry(projectRoot) {
1418
1418
  return void 0;
1419
1419
  }
1420
1420
  }
1421
+ var COVERAGE_INCLUDE = [
1422
+ "src/game/core/**/*.{ts,tsx}",
1423
+ "src/game/runtime/**/*.{ts,tsx}"
1424
+ ];
1425
+ var COVERAGE_EXCLUDE = [
1426
+ "src/game/{core,runtime}/**/*.d.ts",
1427
+ "src/game/{core,runtime}/**/*.{spec,test}.{ts,tsx}"
1428
+ ];
1429
+ var GATE_SCAN_EXCLUDE = [
1430
+ "src/**/*.d.ts",
1431
+ "src/**/*.{spec,test}.{ts,tsx}",
1432
+ "src/game/example/**"
1433
+ ];
1434
+ function assertCoverageScopeIsPopulated(projectRoot) {
1435
+ const scan = (pattern) => globSync(pattern, { cwd: projectRoot, exclude: GATE_SCAN_EXCLUDE });
1436
+ if (COVERAGE_INCLUDE.flatMap(scan).length > 0) return;
1437
+ const misplaced = scan("src/**/{core,runtime}/**/*.{ts,tsx}");
1438
+ if (misplaced.length === 0) return;
1439
+ throw new Error(
1440
+ [
1441
+ "The coverage gate matched no source file, so it proves nothing.",
1442
+ "Deterministic rules belong in src/game/core/; the loop, scheduling,",
1443
+ "Controller, and Telemetry belong in src/game/runtime/.",
1444
+ "Move these files under those two directories:",
1445
+ ...misplaced.slice(0, 10).map((file) => ` ${file}`)
1446
+ ].join("\n")
1447
+ );
1448
+ }
1421
1449
  function defineReactGameVitestConfig(options) {
1450
+ assertCoverageScopeIsPopulated(options.projectRoot);
1422
1451
  const phaser3BrowserEntry = resolvePhaser3BrowserEntry(options.projectRoot);
1423
1452
  return defineConfig({
1424
1453
  // Keep discovery and dependency resolution anchored to the generated app even
@@ -1461,14 +1490,8 @@ function defineReactGameVitestConfig(options) {
1461
1490
  clearMocks: true,
1462
1491
  coverage: {
1463
1492
  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
- ],
1493
+ include: COVERAGE_INCLUDE,
1494
+ exclude: COVERAGE_EXCLUDE,
1472
1495
  reporter: ["text-summary"],
1473
1496
  thresholds: {
1474
1497
  perFile: true,
@@ -184,8 +184,92 @@ function runtimeFailureError(code, value) {
184
184
  return error;
185
185
  }
186
186
 
187
- // src/testing/jsdom-canvas.ts
187
+ // src/testing/jsdom-webgl.ts
188
+ var CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/;
189
+ var TEXTURE_UNIT_LIMIT = 32;
190
+ var TEXTURE_SIZE_LIMIT = 4096;
191
+ var VIEWPORT_LIMIT = 4096;
192
+ function parameterValue(name) {
193
+ if (name === "VERSION") return "WebGL 2.0 (miaoda-game-devkit stub)";
194
+ if (name === "SHADING_LANGUAGE_VERSION") return "WebGL GLSL ES 3.00 (stub)";
195
+ if (name === "VENDOR" || name === "RENDERER") return "miaoda-game-devkit";
196
+ if (name === "MAX_VIEWPORT_DIMS") {
197
+ return new Int32Array([VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
198
+ }
199
+ if (name === "VIEWPORT" || name === "SCISSOR_BOX") {
200
+ return new Int32Array([0, 0, VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
201
+ }
202
+ if (name.includes("MAX_") && name.includes("SIZE")) return TEXTURE_SIZE_LIMIT;
203
+ if (name.startsWith("MAX_")) return TEXTURE_UNIT_LIMIT;
204
+ if (name.endsWith("_BITS")) return 8;
205
+ return 0;
206
+ }
207
+ function createWebGLContext(canvas, attributes) {
208
+ const constants = /* @__PURE__ */ new Map();
209
+ const constantNames = /* @__PURE__ */ new Map();
210
+ const state = {
211
+ canvas,
212
+ drawingBufferWidth: canvas.width,
213
+ drawingBufferHeight: canvas.height
214
+ };
215
+ const method = (name) => {
216
+ if (name === "getContextAttributes") return () => attributes;
217
+ if (name === "getExtension") return () => null;
218
+ if (name === "getSupportedExtensions") return () => [];
219
+ if (name === "getParameter") {
220
+ return (pname) => parameterValue(constantNames.get(pname) ?? "UNKNOWN");
221
+ }
222
+ if (name === "getShaderPrecisionFormat") {
223
+ return () => ({ rangeMin: 127, rangeMax: 127, precision: 23 });
224
+ }
225
+ if (name === "getShaderInfoLog" || name === "getProgramInfoLog") return () => "";
226
+ if (name === "getError") return () => 0;
227
+ if (name === "isContextLost") return () => false;
228
+ if (name.startsWith("get") && name.endsWith("Parameter")) return () => true;
229
+ if (name.startsWith("get")) return () => null;
230
+ if (name.startsWith("create") || name.startsWith("getUniformLocation")) {
231
+ return () => ({});
232
+ }
233
+ return () => void 0;
234
+ };
235
+ return new Proxy(state, {
236
+ get(target, property) {
237
+ if (property in target) return target[property];
238
+ if (typeof property !== "string") return void 0;
239
+ if (CONSTANT_NAME.test(property)) {
240
+ const existing = constants.get(property);
241
+ if (existing !== void 0) return existing;
242
+ const value = constants.size + 1;
243
+ constants.set(property, value);
244
+ constantNames.set(value, property);
245
+ return value;
246
+ }
247
+ const implementation = method(property);
248
+ target[property] = implementation;
249
+ return implementation;
250
+ }
251
+ });
252
+ }
188
253
  var contexts = /* @__PURE__ */ new WeakMap();
254
+ function getJSDOMWebGLContext(canvas, options) {
255
+ const existing = contexts.get(canvas);
256
+ if (existing) return existing;
257
+ const attributes = {
258
+ alpha: true,
259
+ antialias: false,
260
+ depth: true,
261
+ premultipliedAlpha: true,
262
+ preserveDrawingBuffer: false,
263
+ stencil: false,
264
+ ...typeof options === "object" && options !== null ? options : {}
265
+ };
266
+ const context = createWebGLContext(canvas, attributes);
267
+ contexts.set(canvas, context);
268
+ return context;
269
+ }
270
+
271
+ // src/testing/jsdom-canvas.ts
272
+ var contexts2 = /* @__PURE__ */ new WeakMap();
189
273
  function createCanvasContext(canvas) {
190
274
  const imageData = (width = 1, height = 1) => ({
191
275
  data: new Uint8ClampedArray(width * height * 4),
@@ -232,12 +316,15 @@ function createCanvasContext(canvas) {
232
316
  return context;
233
317
  }
234
318
  function installJSDOMCanvasContext() {
235
- HTMLCanvasElement.prototype.getContext = function getContext(contextId) {
319
+ HTMLCanvasElement.prototype.getContext = function getContext(contextId, options) {
320
+ if (contextId === "webgl" || contextId === "webgl2") {
321
+ return getJSDOMWebGLContext(this, options);
322
+ }
236
323
  if (contextId !== "2d") return null;
237
- const existing = contexts.get(this);
324
+ const existing = contexts2.get(this);
238
325
  if (existing) return existing;
239
326
  const context = createCanvasContext(this);
240
- contexts.set(this, context);
327
+ contexts2.set(this, context);
241
328
  return context;
242
329
  };
243
330
  }
@@ -182,8 +182,92 @@ function runtimeFailureError(code, value) {
182
182
  return error;
183
183
  }
184
184
 
185
- // src/testing/jsdom-canvas.ts
185
+ // src/testing/jsdom-webgl.ts
186
+ var CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/;
187
+ var TEXTURE_UNIT_LIMIT = 32;
188
+ var TEXTURE_SIZE_LIMIT = 4096;
189
+ var VIEWPORT_LIMIT = 4096;
190
+ function parameterValue(name) {
191
+ if (name === "VERSION") return "WebGL 2.0 (miaoda-game-devkit stub)";
192
+ if (name === "SHADING_LANGUAGE_VERSION") return "WebGL GLSL ES 3.00 (stub)";
193
+ if (name === "VENDOR" || name === "RENDERER") return "miaoda-game-devkit";
194
+ if (name === "MAX_VIEWPORT_DIMS") {
195
+ return new Int32Array([VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
196
+ }
197
+ if (name === "VIEWPORT" || name === "SCISSOR_BOX") {
198
+ return new Int32Array([0, 0, VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
199
+ }
200
+ if (name.includes("MAX_") && name.includes("SIZE")) return TEXTURE_SIZE_LIMIT;
201
+ if (name.startsWith("MAX_")) return TEXTURE_UNIT_LIMIT;
202
+ if (name.endsWith("_BITS")) return 8;
203
+ return 0;
204
+ }
205
+ function createWebGLContext(canvas, attributes) {
206
+ const constants = /* @__PURE__ */ new Map();
207
+ const constantNames = /* @__PURE__ */ new Map();
208
+ const state = {
209
+ canvas,
210
+ drawingBufferWidth: canvas.width,
211
+ drawingBufferHeight: canvas.height
212
+ };
213
+ const method = (name) => {
214
+ if (name === "getContextAttributes") return () => attributes;
215
+ if (name === "getExtension") return () => null;
216
+ if (name === "getSupportedExtensions") return () => [];
217
+ if (name === "getParameter") {
218
+ return (pname) => parameterValue(constantNames.get(pname) ?? "UNKNOWN");
219
+ }
220
+ if (name === "getShaderPrecisionFormat") {
221
+ return () => ({ rangeMin: 127, rangeMax: 127, precision: 23 });
222
+ }
223
+ if (name === "getShaderInfoLog" || name === "getProgramInfoLog") return () => "";
224
+ if (name === "getError") return () => 0;
225
+ if (name === "isContextLost") return () => false;
226
+ if (name.startsWith("get") && name.endsWith("Parameter")) return () => true;
227
+ if (name.startsWith("get")) return () => null;
228
+ if (name.startsWith("create") || name.startsWith("getUniformLocation")) {
229
+ return () => ({});
230
+ }
231
+ return () => void 0;
232
+ };
233
+ return new Proxy(state, {
234
+ get(target, property) {
235
+ if (property in target) return target[property];
236
+ if (typeof property !== "string") return void 0;
237
+ if (CONSTANT_NAME.test(property)) {
238
+ const existing = constants.get(property);
239
+ if (existing !== void 0) return existing;
240
+ const value = constants.size + 1;
241
+ constants.set(property, value);
242
+ constantNames.set(value, property);
243
+ return value;
244
+ }
245
+ const implementation = method(property);
246
+ target[property] = implementation;
247
+ return implementation;
248
+ }
249
+ });
250
+ }
186
251
  var contexts = /* @__PURE__ */ new WeakMap();
252
+ function getJSDOMWebGLContext(canvas, options) {
253
+ const existing = contexts.get(canvas);
254
+ if (existing) return existing;
255
+ const attributes = {
256
+ alpha: true,
257
+ antialias: false,
258
+ depth: true,
259
+ premultipliedAlpha: true,
260
+ preserveDrawingBuffer: false,
261
+ stencil: false,
262
+ ...typeof options === "object" && options !== null ? options : {}
263
+ };
264
+ const context = createWebGLContext(canvas, attributes);
265
+ contexts.set(canvas, context);
266
+ return context;
267
+ }
268
+
269
+ // src/testing/jsdom-canvas.ts
270
+ var contexts2 = /* @__PURE__ */ new WeakMap();
187
271
  function createCanvasContext(canvas) {
188
272
  const imageData = (width = 1, height = 1) => ({
189
273
  data: new Uint8ClampedArray(width * height * 4),
@@ -230,12 +314,15 @@ function createCanvasContext(canvas) {
230
314
  return context;
231
315
  }
232
316
  function installJSDOMCanvasContext() {
233
- HTMLCanvasElement.prototype.getContext = function getContext(contextId) {
317
+ HTMLCanvasElement.prototype.getContext = function getContext(contextId, options) {
318
+ if (contextId === "webgl" || contextId === "webgl2") {
319
+ return getJSDOMWebGLContext(this, options);
320
+ }
234
321
  if (contextId !== "2d") return null;
235
- const existing = contexts.get(this);
322
+ const existing = contexts2.get(this);
236
323
  if (existing) return existing;
237
324
  const context = createCanvasContext(this);
238
- contexts.set(this, context);
325
+ contexts2.set(this, context);
239
326
  return context;
240
327
  };
241
328
  }
@@ -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.1",
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",