miaoda-game-devkit 0.2.3 → 0.2.5

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
@@ -17,10 +17,14 @@ Scene 命令、restart、业务 checkpoint 和销毁。玩法测试可以调用
17
17
  `host.assertGameplayEvidence()` 把这些客观事实设为门禁;它不推断业务语义,
18
18
  authoritative state 仍必须由测试显式断言。
19
19
 
20
- 玩法测试应优先使用 `gameplayTest`。它在 Vitest 收集阶段写入静态 contract metadata,
21
- 在执行阶段绑定 HEADLESS host,并在 `onTestFinished` 校验 Ledger。`gameplayAudit` reporter
22
- 会在测试执行前报告未声明或重复的 contract,在测试结束后汇总缺失、失败、证据不足、
23
- Scene 未访问和 registry 不一致。`registerGameplayContract` 保留为不参加执行前预检的低层接口。
20
+ 玩法测试应优先使用 `gameplayTest`。它在 Vitest 收集阶段写入测试声明,
21
+ 在执行阶段绑定 HEADLESS host,并在 `onTestFinished` 校验 Ledger。配置加载阶段会先校验
22
+ 交互项目的最低基线:至少一个 `kind: "playthrough"` contract 必须要求真实输入、完整帧和
23
+ 语义 checkpoint,每个非入口 Scene 必须有生产转场覆盖;检测到 `scene.restart()` 时还必须
24
+ 声明 `flows.restart: true` 和 `kind: "recovery"` contract。配置预检失败时测试和 coverage
25
+ 都不会运行。配置通过后,`gameplayAudit` reporter 才会检查测试声明,并在结束时汇总
26
+ 缺失、失败、证据不足、Scene 未访问和 registry 不一致。`registerGameplayContract` 保留为
27
+ 不参加执行前预检的低层接口。
24
28
 
25
29
  模板直接调用开发工具包提供的命令行程序:
26
30
 
@@ -83,7 +87,12 @@ DOM 到 Phaser 的输入、命中测试和游戏状态变化,不验证 Canvas/
83
87
  断言 authoritative state 后调用 `host.checkpoint("progress")`、
84
88
  `host.checkpoint("recovery")` 等稳定检查点,并在清单中通过 `requireCheckpoint` 声明。
85
89
  运行结束会输出稳定的 `GAMEPLAY_AUDIT: ALL CONTRACTS PASSED` 或
86
- `GAMEPLAY_AUDIT: ... FAILED` 标识。
90
+ `GAMEPLAY_AUDIT: ... FAILED` 标识。该标识只表示玩法证据审计;`pnpm test` 的整体结果
91
+ 必须以最后的 `TEST_RESULT: PASS` 或 `TEST_RESULT: FAIL` 为准。失败报告会合并重复的问题,
92
+ 并给出按类别去重的修复建议,先处理列出的原始测试错误,再重新运行 `pnpm test`。
93
+
94
+ 共享 Vitest 配置使用 `minimal` reporter 和 `text-summary` coverage reporter,保留失败
95
+ 定位、Scene 覆盖摘要和最终机器标识,同时避免输出完整测试列表和逐文件噪声。
87
96
 
88
97
  ## 发布顺序
89
98
 
@@ -217,7 +217,7 @@ function auditGameplayCollection(options2, file, tests) {
217
217
  const matches = actual.filter((test) => test.metadata?.id === contract.id);
218
218
  if (matches.length === 0) {
219
219
  issues.push(
220
- `\u6D4B\u8BD5\u6267\u884C\u524D\u672A\u6536\u96C6\u5230 contract "${contract.id}"\uFF08${normalizedFile}\uFF09\u3002`
220
+ `\u6CA1\u6709\u627E\u5230\u68C0\u67E5\u9879 "${contract.id}" \u7684\u53EF\u6267\u884C\u6D4B\u8BD5\uFF08${normalizedFile}\uFF09\u3002`
221
221
  );
222
222
  } else if (matches.length > 1) {
223
223
  issues.push(
@@ -248,12 +248,15 @@ function auditGameplayCollection(options2, file, tests) {
248
248
  function getGameplayEvidenceIssues(id, requirements, evidence) {
249
249
  const issues = [];
250
250
  const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
251
- if (requirements.requireInput && inputEvents === 0)
251
+ if (requirements.requireInput && inputEvents === 0) {
252
252
  issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165\u3002`);
253
- if (requirements.requireFrameAdvance && evidence.frames === 0)
253
+ }
254
+ if (requirements.requireFrameAdvance && evidence.frames === 0) {
254
255
  issues.push(`contract "${id}" \u672A\u63A8\u8FDB\u5B8C\u6574 Phaser \u5E27\u3002`);
255
- if (requirements.requirePhysicsStep && evidence.physicsSteps === 0)
256
+ }
257
+ if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
256
258
  issues.push(`contract "${id}" \u672A\u63A8\u8FDB Arcade Physics\u3002`);
259
+ }
257
260
  for (const target of normalizeList(requirements.requireTransition)) {
258
261
  if (!evidence.transitions.some((transition) => transition.to === target)) {
259
262
  issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u5230 ${target} \u7684 Scene \u8F6C\u573A\u3002`);
@@ -264,21 +267,24 @@ function getGameplayEvidenceIssues(id, requirements, evidence) {
264
267
  issues.push(`contract "${id}" \u672A\u8BBF\u95EE Scene "${scene}"\u3002`);
265
268
  }
266
269
  for (const scene of normalizeList(requirements.requireRestart)) {
267
- if (!evidence.restartedScenes.includes(scene))
270
+ if (!evidence.restartedScenes.includes(scene)) {
268
271
  issues.push(`contract "${id}" \u672A\u91CD\u542F Scene "${scene}"\u3002`);
272
+ }
269
273
  }
270
274
  for (const checkpoint of normalizeList(requirements.requireCheckpoint)) {
271
- if (!evidence.checkpoints.includes(checkpoint))
275
+ if (!evidence.checkpoints.includes(checkpoint)) {
272
276
  issues.push(`contract "${id}" \u7F3A\u5C11 checkpoint "${checkpoint}"\u3002`);
277
+ }
273
278
  }
274
- if (requirements.requireDestroy && !evidence.destroyed)
279
+ if (requirements.requireDestroy && !evidence.destroyed) {
275
280
  issues.push(`contract "${id}" \u672A\u5B8C\u6210 HEADLESS host \u9500\u6BC1\u3002`);
281
+ }
276
282
  return issues;
277
283
  }
278
284
  function auditGameplayRun(options2, tests) {
279
285
  const issues = [];
280
286
  const actualContracts = tests.filter((test) => test.metadata);
281
- const acceptedEvidence = [];
287
+ const observedEvidence = [];
282
288
  for (const expected of options2.contracts) {
283
289
  const matches = actualContracts.filter(
284
290
  (test2) => test2.metadata?.id === expected.id
@@ -294,6 +300,7 @@ function auditGameplayRun(options2, tests) {
294
300
  const test = matches[0];
295
301
  const metadata = test?.metadata;
296
302
  if (!test || !metadata) continue;
303
+ if (metadata.evidence) observedEvidence.push(metadata.evidence);
297
304
  if (test.file.replaceAll("\\", "/") !== expected.testFile.replaceAll("\\", "/")) {
298
305
  issues.push(
299
306
  `contract "${expected.id}" \u5FC5\u987B\u4F4D\u4E8E ${expected.testFile}\uFF0C\u5B9E\u9645\u4F4D\u4E8E ${test.file}\u3002`
@@ -314,7 +321,6 @@ function auditGameplayRun(options2, tests) {
314
321
  metadata.evidence
315
322
  );
316
323
  issues.push(...evidenceIssues);
317
- if (evidenceIssues.length === 0) acceptedEvidence.push(metadata.evidence);
318
324
  }
319
325
  for (const test of actualContracts) {
320
326
  const id = test.metadata?.id;
@@ -325,14 +331,15 @@ function auditGameplayRun(options2, tests) {
325
331
  }
326
332
  }
327
333
  const registeredScenes = normalizeSet(
328
- acceptedEvidence.flatMap((evidence) => evidence.registeredScenes)
334
+ observedEvidence.flatMap((evidence) => evidence.registeredScenes)
329
335
  );
330
336
  const expectedScenes = normalizeSet(options2.scenes);
331
337
  for (const scene of expectedScenes) {
332
- if (!registeredScenes.includes(scene))
338
+ if (!registeredScenes.includes(scene)) {
333
339
  issues.push(
334
- `\u751F\u4EA7 Scene "${scene}" \u672A\u51FA\u73B0\u5728\u4EFB\u4F55\u901A\u8FC7\u5BA1\u8BA1\u7684 host registry \u4E2D\u3002`
340
+ `\u751F\u4EA7 Scene "${scene}" \u672A\u51FA\u73B0\u5728\u6D4B\u8BD5\u5B9E\u9645\u8BB0\u5F55\u7684 host registry \u4E2D\u3002`
335
341
  );
342
+ }
336
343
  }
337
344
  for (const scene of registeredScenes) {
338
345
  if (!expectedScenes.includes(scene))
@@ -343,6 +350,81 @@ function auditGameplayRun(options2, tests) {
343
350
  return { passed: issues.length === 0, issues: [...new Set(issues)] };
344
351
  }
345
352
 
353
+ // src/gameplay-audit-reporter.ts
354
+ import { relative } from "path";
355
+ function formatGameplayIssues(issues) {
356
+ const groups = /* @__PURE__ */ new Map();
357
+ const repairKeys = /* @__PURE__ */ new Set();
358
+ const repairs = [];
359
+ const repairText = {
360
+ input: "\u8F93\u5165\uFF1A\u5148\u63A8\u8FDB\u4E00\u5E27\uFF0C\u518D\u901A\u8FC7 host.input.mouse\u3001host.input.keyboard \u6216 host.input.touch \u9A71\u52A8\u771F\u5B9E\u751F\u4EA7\u8F93\u5165\u3002",
361
+ frames: "\u5E27\u63A8\u8FDB\uFF1A\u5728\u8F93\u5165\u6216\u751F\u4EA7\u547D\u4EE4\u540E\u8C03\u7528 host.stepFrames()\uFF0C\u5F02\u6B65 update \u4F7F\u7528 host.stepFramesAsync()\u3002",
362
+ physics: "\u7269\u7406\uFF1A\u4EC5\u5728\u751F\u4EA7\u4F7F\u7528 Arcade Physics \u65F6\u8C03\u7528 host.stepPhysics()\uFF1B\u5176\u4ED6\u7269\u7406\u7CFB\u7EDF\u4F7F\u7528\u5B8C\u6574\u5E27\u3002",
363
+ transition: "\u8F6C\u573A\uFF1Aboot \u524D\u6CE8\u518C\u76EE\u6807 Scene\uFF0C\u8C03\u7528\u751F\u4EA7\u8F6C\u573A\u547D\u4EE4\uFF0C\u518D\u7528 host.stepUntil() \u7B49\u5F85\u76EE\u6807\u6FC0\u6D3B\u5E76\u65AD\u8A00\u6E90/\u76EE\u6807\u72B6\u6001\u3002",
364
+ visit: "Scene\uFF1A\u786E\u8BA4\u6D4B\u8BD5\u901A\u8FC7\u771F\u5B9E\u5165\u53E3\u6216\u5408\u6CD5\u8F6C\u573A\u8BBF\u95EE\u8BE5 Scene\uFF0C\u5E76\u4F7F\u7528\u771F\u5B9E\u914D\u7F6E\u542F\u52A8\u3002",
365
+ restart: "\u6062\u590D\uFF1A\u901A\u8FC7\u771F\u5B9E\u8F93\u5165\u6216\u751F\u4EA7 controls \u89E6\u53D1 restart\uFF0C\u7B49\u5F85\u5B8C\u6574\u5E27\u5E76\u65AD\u8A00\u72B6\u6001\u5DF2\u6062\u590D\u3002",
366
+ checkpoint: "\u68C0\u67E5\u70B9\uFF1A\u5148\u65AD\u8A00 authoritative state\uFF0C\u518D\u8C03\u7528 host.checkpoint()\uFF1B\u4E0D\u8981\u8C03\u7528 contract.checkpoint()\u3002",
367
+ destroy: "\u6E05\u7406\uFF1A\u5728 afterEach \u4E2D\u8C03\u7528 host.destroy()\uFF0C\u5E76\u4FDD\u6301\u9500\u6BC1\u5E42\u7B49\u3002",
368
+ registry: "Scene \u6CE8\u518C\uFF1A\u786E\u8BA4 Scene \u5728\u751F\u4EA7 registry \u4E2D\uFF0C\u5E76\u5728 boot \u524D\u901A\u8FC7 additionalScenes \u6CE8\u518C\u8F6C\u573A\u76EE\u6807\u3002",
369
+ missing: "\u6D4B\u8BD5\u7F3A\u5931\uFF1A\u5728\u6307\u5B9A\u6587\u4EF6\u4F7F\u7528 gameplayTest \u5B9A\u4E49\u68C0\u67E5\u9879\uFF0C\u5E76\u901A\u8FC7 contract.attachHost(host) \u7ED1\u5B9A host\u3002",
370
+ test: "\u5148\u4FEE\u590D\u5BF9\u5E94\u6D4B\u8BD5\u7684\u7B2C\u4E00\u6761\u539F\u59CB\u9519\u8BEF\uFF1B\u68C0\u67E5\u9879\u53EA\u6C47\u603B\u7ED3\u679C\uFF0C\u4E0D\u662F\u8FD0\u884C\u65F6\u5BF9\u8C61\u3002"
371
+ };
372
+ const getCategory = (issue) => {
373
+ if (issue.includes("\u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165")) return { category: "input", key: "input" };
374
+ if (issue.includes("\u672A\u63A8\u8FDB\u5B8C\u6574 Phaser \u5E27")) return { category: "frames", key: "frames" };
375
+ if (issue.includes("\u672A\u63A8\u8FDB Arcade Physics")) return { category: "physics", key: "physics" };
376
+ if (issue.includes("\u672A\u8BB0\u5F55\u5230 ") && issue.includes("Scene \u8F6C\u573A")) {
377
+ const target = issue.match(/未记录到 (.+?) 的 Scene 转场/)?.[1] ?? "unknown";
378
+ return { category: "transition", key: `transition:${target}` };
379
+ }
380
+ if (issue.includes("\u672A\u8BBF\u95EE Scene")) {
381
+ const scene = issue.match(/未访问 Scene "([^"]+)"/)?.[1] ?? "unknown";
382
+ return { category: "visit", key: `visit:${scene}` };
383
+ }
384
+ if (issue.includes("\u672A\u91CD\u542F Scene")) {
385
+ const scene = issue.match(/未重启 Scene "([^"]+)"/)?.[1] ?? "unknown";
386
+ return { category: "restart", key: `restart:${scene}` };
387
+ }
388
+ if (issue.includes("\u7F3A\u5C11 checkpoint")) return { category: "checkpoint", key: `checkpoint:${issue.match(/checkpoint "([^"]+)"/)?.[1] ?? issue}` };
389
+ if (issue.includes("\u672A\u5B8C\u6210 HEADLESS host \u9500\u6BC1")) return { category: "destroy", key: "destroy" };
390
+ if (issue.includes("\u672A\u51FA\u73B0\u5728\u6D4B\u8BD5\u5B9E\u9645\u8BB0\u5F55\u7684 host registry")) {
391
+ const scene = issue.match(/生产 Scene "([^"]+)"/)?.[1] ?? "unknown";
392
+ return { category: "registry", key: `registry:${scene}` };
393
+ }
394
+ if (issue.includes("\u7F3A\u5C11 gameplay contract")) return { category: "missing", key: "missing" };
395
+ if (issue.includes("\u7684\u6D4B\u8BD5\u72B6\u6001\u4E3A")) return { category: "test", key: "test" };
396
+ return { category: "other", key: `other:${issue}` };
397
+ };
398
+ for (const rawIssue of [...new Set(issues)]) {
399
+ const compact = rawIssue.replaceAll(/\s+/g, " ").trim();
400
+ const readable = compact.replaceAll(/(?:gameplay )?contract "([^"]+)"/g, '\u68C0\u67E5\u9879 "$1"').replaceAll("evidence requirements", "\u6240\u9700\u8FD0\u884C\u8BC1\u636E").replaceAll("metadata", "\u6D4B\u8BD5\u58F0\u660E").replaceAll("host registry", "\u5DF2\u6CE8\u518C Scene \u6E05\u5355");
401
+ const id = compact.match(/contract "([^"]+)"/)?.[1];
402
+ const { category, key } = getCategory(compact);
403
+ const group = groups.get(key);
404
+ if (group) {
405
+ if (id && !group.ids.includes(id)) group.ids.push(id);
406
+ } else {
407
+ groups.set(key, { text: readable, ids: id ? [id] : [], key });
408
+ }
409
+ if (repairText[category] && !repairKeys.has(category)) {
410
+ repairKeys.add(category);
411
+ repairs.push(repairText[category]);
412
+ }
413
+ }
414
+ const grouped = [...groups.values()];
415
+ const visible = grouped.slice(0, 8).map((group) => {
416
+ if (group.ids.length <= 1) return group.text;
417
+ return `${group.text}\uFF08\u53D7\u5F71\u54CD\u68C0\u67E5\u9879\uFF1A${group.ids.join("\u3001")}\uFF09`;
418
+ });
419
+ return {
420
+ issues: visible.map(
421
+ (issue) => issue.length > 360 ? `${issue.slice(0, 357)}...` : issue
422
+ ),
423
+ repairs: repairs.slice(0, 8),
424
+ omitted: Math.max(0, grouped.length - visible.length)
425
+ };
426
+ }
427
+
346
428
  // src/lint/gameplay-audit.test.ts
347
429
  var options = {
348
430
  mode: "interactive",
@@ -568,7 +650,7 @@ describe("gameplay audit", () => {
568
650
  expect(result).toEqual({
569
651
  passed: false,
570
652
  issues: [
571
- '\u6D4B\u8BD5\u6267\u884C\u524D\u672A\u6536\u96C6\u5230 contract "gameplay.main-flow"\uFF08tests/gameplay.test.ts\uFF09\u3002'
653
+ '\u6CA1\u6709\u627E\u5230\u68C0\u67E5\u9879 "gameplay.main-flow" \u7684\u53EF\u6267\u884C\u6D4B\u8BD5\uFF08tests/gameplay.test.ts\uFF09\u3002'
572
654
  ]
573
655
  });
574
656
  });
@@ -602,6 +684,7 @@ describe("gameplay audit", () => {
602
684
  evidence.destroyed = false;
603
685
  evidence.registeredScenes.push("DebugScene");
604
686
  const test = createPassingTest();
687
+ if (!test.metadata) throw new Error("expected gameplay metadata");
605
688
  test.metadata.evidence = evidence;
606
689
  const result = auditGameplayRun(options, [test]);
607
690
  expect(result.passed).toBe(false);
@@ -613,8 +696,24 @@ describe("gameplay audit", () => {
613
696
  ])
614
697
  );
615
698
  });
699
+ it("\u4FDD\u7559\u5931\u8D25 host \u5DF2\u6CE8\u518C\u7684 Scene\uFF0C\u907F\u514D checkpoint \u7F3A\u53E3\u7EA7\u8054\u6210\u6CE8\u518C\u9519\u8BEF", () => {
700
+ const test = createPassingTest();
701
+ if (!test.metadata?.evidence) throw new Error("expected gameplay evidence");
702
+ test.metadata.evidence.checkpoints = ["progress"];
703
+ const result = auditGameplayRun(options, [test]);
704
+ expect(result.issues).toContain(
705
+ 'contract "gameplay.main-flow" \u7F3A\u5C11 checkpoint "recovery"\u3002'
706
+ );
707
+ expect(result.issues).not.toContain(
708
+ '\u751F\u4EA7 Scene "IndexScene" \u672A\u51FA\u73B0\u5728\u6D4B\u8BD5\u5B9E\u9645\u8BB0\u5F55\u7684 host registry \u4E2D\u3002'
709
+ );
710
+ expect(result.issues).not.toContain(
711
+ '\u751F\u4EA7 Scene "BattleScene" \u672A\u51FA\u73B0\u5728\u6D4B\u8BD5\u5B9E\u9645\u8BB0\u5F55\u7684 host registry \u4E2D\u3002'
712
+ );
713
+ });
616
714
  it("\u62D2\u7EDD\u901A\u8FC7\u5951\u7EA6\u7684 host registry \u4E2D\u51FA\u73B0\u6E05\u5355\u5916 Scene", () => {
617
715
  const test = createPassingTest();
716
+ if (!test.metadata?.evidence) throw new Error("expected gameplay evidence");
618
717
  test.metadata.evidence.registeredScenes.push("DebugScene");
619
718
  expect(auditGameplayRun(options, [test]).issues).toContain(
620
719
  'host registry \u51FA\u73B0\u672A\u5217\u5165 gameplayAudit.scenes \u7684\u751F\u4EA7 Scene "DebugScene"\u3002'
@@ -647,4 +746,28 @@ describe("gameplay audit", () => {
647
746
  )
648
747
  ).toContain('contract "arcade-physics" \u672A\u63A8\u8FDB Arcade Physics\u3002');
649
748
  });
749
+ it("\u5408\u5E76\u76F8\u540C\u4FEE\u590D\u7C7B\u578B\u4F46\u4FDD\u7559\u4E0D\u540C Scene \u8F6C\u573A\u76EE\u6807", () => {
750
+ const result = formatGameplayIssues([
751
+ 'contract "Menu.start" \u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165\u3002',
752
+ 'contract "Battle.start" \u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165\u3002',
753
+ 'contract "Menu.to-battle" \u672A\u8BB0\u5F55\u5230 BattleScene \u7684 Scene \u8F6C\u573A\u3002',
754
+ 'contract "Battle.to-result" \u672A\u8BB0\u5F55\u5230 ResultScene \u7684 Scene \u8F6C\u573A\u3002',
755
+ 'contract "Menu.visit-result" \u672A\u8BBF\u95EE Scene "ResultScene"\u3002',
756
+ 'contract "Battle.visit-result" \u672A\u8BBF\u95EE Scene "ResultScene"\u3002'
757
+ ]);
758
+ expect(result.issues).toHaveLength(4);
759
+ expect(result.repairs).toHaveLength(3);
760
+ expect(result.repairs[0]).toContain("\u8F93\u5165");
761
+ expect(result.repairs[1]).toContain("\u8F6C\u573A");
762
+ });
763
+ it("\u5C06\u591A\u68C0\u67E5\u9879\u7684\u7F3A\u53E3\u9650\u5236\u4E3A\u95EE\u9898\u7EC4\u800C\u4E0D\u662F\u539F\u59CB\u65E5\u5FD7\u884C", () => {
764
+ const issues = Array.from(
765
+ { length: 20 },
766
+ (_, index) => `contract "Scene${index}.boot" \u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165\u3002`
767
+ );
768
+ const result = formatGameplayIssues(issues);
769
+ expect(result.issues).toHaveLength(1);
770
+ expect(result.repairs).toHaveLength(1);
771
+ expect(result.omitted).toBe(0);
772
+ });
650
773
  });
@@ -1188,6 +1188,7 @@ describe("gameplay contract lifecycle integration", () => {
1188
1188
  const { callbacks, context } = createTestContext();
1189
1189
  const contract = registerGameplayContract(context, {
1190
1190
  id: "InputScene.hit",
1191
+ kind: "playthrough",
1191
1192
  requireInput: true,
1192
1193
  requireFrameAdvance: true
1193
1194
  });
@@ -1216,6 +1217,7 @@ describe("gameplay contract lifecycle integration", () => {
1216
1217
  const { callbacks, context } = createTestContext();
1217
1218
  const contract = registerGameplayContract(context, {
1218
1219
  id: "InputScene.missing-input",
1220
+ kind: "playthrough",
1219
1221
  requireInput: true
1220
1222
  });
1221
1223
  const host = await createHeadlessGame(new InputScene());
@@ -222,7 +222,7 @@ function auditGameplayCollection(options, file, tests) {
222
222
  const matches = actual.filter((test) => test.metadata?.id === contract.id);
223
223
  if (matches.length === 0) {
224
224
  issues.push(
225
- `\u6D4B\u8BD5\u6267\u884C\u524D\u672A\u6536\u96C6\u5230 contract "${contract.id}"\uFF08${normalizedFile}\uFF09\u3002`
225
+ `\u6CA1\u6709\u627E\u5230\u68C0\u67E5\u9879 "${contract.id}" \u7684\u53EF\u6267\u884C\u6D4B\u8BD5\uFF08${normalizedFile}\uFF09\u3002`
226
226
  );
227
227
  } else if (matches.length > 1) {
228
228
  issues.push(
@@ -253,12 +253,15 @@ function auditGameplayCollection(options, file, tests) {
253
253
  function getGameplayEvidenceIssues(id, requirements, evidence) {
254
254
  const issues = [];
255
255
  const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
256
- if (requirements.requireInput && inputEvents === 0)
256
+ if (requirements.requireInput && inputEvents === 0) {
257
257
  issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165\u3002`);
258
- if (requirements.requireFrameAdvance && evidence.frames === 0)
258
+ }
259
+ if (requirements.requireFrameAdvance && evidence.frames === 0) {
259
260
  issues.push(`contract "${id}" \u672A\u63A8\u8FDB\u5B8C\u6574 Phaser \u5E27\u3002`);
260
- if (requirements.requirePhysicsStep && evidence.physicsSteps === 0)
261
+ }
262
+ if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
261
263
  issues.push(`contract "${id}" \u672A\u63A8\u8FDB Arcade Physics\u3002`);
264
+ }
262
265
  for (const target of normalizeList(requirements.requireTransition)) {
263
266
  if (!evidence.transitions.some((transition) => transition.to === target)) {
264
267
  issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u5230 ${target} \u7684 Scene \u8F6C\u573A\u3002`);
@@ -269,21 +272,24 @@ function getGameplayEvidenceIssues(id, requirements, evidence) {
269
272
  issues.push(`contract "${id}" \u672A\u8BBF\u95EE Scene "${scene}"\u3002`);
270
273
  }
271
274
  for (const scene of normalizeList(requirements.requireRestart)) {
272
- if (!evidence.restartedScenes.includes(scene))
275
+ if (!evidence.restartedScenes.includes(scene)) {
273
276
  issues.push(`contract "${id}" \u672A\u91CD\u542F Scene "${scene}"\u3002`);
277
+ }
274
278
  }
275
279
  for (const checkpoint of normalizeList(requirements.requireCheckpoint)) {
276
- if (!evidence.checkpoints.includes(checkpoint))
280
+ if (!evidence.checkpoints.includes(checkpoint)) {
277
281
  issues.push(`contract "${id}" \u7F3A\u5C11 checkpoint "${checkpoint}"\u3002`);
282
+ }
278
283
  }
279
- if (requirements.requireDestroy && !evidence.destroyed)
284
+ if (requirements.requireDestroy && !evidence.destroyed) {
280
285
  issues.push(`contract "${id}" \u672A\u5B8C\u6210 HEADLESS host \u9500\u6BC1\u3002`);
286
+ }
281
287
  return issues;
282
288
  }
283
289
  function auditGameplayRun(options, tests) {
284
290
  const issues = [];
285
291
  const actualContracts = tests.filter((test) => test.metadata);
286
- const acceptedEvidence = [];
292
+ const observedEvidence = [];
287
293
  for (const expected of options.contracts) {
288
294
  const matches = actualContracts.filter(
289
295
  (test2) => test2.metadata?.id === expected.id
@@ -299,6 +305,7 @@ function auditGameplayRun(options, tests) {
299
305
  const test = matches[0];
300
306
  const metadata = test?.metadata;
301
307
  if (!test || !metadata) continue;
308
+ if (metadata.evidence) observedEvidence.push(metadata.evidence);
302
309
  if (test.file.replaceAll("\\", "/") !== expected.testFile.replaceAll("\\", "/")) {
303
310
  issues.push(
304
311
  `contract "${expected.id}" \u5FC5\u987B\u4F4D\u4E8E ${expected.testFile}\uFF0C\u5B9E\u9645\u4F4D\u4E8E ${test.file}\u3002`
@@ -319,7 +326,6 @@ function auditGameplayRun(options, tests) {
319
326
  metadata.evidence
320
327
  );
321
328
  issues.push(...evidenceIssues);
322
- if (evidenceIssues.length === 0) acceptedEvidence.push(metadata.evidence);
323
329
  }
324
330
  for (const test of actualContracts) {
325
331
  const id = test.metadata?.id;
@@ -330,14 +336,15 @@ function auditGameplayRun(options, tests) {
330
336
  }
331
337
  }
332
338
  const registeredScenes = normalizeSet(
333
- acceptedEvidence.flatMap((evidence) => evidence.registeredScenes)
339
+ observedEvidence.flatMap((evidence) => evidence.registeredScenes)
334
340
  );
335
341
  const expectedScenes = normalizeSet(options.scenes);
336
342
  for (const scene of expectedScenes) {
337
- if (!registeredScenes.includes(scene))
343
+ if (!registeredScenes.includes(scene)) {
338
344
  issues.push(
339
- `\u751F\u4EA7 Scene "${scene}" \u672A\u51FA\u73B0\u5728\u4EFB\u4F55\u901A\u8FC7\u5BA1\u8BA1\u7684 host registry \u4E2D\u3002`
345
+ `\u751F\u4EA7 Scene "${scene}" \u672A\u51FA\u73B0\u5728\u6D4B\u8BD5\u5B9E\u9645\u8BB0\u5F55\u7684 host registry \u4E2D\u3002`
340
346
  );
347
+ }
341
348
  }
342
349
  for (const scene of registeredScenes) {
343
350
  if (!expectedScenes.includes(scene))
@@ -367,11 +374,96 @@ function toAuditTest(test) {
367
374
  metadata: isGameplayContractMetadata(metadata) ? metadata : void 0
368
375
  };
369
376
  }
377
+ function formatGameplayIssues(issues) {
378
+ const groups = /* @__PURE__ */ new Map();
379
+ const repairKeys = /* @__PURE__ */ new Set();
380
+ const repairs = [];
381
+ const repairText = {
382
+ input: "\u8F93\u5165\uFF1A\u5148\u63A8\u8FDB\u4E00\u5E27\uFF0C\u518D\u901A\u8FC7 host.input.mouse\u3001host.input.keyboard \u6216 host.input.touch \u9A71\u52A8\u771F\u5B9E\u751F\u4EA7\u8F93\u5165\u3002",
383
+ frames: "\u5E27\u63A8\u8FDB\uFF1A\u5728\u8F93\u5165\u6216\u751F\u4EA7\u547D\u4EE4\u540E\u8C03\u7528 host.stepFrames()\uFF0C\u5F02\u6B65 update \u4F7F\u7528 host.stepFramesAsync()\u3002",
384
+ physics: "\u7269\u7406\uFF1A\u4EC5\u5728\u751F\u4EA7\u4F7F\u7528 Arcade Physics \u65F6\u8C03\u7528 host.stepPhysics()\uFF1B\u5176\u4ED6\u7269\u7406\u7CFB\u7EDF\u4F7F\u7528\u5B8C\u6574\u5E27\u3002",
385
+ transition: "\u8F6C\u573A\uFF1Aboot \u524D\u6CE8\u518C\u76EE\u6807 Scene\uFF0C\u8C03\u7528\u751F\u4EA7\u8F6C\u573A\u547D\u4EE4\uFF0C\u518D\u7528 host.stepUntil() \u7B49\u5F85\u76EE\u6807\u6FC0\u6D3B\u5E76\u65AD\u8A00\u6E90/\u76EE\u6807\u72B6\u6001\u3002",
386
+ visit: "Scene\uFF1A\u786E\u8BA4\u6D4B\u8BD5\u901A\u8FC7\u771F\u5B9E\u5165\u53E3\u6216\u5408\u6CD5\u8F6C\u573A\u8BBF\u95EE\u8BE5 Scene\uFF0C\u5E76\u4F7F\u7528\u771F\u5B9E\u914D\u7F6E\u542F\u52A8\u3002",
387
+ restart: "\u6062\u590D\uFF1A\u901A\u8FC7\u771F\u5B9E\u8F93\u5165\u6216\u751F\u4EA7 controls \u89E6\u53D1 restart\uFF0C\u7B49\u5F85\u5B8C\u6574\u5E27\u5E76\u65AD\u8A00\u72B6\u6001\u5DF2\u6062\u590D\u3002",
388
+ checkpoint: "\u68C0\u67E5\u70B9\uFF1A\u5148\u65AD\u8A00 authoritative state\uFF0C\u518D\u8C03\u7528 host.checkpoint()\uFF1B\u4E0D\u8981\u8C03\u7528 contract.checkpoint()\u3002",
389
+ destroy: "\u6E05\u7406\uFF1A\u5728 afterEach \u4E2D\u8C03\u7528 host.destroy()\uFF0C\u5E76\u4FDD\u6301\u9500\u6BC1\u5E42\u7B49\u3002",
390
+ registry: "Scene \u6CE8\u518C\uFF1A\u786E\u8BA4 Scene \u5728\u751F\u4EA7 registry \u4E2D\uFF0C\u5E76\u5728 boot \u524D\u901A\u8FC7 additionalScenes \u6CE8\u518C\u8F6C\u573A\u76EE\u6807\u3002",
391
+ missing: "\u6D4B\u8BD5\u7F3A\u5931\uFF1A\u5728\u6307\u5B9A\u6587\u4EF6\u4F7F\u7528 gameplayTest \u5B9A\u4E49\u68C0\u67E5\u9879\uFF0C\u5E76\u901A\u8FC7 contract.attachHost(host) \u7ED1\u5B9A host\u3002",
392
+ test: "\u5148\u4FEE\u590D\u5BF9\u5E94\u6D4B\u8BD5\u7684\u7B2C\u4E00\u6761\u539F\u59CB\u9519\u8BEF\uFF1B\u68C0\u67E5\u9879\u53EA\u6C47\u603B\u7ED3\u679C\uFF0C\u4E0D\u662F\u8FD0\u884C\u65F6\u5BF9\u8C61\u3002"
393
+ };
394
+ const getCategory = (issue) => {
395
+ if (issue.includes("\u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165")) return { category: "input", key: "input" };
396
+ if (issue.includes("\u672A\u63A8\u8FDB\u5B8C\u6574 Phaser \u5E27")) return { category: "frames", key: "frames" };
397
+ if (issue.includes("\u672A\u63A8\u8FDB Arcade Physics")) return { category: "physics", key: "physics" };
398
+ if (issue.includes("\u672A\u8BB0\u5F55\u5230 ") && issue.includes("Scene \u8F6C\u573A")) {
399
+ const target = issue.match(/未记录到 (.+?) 的 Scene 转场/)?.[1] ?? "unknown";
400
+ return { category: "transition", key: `transition:${target}` };
401
+ }
402
+ if (issue.includes("\u672A\u8BBF\u95EE Scene")) {
403
+ const scene = issue.match(/未访问 Scene "([^"]+)"/)?.[1] ?? "unknown";
404
+ return { category: "visit", key: `visit:${scene}` };
405
+ }
406
+ if (issue.includes("\u672A\u91CD\u542F Scene")) {
407
+ const scene = issue.match(/未重启 Scene "([^"]+)"/)?.[1] ?? "unknown";
408
+ return { category: "restart", key: `restart:${scene}` };
409
+ }
410
+ if (issue.includes("\u7F3A\u5C11 checkpoint")) return { category: "checkpoint", key: `checkpoint:${issue.match(/checkpoint "([^"]+)"/)?.[1] ?? issue}` };
411
+ if (issue.includes("\u672A\u5B8C\u6210 HEADLESS host \u9500\u6BC1")) return { category: "destroy", key: "destroy" };
412
+ if (issue.includes("\u672A\u51FA\u73B0\u5728\u6D4B\u8BD5\u5B9E\u9645\u8BB0\u5F55\u7684 host registry")) {
413
+ const scene = issue.match(/生产 Scene "([^"]+)"/)?.[1] ?? "unknown";
414
+ return { category: "registry", key: `registry:${scene}` };
415
+ }
416
+ if (issue.includes("\u7F3A\u5C11 gameplay contract")) return { category: "missing", key: "missing" };
417
+ if (issue.includes("\u7684\u6D4B\u8BD5\u72B6\u6001\u4E3A")) return { category: "test", key: "test" };
418
+ return { category: "other", key: `other:${issue}` };
419
+ };
420
+ for (const rawIssue of [...new Set(issues)]) {
421
+ const compact = rawIssue.replaceAll(/\s+/g, " ").trim();
422
+ const readable = compact.replaceAll(/(?:gameplay )?contract "([^"]+)"/g, '\u68C0\u67E5\u9879 "$1"').replaceAll("evidence requirements", "\u6240\u9700\u8FD0\u884C\u8BC1\u636E").replaceAll("metadata", "\u6D4B\u8BD5\u58F0\u660E").replaceAll("host registry", "\u5DF2\u6CE8\u518C Scene \u6E05\u5355");
423
+ const id = compact.match(/contract "([^"]+)"/)?.[1];
424
+ const { category, key } = getCategory(compact);
425
+ const group = groups.get(key);
426
+ if (group) {
427
+ if (id && !group.ids.includes(id)) group.ids.push(id);
428
+ } else {
429
+ groups.set(key, { text: readable, ids: id ? [id] : [], key });
430
+ }
431
+ if (repairText[category] && !repairKeys.has(category)) {
432
+ repairKeys.add(category);
433
+ repairs.push(repairText[category]);
434
+ }
435
+ }
436
+ const grouped = [...groups.values()];
437
+ const visible = grouped.slice(0, 8).map((group) => {
438
+ if (group.ids.length <= 1) return group.text;
439
+ return `${group.text}\uFF08\u53D7\u5F71\u54CD\u68C0\u67E5\u9879\uFF1A${group.ids.join("\u3001")}\uFF09`;
440
+ });
441
+ return {
442
+ issues: visible.map(
443
+ (issue) => issue.length > 360 ? `${issue.slice(0, 357)}...` : issue
444
+ ),
445
+ repairs: repairs.slice(0, 8),
446
+ omitted: Math.max(0, grouped.length - visible.length)
447
+ };
448
+ }
370
449
  function printIssues(stage, issues) {
371
450
  if (issues.length === 0) return;
372
451
  console.error(`
373
452
  GAMEPLAY_AUDIT: ${stage} FAILED`);
374
- for (const issue of issues) console.error(`- ${issue}`);
453
+ console.error(
454
+ "\u73A9\u6CD5\u68C0\u67E5\u5931\u8D25\uFF1A\u4EE5\u4E0B\u6BCF\u4E2A\u68C0\u67E5\u9879\u5BF9\u5E94\u4E00\u4E2A\u9700\u8981\u6D4B\u8BD5\u8BC1\u660E\u7684\u6E38\u620F\u884C\u4E3A\u3002"
455
+ );
456
+ const formatted = formatGameplayIssues(issues);
457
+ for (const issue of formatted.issues) console.error(`- ${issue}`);
458
+ if (formatted.repairs.length > 0) {
459
+ console.error("\u4FEE\u590D\u5EFA\u8BAE\uFF08\u540C\u7C7B\u95EE\u9898\u53EA\u5217\u4E00\u6B21\uFF09\uFF1A");
460
+ for (const repair of formatted.repairs) console.error(`- ${repair}`);
461
+ }
462
+ if (formatted.omitted > 0) {
463
+ console.error(
464
+ `- \u5176\u4F59 ${formatted.omitted} \u7C7B\u95EE\u9898\u5DF2\u7701\u7565\uFF1B\u5148\u4FEE\u590D\u4EE5\u4E0A\u95EE\u9898\u540E\u91CD\u65B0\u8FD0\u884C pnpm test\u3002`
465
+ );
466
+ }
375
467
  }
376
468
  var GameplayAuditReporter = class {
377
469
  projectRoot;
@@ -407,26 +499,45 @@ var GameplayAuditReporter = class {
407
499
  for (const issue of result.issues) this.reportPreflightIssue(issue);
408
500
  }
409
501
  /** 在测试运行结束后汇总全部 contract、Ledger 和 Scene registry,并决定命令退出码。 */
410
- onTestRunEnd(testModules) {
502
+ onTestRunEnd(testModules, _unhandledErrors = [], reason = "passed") {
503
+ const collectionFailed = reason === "failed" && testModules.some(
504
+ (testModule) => testModule.state() === "failed" && [...testModule.children.allTests()].length === 0
505
+ );
506
+ if (collectionFailed) {
507
+ console.error(
508
+ "\nGAMEPLAY_AUDIT: NOT RUN\n\u73A9\u6CD5\u6D4B\u8BD5\u6587\u4EF6\u65E0\u6CD5\u52A0\u8F7D\uFF0C\u56E0\u6B64\u65E0\u6CD5\u68C0\u67E5\u6E38\u620F\u884C\u4E3A\u3002\u8BF7\u5148\u4FEE\u590D Vitest \u4E0A\u65B9\u663E\u793A\u7684\u8BED\u6CD5\u6216\u5BFC\u5165\u9519\u8BEF\uFF0C\u518D\u8FD0\u884C pnpm test\u3002"
509
+ );
510
+ process.exitCode = 1;
511
+ return;
512
+ }
411
513
  const tests = testModules.flatMap(
412
514
  (testModule) => [...testModule.children.allTests()].map(toAuditTest)
413
515
  );
414
516
  const result = auditGameplayRun(this.options, tests);
415
- if (!result.passed) {
416
- printIssues("FINAL", result.issues);
517
+ const combinedIssues = [
518
+ ...this.preflightIssues,
519
+ ...result.issues
520
+ ];
521
+ if (combinedIssues.length > 0) {
522
+ if (this.preflightIssues.size > 0) {
523
+ console.error("\nGAMEPLAY_AUDIT: PREFLIGHT FAILED (included in final summary)");
524
+ }
525
+ printIssues("FINAL", [...new Set(combinedIssues)]);
417
526
  process.exitCode = 1;
418
527
  } else {
419
528
  console.log(
420
529
  `
421
530
  GAMEPLAY_AUDIT: ALL CONTRACTS PASSED (${this.options.contracts.length} contracts, ${this.options.scenes.length} scenes)`
422
531
  );
532
+ console.log(
533
+ "GAMEPLAY_AUDIT_NOTE: \u73A9\u6CD5\u68C0\u67E5\u5DF2\u901A\u8FC7\uFF1B\u4ECD\u9700\u4EE5\u6700\u540E\u7684 TEST_RESULT \u5224\u65AD\u6574\u4F53\u6210\u529F\u3002"
534
+ );
423
535
  }
424
536
  }
425
537
  /** 立即输出一个此前未报告过的预检缺口,并让本次测试命令失败。 */
426
538
  reportPreflightIssue(issue) {
427
539
  if (this.preflightIssues.has(issue)) return;
428
540
  this.preflightIssues.add(issue);
429
- printIssues("PREFLIGHT", [issue]);
430
541
  process.exitCode = 1;
431
542
  }
432
543
  };
@@ -476,7 +587,9 @@ function defineGameVitestConfig(options) {
476
587
  ...options.additionalSetupFiles ?? []
477
588
  ],
478
589
  reporters: [
479
- "default",
590
+ // Vitest's minimal reporter is optimized for AI output: passing tests
591
+ // stay quiet while failed tests retain their useful error message.
592
+ "minimal",
480
593
  new GameplayAuditReporter(options.projectRoot, options.gameplayAudit)
481
594
  ],
482
595
  restoreMocks: true,
@@ -488,7 +601,9 @@ function defineGameVitestConfig(options) {
488
601
  "src/scenes/**/*.d.ts",
489
602
  "src/scenes/**/*.{spec,test}.{ts,tsx}"
490
603
  ],
491
- reporter: ["text"],
604
+ // Per-file threshold errors still identify omitted Scene files; the
605
+ // summary avoids printing a large coverage table on every successful run.
606
+ reporter: ["text-summary"],
492
607
  thresholds: {
493
608
  perFile: true,
494
609
  lines: 1,
@@ -535,7 +650,7 @@ describe("defineGameVitestConfig", () => {
535
650
  environment: "jsdom",
536
651
  environmentOptions: { jsdom: { url: "http://localhost/" } },
537
652
  setupFiles: ["miaoda-game-devkit/vitest-setup"],
538
- reporters: ["default", expect.any(Object)],
653
+ reporters: ["minimal", expect.any(Object)],
539
654
  restoreMocks: true,
540
655
  clearMocks: true,
541
656
  coverage: {
@@ -545,7 +660,7 @@ describe("defineGameVitestConfig", () => {
545
660
  "src/scenes/**/*.d.ts",
546
661
  "src/scenes/**/*.{spec,test}.{ts,tsx}"
547
662
  ],
548
- reporter: ["text"],
663
+ reporter: ["text-summary"],
549
664
  thresholds: {
550
665
  perFile: true,
551
666
  lines: 1,
@@ -242,7 +242,7 @@ function auditGameplayCollection(options, file, tests) {
242
242
  const matches = actual.filter((test) => test.metadata?.id === contract.id);
243
243
  if (matches.length === 0) {
244
244
  issues.push(
245
- `\u6D4B\u8BD5\u6267\u884C\u524D\u672A\u6536\u96C6\u5230 contract "${contract.id}"\uFF08${normalizedFile}\uFF09\u3002`
245
+ `\u6CA1\u6709\u627E\u5230\u68C0\u67E5\u9879 "${contract.id}" \u7684\u53EF\u6267\u884C\u6D4B\u8BD5\uFF08${normalizedFile}\uFF09\u3002`
246
246
  );
247
247
  } else if (matches.length > 1) {
248
248
  issues.push(
@@ -273,12 +273,15 @@ function auditGameplayCollection(options, file, tests) {
273
273
  function getGameplayEvidenceIssues(id, requirements, evidence) {
274
274
  const issues = [];
275
275
  const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
276
- if (requirements.requireInput && inputEvents === 0)
276
+ if (requirements.requireInput && inputEvents === 0) {
277
277
  issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165\u3002`);
278
- if (requirements.requireFrameAdvance && evidence.frames === 0)
278
+ }
279
+ if (requirements.requireFrameAdvance && evidence.frames === 0) {
279
280
  issues.push(`contract "${id}" \u672A\u63A8\u8FDB\u5B8C\u6574 Phaser \u5E27\u3002`);
280
- if (requirements.requirePhysicsStep && evidence.physicsSteps === 0)
281
+ }
282
+ if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
281
283
  issues.push(`contract "${id}" \u672A\u63A8\u8FDB Arcade Physics\u3002`);
284
+ }
282
285
  for (const target of normalizeList(requirements.requireTransition)) {
283
286
  if (!evidence.transitions.some((transition) => transition.to === target)) {
284
287
  issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u5230 ${target} \u7684 Scene \u8F6C\u573A\u3002`);
@@ -289,21 +292,24 @@ function getGameplayEvidenceIssues(id, requirements, evidence) {
289
292
  issues.push(`contract "${id}" \u672A\u8BBF\u95EE Scene "${scene}"\u3002`);
290
293
  }
291
294
  for (const scene of normalizeList(requirements.requireRestart)) {
292
- if (!evidence.restartedScenes.includes(scene))
295
+ if (!evidence.restartedScenes.includes(scene)) {
293
296
  issues.push(`contract "${id}" \u672A\u91CD\u542F Scene "${scene}"\u3002`);
297
+ }
294
298
  }
295
299
  for (const checkpoint of normalizeList(requirements.requireCheckpoint)) {
296
- if (!evidence.checkpoints.includes(checkpoint))
300
+ if (!evidence.checkpoints.includes(checkpoint)) {
297
301
  issues.push(`contract "${id}" \u7F3A\u5C11 checkpoint "${checkpoint}"\u3002`);
302
+ }
298
303
  }
299
- if (requirements.requireDestroy && !evidence.destroyed)
304
+ if (requirements.requireDestroy && !evidence.destroyed) {
300
305
  issues.push(`contract "${id}" \u672A\u5B8C\u6210 HEADLESS host \u9500\u6BC1\u3002`);
306
+ }
301
307
  return issues;
302
308
  }
303
309
  function auditGameplayRun(options, tests) {
304
310
  const issues = [];
305
311
  const actualContracts = tests.filter((test) => test.metadata);
306
- const acceptedEvidence = [];
312
+ const observedEvidence = [];
307
313
  for (const expected of options.contracts) {
308
314
  const matches = actualContracts.filter(
309
315
  (test2) => test2.metadata?.id === expected.id
@@ -319,6 +325,7 @@ function auditGameplayRun(options, tests) {
319
325
  const test = matches[0];
320
326
  const metadata = test?.metadata;
321
327
  if (!test || !metadata) continue;
328
+ if (metadata.evidence) observedEvidence.push(metadata.evidence);
322
329
  if (test.file.replaceAll("\\", "/") !== expected.testFile.replaceAll("\\", "/")) {
323
330
  issues.push(
324
331
  `contract "${expected.id}" \u5FC5\u987B\u4F4D\u4E8E ${expected.testFile}\uFF0C\u5B9E\u9645\u4F4D\u4E8E ${test.file}\u3002`
@@ -339,7 +346,6 @@ function auditGameplayRun(options, tests) {
339
346
  metadata.evidence
340
347
  );
341
348
  issues.push(...evidenceIssues);
342
- if (evidenceIssues.length === 0) acceptedEvidence.push(metadata.evidence);
343
349
  }
344
350
  for (const test of actualContracts) {
345
351
  const id = test.metadata?.id;
@@ -350,14 +356,15 @@ function auditGameplayRun(options, tests) {
350
356
  }
351
357
  }
352
358
  const registeredScenes = normalizeSet(
353
- acceptedEvidence.flatMap((evidence) => evidence.registeredScenes)
359
+ observedEvidence.flatMap((evidence) => evidence.registeredScenes)
354
360
  );
355
361
  const expectedScenes = normalizeSet(options.scenes);
356
362
  for (const scene of expectedScenes) {
357
- if (!registeredScenes.includes(scene))
363
+ if (!registeredScenes.includes(scene)) {
358
364
  issues.push(
359
- `\u751F\u4EA7 Scene "${scene}" \u672A\u51FA\u73B0\u5728\u4EFB\u4F55\u901A\u8FC7\u5BA1\u8BA1\u7684 host registry \u4E2D\u3002`
365
+ `\u751F\u4EA7 Scene "${scene}" \u672A\u51FA\u73B0\u5728\u6D4B\u8BD5\u5B9E\u9645\u8BB0\u5F55\u7684 host registry \u4E2D\u3002`
360
366
  );
367
+ }
361
368
  }
362
369
  for (const scene of registeredScenes) {
363
370
  if (!expectedScenes.includes(scene))
@@ -387,11 +394,96 @@ function toAuditTest(test) {
387
394
  metadata: isGameplayContractMetadata(metadata) ? metadata : void 0
388
395
  };
389
396
  }
397
+ function formatGameplayIssues(issues) {
398
+ const groups = /* @__PURE__ */ new Map();
399
+ const repairKeys = /* @__PURE__ */ new Set();
400
+ const repairs = [];
401
+ const repairText = {
402
+ input: "\u8F93\u5165\uFF1A\u5148\u63A8\u8FDB\u4E00\u5E27\uFF0C\u518D\u901A\u8FC7 host.input.mouse\u3001host.input.keyboard \u6216 host.input.touch \u9A71\u52A8\u771F\u5B9E\u751F\u4EA7\u8F93\u5165\u3002",
403
+ frames: "\u5E27\u63A8\u8FDB\uFF1A\u5728\u8F93\u5165\u6216\u751F\u4EA7\u547D\u4EE4\u540E\u8C03\u7528 host.stepFrames()\uFF0C\u5F02\u6B65 update \u4F7F\u7528 host.stepFramesAsync()\u3002",
404
+ physics: "\u7269\u7406\uFF1A\u4EC5\u5728\u751F\u4EA7\u4F7F\u7528 Arcade Physics \u65F6\u8C03\u7528 host.stepPhysics()\uFF1B\u5176\u4ED6\u7269\u7406\u7CFB\u7EDF\u4F7F\u7528\u5B8C\u6574\u5E27\u3002",
405
+ transition: "\u8F6C\u573A\uFF1Aboot \u524D\u6CE8\u518C\u76EE\u6807 Scene\uFF0C\u8C03\u7528\u751F\u4EA7\u8F6C\u573A\u547D\u4EE4\uFF0C\u518D\u7528 host.stepUntil() \u7B49\u5F85\u76EE\u6807\u6FC0\u6D3B\u5E76\u65AD\u8A00\u6E90/\u76EE\u6807\u72B6\u6001\u3002",
406
+ visit: "Scene\uFF1A\u786E\u8BA4\u6D4B\u8BD5\u901A\u8FC7\u771F\u5B9E\u5165\u53E3\u6216\u5408\u6CD5\u8F6C\u573A\u8BBF\u95EE\u8BE5 Scene\uFF0C\u5E76\u4F7F\u7528\u771F\u5B9E\u914D\u7F6E\u542F\u52A8\u3002",
407
+ restart: "\u6062\u590D\uFF1A\u901A\u8FC7\u771F\u5B9E\u8F93\u5165\u6216\u751F\u4EA7 controls \u89E6\u53D1 restart\uFF0C\u7B49\u5F85\u5B8C\u6574\u5E27\u5E76\u65AD\u8A00\u72B6\u6001\u5DF2\u6062\u590D\u3002",
408
+ checkpoint: "\u68C0\u67E5\u70B9\uFF1A\u5148\u65AD\u8A00 authoritative state\uFF0C\u518D\u8C03\u7528 host.checkpoint()\uFF1B\u4E0D\u8981\u8C03\u7528 contract.checkpoint()\u3002",
409
+ destroy: "\u6E05\u7406\uFF1A\u5728 afterEach \u4E2D\u8C03\u7528 host.destroy()\uFF0C\u5E76\u4FDD\u6301\u9500\u6BC1\u5E42\u7B49\u3002",
410
+ registry: "Scene \u6CE8\u518C\uFF1A\u786E\u8BA4 Scene \u5728\u751F\u4EA7 registry \u4E2D\uFF0C\u5E76\u5728 boot \u524D\u901A\u8FC7 additionalScenes \u6CE8\u518C\u8F6C\u573A\u76EE\u6807\u3002",
411
+ missing: "\u6D4B\u8BD5\u7F3A\u5931\uFF1A\u5728\u6307\u5B9A\u6587\u4EF6\u4F7F\u7528 gameplayTest \u5B9A\u4E49\u68C0\u67E5\u9879\uFF0C\u5E76\u901A\u8FC7 contract.attachHost(host) \u7ED1\u5B9A host\u3002",
412
+ test: "\u5148\u4FEE\u590D\u5BF9\u5E94\u6D4B\u8BD5\u7684\u7B2C\u4E00\u6761\u539F\u59CB\u9519\u8BEF\uFF1B\u68C0\u67E5\u9879\u53EA\u6C47\u603B\u7ED3\u679C\uFF0C\u4E0D\u662F\u8FD0\u884C\u65F6\u5BF9\u8C61\u3002"
413
+ };
414
+ const getCategory = (issue) => {
415
+ if (issue.includes("\u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165")) return { category: "input", key: "input" };
416
+ if (issue.includes("\u672A\u63A8\u8FDB\u5B8C\u6574 Phaser \u5E27")) return { category: "frames", key: "frames" };
417
+ if (issue.includes("\u672A\u63A8\u8FDB Arcade Physics")) return { category: "physics", key: "physics" };
418
+ if (issue.includes("\u672A\u8BB0\u5F55\u5230 ") && issue.includes("Scene \u8F6C\u573A")) {
419
+ const target = issue.match(/未记录到 (.+?) 的 Scene 转场/)?.[1] ?? "unknown";
420
+ return { category: "transition", key: `transition:${target}` };
421
+ }
422
+ if (issue.includes("\u672A\u8BBF\u95EE Scene")) {
423
+ const scene = issue.match(/未访问 Scene "([^"]+)"/)?.[1] ?? "unknown";
424
+ return { category: "visit", key: `visit:${scene}` };
425
+ }
426
+ if (issue.includes("\u672A\u91CD\u542F Scene")) {
427
+ const scene = issue.match(/未重启 Scene "([^"]+)"/)?.[1] ?? "unknown";
428
+ return { category: "restart", key: `restart:${scene}` };
429
+ }
430
+ if (issue.includes("\u7F3A\u5C11 checkpoint")) return { category: "checkpoint", key: `checkpoint:${issue.match(/checkpoint "([^"]+)"/)?.[1] ?? issue}` };
431
+ if (issue.includes("\u672A\u5B8C\u6210 HEADLESS host \u9500\u6BC1")) return { category: "destroy", key: "destroy" };
432
+ if (issue.includes("\u672A\u51FA\u73B0\u5728\u6D4B\u8BD5\u5B9E\u9645\u8BB0\u5F55\u7684 host registry")) {
433
+ const scene = issue.match(/生产 Scene "([^"]+)"/)?.[1] ?? "unknown";
434
+ return { category: "registry", key: `registry:${scene}` };
435
+ }
436
+ if (issue.includes("\u7F3A\u5C11 gameplay contract")) return { category: "missing", key: "missing" };
437
+ if (issue.includes("\u7684\u6D4B\u8BD5\u72B6\u6001\u4E3A")) return { category: "test", key: "test" };
438
+ return { category: "other", key: `other:${issue}` };
439
+ };
440
+ for (const rawIssue of [...new Set(issues)]) {
441
+ const compact = rawIssue.replaceAll(/\s+/g, " ").trim();
442
+ const readable = compact.replaceAll(/(?:gameplay )?contract "([^"]+)"/g, '\u68C0\u67E5\u9879 "$1"').replaceAll("evidence requirements", "\u6240\u9700\u8FD0\u884C\u8BC1\u636E").replaceAll("metadata", "\u6D4B\u8BD5\u58F0\u660E").replaceAll("host registry", "\u5DF2\u6CE8\u518C Scene \u6E05\u5355");
443
+ const id = compact.match(/contract "([^"]+)"/)?.[1];
444
+ const { category, key } = getCategory(compact);
445
+ const group = groups.get(key);
446
+ if (group) {
447
+ if (id && !group.ids.includes(id)) group.ids.push(id);
448
+ } else {
449
+ groups.set(key, { text: readable, ids: id ? [id] : [], key });
450
+ }
451
+ if (repairText[category] && !repairKeys.has(category)) {
452
+ repairKeys.add(category);
453
+ repairs.push(repairText[category]);
454
+ }
455
+ }
456
+ const grouped = [...groups.values()];
457
+ const visible = grouped.slice(0, 8).map((group) => {
458
+ if (group.ids.length <= 1) return group.text;
459
+ return `${group.text}\uFF08\u53D7\u5F71\u54CD\u68C0\u67E5\u9879\uFF1A${group.ids.join("\u3001")}\uFF09`;
460
+ });
461
+ return {
462
+ issues: visible.map(
463
+ (issue) => issue.length > 360 ? `${issue.slice(0, 357)}...` : issue
464
+ ),
465
+ repairs: repairs.slice(0, 8),
466
+ omitted: Math.max(0, grouped.length - visible.length)
467
+ };
468
+ }
390
469
  function printIssues(stage, issues) {
391
470
  if (issues.length === 0) return;
392
471
  console.error(`
393
472
  GAMEPLAY_AUDIT: ${stage} FAILED`);
394
- for (const issue of issues) console.error(`- ${issue}`);
473
+ console.error(
474
+ "\u73A9\u6CD5\u68C0\u67E5\u5931\u8D25\uFF1A\u4EE5\u4E0B\u6BCF\u4E2A\u68C0\u67E5\u9879\u5BF9\u5E94\u4E00\u4E2A\u9700\u8981\u6D4B\u8BD5\u8BC1\u660E\u7684\u6E38\u620F\u884C\u4E3A\u3002"
475
+ );
476
+ const formatted = formatGameplayIssues(issues);
477
+ for (const issue of formatted.issues) console.error(`- ${issue}`);
478
+ if (formatted.repairs.length > 0) {
479
+ console.error("\u4FEE\u590D\u5EFA\u8BAE\uFF08\u540C\u7C7B\u95EE\u9898\u53EA\u5217\u4E00\u6B21\uFF09\uFF1A");
480
+ for (const repair of formatted.repairs) console.error(`- ${repair}`);
481
+ }
482
+ if (formatted.omitted > 0) {
483
+ console.error(
484
+ `- \u5176\u4F59 ${formatted.omitted} \u7C7B\u95EE\u9898\u5DF2\u7701\u7565\uFF1B\u5148\u4FEE\u590D\u4EE5\u4E0A\u95EE\u9898\u540E\u91CD\u65B0\u8FD0\u884C pnpm test\u3002`
485
+ );
486
+ }
395
487
  }
396
488
  var GameplayAuditReporter = class {
397
489
  projectRoot;
@@ -427,26 +519,45 @@ var GameplayAuditReporter = class {
427
519
  for (const issue of result.issues) this.reportPreflightIssue(issue);
428
520
  }
429
521
  /** 在测试运行结束后汇总全部 contract、Ledger 和 Scene registry,并决定命令退出码。 */
430
- onTestRunEnd(testModules) {
522
+ onTestRunEnd(testModules, _unhandledErrors = [], reason = "passed") {
523
+ const collectionFailed = reason === "failed" && testModules.some(
524
+ (testModule) => testModule.state() === "failed" && [...testModule.children.allTests()].length === 0
525
+ );
526
+ if (collectionFailed) {
527
+ console.error(
528
+ "\nGAMEPLAY_AUDIT: NOT RUN\n\u73A9\u6CD5\u6D4B\u8BD5\u6587\u4EF6\u65E0\u6CD5\u52A0\u8F7D\uFF0C\u56E0\u6B64\u65E0\u6CD5\u68C0\u67E5\u6E38\u620F\u884C\u4E3A\u3002\u8BF7\u5148\u4FEE\u590D Vitest \u4E0A\u65B9\u663E\u793A\u7684\u8BED\u6CD5\u6216\u5BFC\u5165\u9519\u8BEF\uFF0C\u518D\u8FD0\u884C pnpm test\u3002"
529
+ );
530
+ process.exitCode = 1;
531
+ return;
532
+ }
431
533
  const tests = testModules.flatMap(
432
534
  (testModule) => [...testModule.children.allTests()].map(toAuditTest)
433
535
  );
434
536
  const result = auditGameplayRun(this.options, tests);
435
- if (!result.passed) {
436
- printIssues("FINAL", result.issues);
537
+ const combinedIssues = [
538
+ ...this.preflightIssues,
539
+ ...result.issues
540
+ ];
541
+ if (combinedIssues.length > 0) {
542
+ if (this.preflightIssues.size > 0) {
543
+ console.error("\nGAMEPLAY_AUDIT: PREFLIGHT FAILED (included in final summary)");
544
+ }
545
+ printIssues("FINAL", [...new Set(combinedIssues)]);
437
546
  process.exitCode = 1;
438
547
  } else {
439
548
  console.log(
440
549
  `
441
550
  GAMEPLAY_AUDIT: ALL CONTRACTS PASSED (${this.options.contracts.length} contracts, ${this.options.scenes.length} scenes)`
442
551
  );
552
+ console.log(
553
+ "GAMEPLAY_AUDIT_NOTE: \u73A9\u6CD5\u68C0\u67E5\u5DF2\u901A\u8FC7\uFF1B\u4ECD\u9700\u4EE5\u6700\u540E\u7684 TEST_RESULT \u5224\u65AD\u6574\u4F53\u6210\u529F\u3002"
554
+ );
443
555
  }
444
556
  }
445
557
  /** 立即输出一个此前未报告过的预检缺口,并让本次测试命令失败。 */
446
558
  reportPreflightIssue(issue) {
447
559
  if (this.preflightIssues.has(issue)) return;
448
560
  this.preflightIssues.add(issue);
449
- printIssues("PREFLIGHT", [issue]);
450
561
  process.exitCode = 1;
451
562
  }
452
563
  };
@@ -496,7 +607,9 @@ function defineGameVitestConfig(options) {
496
607
  ...options.additionalSetupFiles ?? []
497
608
  ],
498
609
  reporters: [
499
- "default",
610
+ // Vitest's minimal reporter is optimized for AI output: passing tests
611
+ // stay quiet while failed tests retain their useful error message.
612
+ "minimal",
500
613
  new GameplayAuditReporter(options.projectRoot, options.gameplayAudit)
501
614
  ],
502
615
  restoreMocks: true,
@@ -508,7 +621,9 @@ function defineGameVitestConfig(options) {
508
621
  "src/scenes/**/*.d.ts",
509
622
  "src/scenes/**/*.{spec,test}.{ts,tsx}"
510
623
  ],
511
- reporter: ["text"],
624
+ // Per-file threshold errors still identify omitted Scene files; the
625
+ // summary avoids printing a large coverage table on every successful run.
626
+ reporter: ["text-summary"],
512
627
  thresholds: {
513
628
  perFile: true,
514
629
  lines: 1,
@@ -218,7 +218,7 @@ function auditGameplayCollection(options, file, tests) {
218
218
  const matches = actual.filter((test) => test.metadata?.id === contract.id);
219
219
  if (matches.length === 0) {
220
220
  issues.push(
221
- `\u6D4B\u8BD5\u6267\u884C\u524D\u672A\u6536\u96C6\u5230 contract "${contract.id}"\uFF08${normalizedFile}\uFF09\u3002`
221
+ `\u6CA1\u6709\u627E\u5230\u68C0\u67E5\u9879 "${contract.id}" \u7684\u53EF\u6267\u884C\u6D4B\u8BD5\uFF08${normalizedFile}\uFF09\u3002`
222
222
  );
223
223
  } else if (matches.length > 1) {
224
224
  issues.push(
@@ -249,12 +249,15 @@ function auditGameplayCollection(options, file, tests) {
249
249
  function getGameplayEvidenceIssues(id, requirements, evidence) {
250
250
  const issues = [];
251
251
  const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
252
- if (requirements.requireInput && inputEvents === 0)
252
+ if (requirements.requireInput && inputEvents === 0) {
253
253
  issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165\u3002`);
254
- if (requirements.requireFrameAdvance && evidence.frames === 0)
254
+ }
255
+ if (requirements.requireFrameAdvance && evidence.frames === 0) {
255
256
  issues.push(`contract "${id}" \u672A\u63A8\u8FDB\u5B8C\u6574 Phaser \u5E27\u3002`);
256
- if (requirements.requirePhysicsStep && evidence.physicsSteps === 0)
257
+ }
258
+ if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
257
259
  issues.push(`contract "${id}" \u672A\u63A8\u8FDB Arcade Physics\u3002`);
260
+ }
258
261
  for (const target of normalizeList(requirements.requireTransition)) {
259
262
  if (!evidence.transitions.some((transition) => transition.to === target)) {
260
263
  issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u5230 ${target} \u7684 Scene \u8F6C\u573A\u3002`);
@@ -265,21 +268,24 @@ function getGameplayEvidenceIssues(id, requirements, evidence) {
265
268
  issues.push(`contract "${id}" \u672A\u8BBF\u95EE Scene "${scene}"\u3002`);
266
269
  }
267
270
  for (const scene of normalizeList(requirements.requireRestart)) {
268
- if (!evidence.restartedScenes.includes(scene))
271
+ if (!evidence.restartedScenes.includes(scene)) {
269
272
  issues.push(`contract "${id}" \u672A\u91CD\u542F Scene "${scene}"\u3002`);
273
+ }
270
274
  }
271
275
  for (const checkpoint of normalizeList(requirements.requireCheckpoint)) {
272
- if (!evidence.checkpoints.includes(checkpoint))
276
+ if (!evidence.checkpoints.includes(checkpoint)) {
273
277
  issues.push(`contract "${id}" \u7F3A\u5C11 checkpoint "${checkpoint}"\u3002`);
278
+ }
274
279
  }
275
- if (requirements.requireDestroy && !evidence.destroyed)
280
+ if (requirements.requireDestroy && !evidence.destroyed) {
276
281
  issues.push(`contract "${id}" \u672A\u5B8C\u6210 HEADLESS host \u9500\u6BC1\u3002`);
282
+ }
277
283
  return issues;
278
284
  }
279
285
  function auditGameplayRun(options, tests) {
280
286
  const issues = [];
281
287
  const actualContracts = tests.filter((test) => test.metadata);
282
- const acceptedEvidence = [];
288
+ const observedEvidence = [];
283
289
  for (const expected of options.contracts) {
284
290
  const matches = actualContracts.filter(
285
291
  (test2) => test2.metadata?.id === expected.id
@@ -295,6 +301,7 @@ function auditGameplayRun(options, tests) {
295
301
  const test = matches[0];
296
302
  const metadata = test?.metadata;
297
303
  if (!test || !metadata) continue;
304
+ if (metadata.evidence) observedEvidence.push(metadata.evidence);
298
305
  if (test.file.replaceAll("\\", "/") !== expected.testFile.replaceAll("\\", "/")) {
299
306
  issues.push(
300
307
  `contract "${expected.id}" \u5FC5\u987B\u4F4D\u4E8E ${expected.testFile}\uFF0C\u5B9E\u9645\u4F4D\u4E8E ${test.file}\u3002`
@@ -315,7 +322,6 @@ function auditGameplayRun(options, tests) {
315
322
  metadata.evidence
316
323
  );
317
324
  issues.push(...evidenceIssues);
318
- if (evidenceIssues.length === 0) acceptedEvidence.push(metadata.evidence);
319
325
  }
320
326
  for (const test of actualContracts) {
321
327
  const id = test.metadata?.id;
@@ -326,14 +332,15 @@ function auditGameplayRun(options, tests) {
326
332
  }
327
333
  }
328
334
  const registeredScenes = normalizeSet(
329
- acceptedEvidence.flatMap((evidence) => evidence.registeredScenes)
335
+ observedEvidence.flatMap((evidence) => evidence.registeredScenes)
330
336
  );
331
337
  const expectedScenes = normalizeSet(options.scenes);
332
338
  for (const scene of expectedScenes) {
333
- if (!registeredScenes.includes(scene))
339
+ if (!registeredScenes.includes(scene)) {
334
340
  issues.push(
335
- `\u751F\u4EA7 Scene "${scene}" \u672A\u51FA\u73B0\u5728\u4EFB\u4F55\u901A\u8FC7\u5BA1\u8BA1\u7684 host registry \u4E2D\u3002`
341
+ `\u751F\u4EA7 Scene "${scene}" \u672A\u51FA\u73B0\u5728\u6D4B\u8BD5\u5B9E\u9645\u8BB0\u5F55\u7684 host registry \u4E2D\u3002`
336
342
  );
343
+ }
337
344
  }
338
345
  for (const scene of registeredScenes) {
339
346
  if (!expectedScenes.includes(scene))
@@ -363,11 +370,96 @@ function toAuditTest(test) {
363
370
  metadata: isGameplayContractMetadata(metadata) ? metadata : void 0
364
371
  };
365
372
  }
373
+ function formatGameplayIssues(issues) {
374
+ const groups = /* @__PURE__ */ new Map();
375
+ const repairKeys = /* @__PURE__ */ new Set();
376
+ const repairs = [];
377
+ const repairText = {
378
+ input: "\u8F93\u5165\uFF1A\u5148\u63A8\u8FDB\u4E00\u5E27\uFF0C\u518D\u901A\u8FC7 host.input.mouse\u3001host.input.keyboard \u6216 host.input.touch \u9A71\u52A8\u771F\u5B9E\u751F\u4EA7\u8F93\u5165\u3002",
379
+ frames: "\u5E27\u63A8\u8FDB\uFF1A\u5728\u8F93\u5165\u6216\u751F\u4EA7\u547D\u4EE4\u540E\u8C03\u7528 host.stepFrames()\uFF0C\u5F02\u6B65 update \u4F7F\u7528 host.stepFramesAsync()\u3002",
380
+ physics: "\u7269\u7406\uFF1A\u4EC5\u5728\u751F\u4EA7\u4F7F\u7528 Arcade Physics \u65F6\u8C03\u7528 host.stepPhysics()\uFF1B\u5176\u4ED6\u7269\u7406\u7CFB\u7EDF\u4F7F\u7528\u5B8C\u6574\u5E27\u3002",
381
+ transition: "\u8F6C\u573A\uFF1Aboot \u524D\u6CE8\u518C\u76EE\u6807 Scene\uFF0C\u8C03\u7528\u751F\u4EA7\u8F6C\u573A\u547D\u4EE4\uFF0C\u518D\u7528 host.stepUntil() \u7B49\u5F85\u76EE\u6807\u6FC0\u6D3B\u5E76\u65AD\u8A00\u6E90/\u76EE\u6807\u72B6\u6001\u3002",
382
+ visit: "Scene\uFF1A\u786E\u8BA4\u6D4B\u8BD5\u901A\u8FC7\u771F\u5B9E\u5165\u53E3\u6216\u5408\u6CD5\u8F6C\u573A\u8BBF\u95EE\u8BE5 Scene\uFF0C\u5E76\u4F7F\u7528\u771F\u5B9E\u914D\u7F6E\u542F\u52A8\u3002",
383
+ restart: "\u6062\u590D\uFF1A\u901A\u8FC7\u771F\u5B9E\u8F93\u5165\u6216\u751F\u4EA7 controls \u89E6\u53D1 restart\uFF0C\u7B49\u5F85\u5B8C\u6574\u5E27\u5E76\u65AD\u8A00\u72B6\u6001\u5DF2\u6062\u590D\u3002",
384
+ checkpoint: "\u68C0\u67E5\u70B9\uFF1A\u5148\u65AD\u8A00 authoritative state\uFF0C\u518D\u8C03\u7528 host.checkpoint()\uFF1B\u4E0D\u8981\u8C03\u7528 contract.checkpoint()\u3002",
385
+ destroy: "\u6E05\u7406\uFF1A\u5728 afterEach \u4E2D\u8C03\u7528 host.destroy()\uFF0C\u5E76\u4FDD\u6301\u9500\u6BC1\u5E42\u7B49\u3002",
386
+ registry: "Scene \u6CE8\u518C\uFF1A\u786E\u8BA4 Scene \u5728\u751F\u4EA7 registry \u4E2D\uFF0C\u5E76\u5728 boot \u524D\u901A\u8FC7 additionalScenes \u6CE8\u518C\u8F6C\u573A\u76EE\u6807\u3002",
387
+ missing: "\u6D4B\u8BD5\u7F3A\u5931\uFF1A\u5728\u6307\u5B9A\u6587\u4EF6\u4F7F\u7528 gameplayTest \u5B9A\u4E49\u68C0\u67E5\u9879\uFF0C\u5E76\u901A\u8FC7 contract.attachHost(host) \u7ED1\u5B9A host\u3002",
388
+ test: "\u5148\u4FEE\u590D\u5BF9\u5E94\u6D4B\u8BD5\u7684\u7B2C\u4E00\u6761\u539F\u59CB\u9519\u8BEF\uFF1B\u68C0\u67E5\u9879\u53EA\u6C47\u603B\u7ED3\u679C\uFF0C\u4E0D\u662F\u8FD0\u884C\u65F6\u5BF9\u8C61\u3002"
389
+ };
390
+ const getCategory = (issue) => {
391
+ if (issue.includes("\u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165")) return { category: "input", key: "input" };
392
+ if (issue.includes("\u672A\u63A8\u8FDB\u5B8C\u6574 Phaser \u5E27")) return { category: "frames", key: "frames" };
393
+ if (issue.includes("\u672A\u63A8\u8FDB Arcade Physics")) return { category: "physics", key: "physics" };
394
+ if (issue.includes("\u672A\u8BB0\u5F55\u5230 ") && issue.includes("Scene \u8F6C\u573A")) {
395
+ const target = issue.match(/未记录到 (.+?) 的 Scene 转场/)?.[1] ?? "unknown";
396
+ return { category: "transition", key: `transition:${target}` };
397
+ }
398
+ if (issue.includes("\u672A\u8BBF\u95EE Scene")) {
399
+ const scene = issue.match(/未访问 Scene "([^"]+)"/)?.[1] ?? "unknown";
400
+ return { category: "visit", key: `visit:${scene}` };
401
+ }
402
+ if (issue.includes("\u672A\u91CD\u542F Scene")) {
403
+ const scene = issue.match(/未重启 Scene "([^"]+)"/)?.[1] ?? "unknown";
404
+ return { category: "restart", key: `restart:${scene}` };
405
+ }
406
+ if (issue.includes("\u7F3A\u5C11 checkpoint")) return { category: "checkpoint", key: `checkpoint:${issue.match(/checkpoint "([^"]+)"/)?.[1] ?? issue}` };
407
+ if (issue.includes("\u672A\u5B8C\u6210 HEADLESS host \u9500\u6BC1")) return { category: "destroy", key: "destroy" };
408
+ if (issue.includes("\u672A\u51FA\u73B0\u5728\u6D4B\u8BD5\u5B9E\u9645\u8BB0\u5F55\u7684 host registry")) {
409
+ const scene = issue.match(/生产 Scene "([^"]+)"/)?.[1] ?? "unknown";
410
+ return { category: "registry", key: `registry:${scene}` };
411
+ }
412
+ if (issue.includes("\u7F3A\u5C11 gameplay contract")) return { category: "missing", key: "missing" };
413
+ if (issue.includes("\u7684\u6D4B\u8BD5\u72B6\u6001\u4E3A")) return { category: "test", key: "test" };
414
+ return { category: "other", key: `other:${issue}` };
415
+ };
416
+ for (const rawIssue of [...new Set(issues)]) {
417
+ const compact = rawIssue.replaceAll(/\s+/g, " ").trim();
418
+ const readable = compact.replaceAll(/(?:gameplay )?contract "([^"]+)"/g, '\u68C0\u67E5\u9879 "$1"').replaceAll("evidence requirements", "\u6240\u9700\u8FD0\u884C\u8BC1\u636E").replaceAll("metadata", "\u6D4B\u8BD5\u58F0\u660E").replaceAll("host registry", "\u5DF2\u6CE8\u518C Scene \u6E05\u5355");
419
+ const id = compact.match(/contract "([^"]+)"/)?.[1];
420
+ const { category, key } = getCategory(compact);
421
+ const group = groups.get(key);
422
+ if (group) {
423
+ if (id && !group.ids.includes(id)) group.ids.push(id);
424
+ } else {
425
+ groups.set(key, { text: readable, ids: id ? [id] : [], key });
426
+ }
427
+ if (repairText[category] && !repairKeys.has(category)) {
428
+ repairKeys.add(category);
429
+ repairs.push(repairText[category]);
430
+ }
431
+ }
432
+ const grouped = [...groups.values()];
433
+ const visible = grouped.slice(0, 8).map((group) => {
434
+ if (group.ids.length <= 1) return group.text;
435
+ return `${group.text}\uFF08\u53D7\u5F71\u54CD\u68C0\u67E5\u9879\uFF1A${group.ids.join("\u3001")}\uFF09`;
436
+ });
437
+ return {
438
+ issues: visible.map(
439
+ (issue) => issue.length > 360 ? `${issue.slice(0, 357)}...` : issue
440
+ ),
441
+ repairs: repairs.slice(0, 8),
442
+ omitted: Math.max(0, grouped.length - visible.length)
443
+ };
444
+ }
366
445
  function printIssues(stage, issues) {
367
446
  if (issues.length === 0) return;
368
447
  console.error(`
369
448
  GAMEPLAY_AUDIT: ${stage} FAILED`);
370
- for (const issue of issues) console.error(`- ${issue}`);
449
+ console.error(
450
+ "\u73A9\u6CD5\u68C0\u67E5\u5931\u8D25\uFF1A\u4EE5\u4E0B\u6BCF\u4E2A\u68C0\u67E5\u9879\u5BF9\u5E94\u4E00\u4E2A\u9700\u8981\u6D4B\u8BD5\u8BC1\u660E\u7684\u6E38\u620F\u884C\u4E3A\u3002"
451
+ );
452
+ const formatted = formatGameplayIssues(issues);
453
+ for (const issue of formatted.issues) console.error(`- ${issue}`);
454
+ if (formatted.repairs.length > 0) {
455
+ console.error("\u4FEE\u590D\u5EFA\u8BAE\uFF08\u540C\u7C7B\u95EE\u9898\u53EA\u5217\u4E00\u6B21\uFF09\uFF1A");
456
+ for (const repair of formatted.repairs) console.error(`- ${repair}`);
457
+ }
458
+ if (formatted.omitted > 0) {
459
+ console.error(
460
+ `- \u5176\u4F59 ${formatted.omitted} \u7C7B\u95EE\u9898\u5DF2\u7701\u7565\uFF1B\u5148\u4FEE\u590D\u4EE5\u4E0A\u95EE\u9898\u540E\u91CD\u65B0\u8FD0\u884C pnpm test\u3002`
461
+ );
462
+ }
371
463
  }
372
464
  var GameplayAuditReporter = class {
373
465
  projectRoot;
@@ -403,26 +495,45 @@ var GameplayAuditReporter = class {
403
495
  for (const issue of result.issues) this.reportPreflightIssue(issue);
404
496
  }
405
497
  /** 在测试运行结束后汇总全部 contract、Ledger 和 Scene registry,并决定命令退出码。 */
406
- onTestRunEnd(testModules) {
498
+ onTestRunEnd(testModules, _unhandledErrors = [], reason = "passed") {
499
+ const collectionFailed = reason === "failed" && testModules.some(
500
+ (testModule) => testModule.state() === "failed" && [...testModule.children.allTests()].length === 0
501
+ );
502
+ if (collectionFailed) {
503
+ console.error(
504
+ "\nGAMEPLAY_AUDIT: NOT RUN\n\u73A9\u6CD5\u6D4B\u8BD5\u6587\u4EF6\u65E0\u6CD5\u52A0\u8F7D\uFF0C\u56E0\u6B64\u65E0\u6CD5\u68C0\u67E5\u6E38\u620F\u884C\u4E3A\u3002\u8BF7\u5148\u4FEE\u590D Vitest \u4E0A\u65B9\u663E\u793A\u7684\u8BED\u6CD5\u6216\u5BFC\u5165\u9519\u8BEF\uFF0C\u518D\u8FD0\u884C pnpm test\u3002"
505
+ );
506
+ process.exitCode = 1;
507
+ return;
508
+ }
407
509
  const tests = testModules.flatMap(
408
510
  (testModule) => [...testModule.children.allTests()].map(toAuditTest)
409
511
  );
410
512
  const result = auditGameplayRun(this.options, tests);
411
- if (!result.passed) {
412
- printIssues("FINAL", result.issues);
513
+ const combinedIssues = [
514
+ ...this.preflightIssues,
515
+ ...result.issues
516
+ ];
517
+ if (combinedIssues.length > 0) {
518
+ if (this.preflightIssues.size > 0) {
519
+ console.error("\nGAMEPLAY_AUDIT: PREFLIGHT FAILED (included in final summary)");
520
+ }
521
+ printIssues("FINAL", [...new Set(combinedIssues)]);
413
522
  process.exitCode = 1;
414
523
  } else {
415
524
  console.log(
416
525
  `
417
526
  GAMEPLAY_AUDIT: ALL CONTRACTS PASSED (${this.options.contracts.length} contracts, ${this.options.scenes.length} scenes)`
418
527
  );
528
+ console.log(
529
+ "GAMEPLAY_AUDIT_NOTE: \u73A9\u6CD5\u68C0\u67E5\u5DF2\u901A\u8FC7\uFF1B\u4ECD\u9700\u4EE5\u6700\u540E\u7684 TEST_RESULT \u5224\u65AD\u6574\u4F53\u6210\u529F\u3002"
530
+ );
419
531
  }
420
532
  }
421
533
  /** 立即输出一个此前未报告过的预检缺口,并让本次测试命令失败。 */
422
534
  reportPreflightIssue(issue) {
423
535
  if (this.preflightIssues.has(issue)) return;
424
536
  this.preflightIssues.add(issue);
425
- printIssues("PREFLIGHT", [issue]);
426
537
  process.exitCode = 1;
427
538
  }
428
539
  };
@@ -472,7 +583,9 @@ function defineGameVitestConfig(options) {
472
583
  ...options.additionalSetupFiles ?? []
473
584
  ],
474
585
  reporters: [
475
- "default",
586
+ // Vitest's minimal reporter is optimized for AI output: passing tests
587
+ // stay quiet while failed tests retain their useful error message.
588
+ "minimal",
476
589
  new GameplayAuditReporter(options.projectRoot, options.gameplayAudit)
477
590
  ],
478
591
  restoreMocks: true,
@@ -484,7 +597,9 @@ function defineGameVitestConfig(options) {
484
597
  "src/scenes/**/*.d.ts",
485
598
  "src/scenes/**/*.{spec,test}.{ts,tsx}"
486
599
  ],
487
- reporter: ["text"],
600
+ // Per-file threshold errors still identify omitted Scene files; the
601
+ // summary avoids printing a large coverage table on every successful run.
602
+ reporter: ["text-summary"],
488
603
  thresholds: {
489
604
  perFile: true,
490
605
  lines: 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Shared lint and deterministic Phaser HEADLESS testing tools for Miaoda games",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",
@@ -43,18 +43,6 @@
43
43
  "!dist/**/*.map",
44
44
  "!dist/lint/**/*.test.*"
45
45
  ],
46
- "scripts": {
47
- "build": "tsup",
48
- "prepack": "pnpm run build",
49
- "release": "pnpm run test && clean-publish",
50
- "release:dry-run": "pnpm run test && clean-publish --dry-run",
51
- "release:beta": "pnpm run test && npm version prerelease --preid=beta && clean-publish --tag beta",
52
- "release:patch": "pnpm run test && npm version patch && clean-publish",
53
- "release:minor": "pnpm run test && npm version minor && clean-publish",
54
- "release:major": "pnpm run test && npm version major && clean-publish",
55
- "typecheck": "tsc --noEmit",
56
- "test": "pnpm run build && ./bin/miaoda-game-lint.js --contracts-only"
57
- },
58
46
  "nx": {
59
47
  "tags": [
60
48
  "type:package",
@@ -94,23 +82,8 @@
94
82
  "peerDependencies": {
95
83
  "phaser": ">=4.0.0 <5"
96
84
  },
97
- "devDependencies": {
98
- "@types/eslint": "^9.6.1",
99
- "@types/estree": "^1.0.9",
100
- "@types/node": "^24.13.3",
101
- "clean-publish": "^6.0.5",
102
- "phaser": "4.0.0",
103
- "tsup": "^8.5.1",
104
- "typescript": "^5.9.3"
105
- },
106
85
  "publishConfig": {
107
86
  "registry": "https://registry.npmjs.org/",
108
87
  "access": "public"
109
- },
110
- "clean-publish": {
111
- "fields": [
112
- "devDependencies",
113
- "scripts"
114
- ]
115
88
  }
116
89
  }