miaoda-game-devkit 0.2.2 → 0.2.4

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.
@@ -2,6 +2,8 @@
2
2
  import { describe, expect, it } from "vitest";
3
3
 
4
4
  // src/gameplay-audit.ts
5
+ import { readdirSync, readFileSync, statSync } from "fs";
6
+ import { join } from "path";
5
7
  function normalizeList(value) {
6
8
  if (value === void 0) return [];
7
9
  const values = typeof value === "string" ? [value] : value;
@@ -35,20 +37,35 @@ function createGameplayContractMetadata(contract) {
35
37
  const scenes = normalizeSet(contract.scenes);
36
38
  return {
37
39
  id: contract.id.trim(),
40
+ kind: contract.kind,
38
41
  scenes,
39
42
  requirements: normalizeGameplayRequirements(contract, scenes),
40
43
  verified: false
41
44
  };
42
45
  }
43
- function validateGameplayAuditOptions(options2) {
46
+ function validateGameplayAuditOptions(options2, projectRoot) {
44
47
  const issues = [];
45
- const scenes = normalizeSet(options2.scenes);
48
+ const scenes = [...new Set(options2.scenes.map((scene) => scene.trim()).filter(Boolean))];
49
+ const mode = options2.mode ?? "interactive";
50
+ if (mode === "non-interactive") {
51
+ const justification = options2.nonInteractiveJustification?.trim() ?? "";
52
+ if (justification.length < 20) {
53
+ issues.push(
54
+ "non-interactive \u6A21\u5F0F\u5FC5\u987B\u63D0\u4F9B\u81F3\u5C11 20 \u4E2A\u5B57\u7B26\u7684 nonInteractiveJustification\u3002"
55
+ );
56
+ }
57
+ }
58
+ if (mode !== "interactive" && mode !== "non-interactive") {
59
+ issues.push("gameplayAudit.mode \u5FC5\u987B\u662F interactive \u6216 non-interactive\u3002");
60
+ }
46
61
  if (scenes.length === 0) issues.push("gameplayAudit.scenes \u4E0D\u80FD\u4E3A\u7A7A\u3002");
47
62
  if (scenes.length !== options2.scenes.length) {
48
63
  issues.push("gameplayAudit.scenes \u4E0D\u80FD\u5305\u542B\u7A7A\u503C\u6216\u91CD\u590D Scene key\u3002");
49
64
  }
50
65
  const contractIds = /* @__PURE__ */ new Set();
51
66
  const coveredScenes = /* @__PURE__ */ new Set();
67
+ const kinds = /* @__PURE__ */ new Map();
68
+ const invalidKindContracts = [];
52
69
  for (const contract of options2.contracts) {
53
70
  const id = contract.id.trim();
54
71
  if (!id) {
@@ -67,6 +84,27 @@ function validateGameplayAuditOptions(options2) {
67
84
  );
68
85
  }
69
86
  const contractScenes = normalizeSet(contract.scenes);
87
+ if (!["boot", "playthrough", "transition", "recovery"].includes(contract.kind)) {
88
+ invalidKindContracts.push(id || "<empty>");
89
+ } else {
90
+ const list = kinds.get(contract.kind) ?? [];
91
+ list.push(contract);
92
+ kinds.set(contract.kind, list);
93
+ }
94
+ if (contract.kind === "playthrough") {
95
+ if (!contract.requireInput) issues.push(`playthrough contract "${id}" \u5FC5\u987B requireInput: true\u3002`);
96
+ if (!contract.requireFrameAdvance) issues.push(`playthrough contract "${id}" \u5FC5\u987B requireFrameAdvance: true\u3002`);
97
+ if (normalizeList(contract.requireCheckpoint).length === 0) issues.push(`playthrough contract "${id}" \u5FC5\u987B\u58F0\u660E requireCheckpoint\u3002`);
98
+ }
99
+ if (contract.kind === "transition" && normalizeList(contract.requireTransition).length === 0) {
100
+ issues.push(`transition contract "${id}" \u5FC5\u987B\u58F0\u660E requireTransition\u3002`);
101
+ }
102
+ if (contract.kind === "recovery") {
103
+ if (!contract.requireInput) issues.push(`recovery contract "${id}" \u5FC5\u987B requireInput: true\u3002`);
104
+ if (!contract.requireFrameAdvance) issues.push(`recovery contract "${id}" \u5FC5\u987B requireFrameAdvance: true\u3002`);
105
+ if (normalizeList(contract.requireRestart).length === 0) issues.push(`recovery contract "${id}" \u5FC5\u987B\u58F0\u660E requireRestart\u3002`);
106
+ if (normalizeList(contract.requireCheckpoint).length < 2) issues.push(`recovery contract "${id}" \u81F3\u5C11\u9700\u8981\u4E24\u4E2A checkpoint\uFF08\u91CD\u7F6E\u524D\u540E\uFF09\u3002`);
107
+ }
70
108
  if (contractScenes.length === 0) {
71
109
  issues.push(`contract "${id || "<empty>"}" \u5FC5\u987B\u58F0\u660E\u81F3\u5C11\u4E00\u4E2A Scene\u3002`);
72
110
  }
@@ -79,6 +117,11 @@ function validateGameplayAuditOptions(options2) {
79
117
  }
80
118
  }
81
119
  }
120
+ if (invalidKindContracts.length > 0) {
121
+ issues.push(
122
+ `\u4EE5\u4E0B contract \u7F3A\u5C11\u6709\u6548 kind\uFF1A${invalidKindContracts.map((id) => `"${id}"`).join("\u3001")}\u3002\u8BF7\u4E3A\u6BCF\u4E2A contract \u8BBE\u7F6E kind: "boot"\u3001"playthrough"\u3001"transition" \u6216 "recovery"\u3002`
123
+ );
124
+ }
82
125
  for (const scene of scenes) {
83
126
  if (!coveredScenes.has(scene)) {
84
127
  issues.push(`Scene "${scene}" \u6CA1\u6709\u4EFB\u4F55 gameplay contract \u8D1F\u8D23\u9A8C\u8BC1\u3002`);
@@ -86,11 +129,71 @@ function validateGameplayAuditOptions(options2) {
86
129
  }
87
130
  if (options2.contracts.length === 0)
88
131
  issues.push("gameplayAudit.contracts \u4E0D\u80FD\u4E3A\u7A7A\u3002");
132
+ if (mode === "interactive") {
133
+ const hasPlaythrough = (kinds.get("playthrough") ?? []).length > 0;
134
+ const hasInput = options2.contracts.some((contract) => contract.requireInput);
135
+ const hasCheckpoint = options2.contracts.some((contract) => normalizeList(contract.requireCheckpoint).length > 0);
136
+ if (!hasPlaythrough || !hasInput || !hasCheckpoint) {
137
+ issues.push(
138
+ '\u4EA4\u4E92\u9879\u76EE\u7F3A\u5C11\u6700\u4F4E\u73A9\u6CD5\u57FA\u7EBF\uFF1A\u81F3\u5C11\u4E00\u4E2A kind: "playthrough" contract \u5FC5\u987B\u540C\u65F6\u58F0\u660E requireInput: true\u3001requireFrameAdvance: true \u548C requireCheckpoint: ["progress"]\u3002'
139
+ );
140
+ }
141
+ const entryScene = scenes[0];
142
+ for (const scene of scenes.slice(1)) {
143
+ const transitioned = options2.contracts.some(
144
+ (contract) => (contract.kind === "transition" || contract.kind === "playthrough") && normalizeList(contract.requireTransition).includes(scene)
145
+ );
146
+ if (!transitioned) issues.push(`\u975E\u5165\u53E3 Scene "${scene}" \u5FC5\u987B\u7531 transition/playthrough contract \u901A\u8FC7\u751F\u4EA7\u8F6C\u573A\u8986\u76D6\u3002`);
147
+ }
148
+ if (entryScene && scenes.length > 1 && (kinds.get("transition") ?? []).length === 0 && (kinds.get("playthrough") ?? []).length === 0 && invalidKindContracts.length === 0) {
149
+ issues.push(`\u4EA4\u4E92\u9879\u76EE\u5165\u53E3 Scene "${entryScene}" \u7F3A\u5C11 transition/playthrough flow\u3002`);
150
+ }
151
+ const restartSignal = projectRoot ? containsProductionRestart(projectRoot) : false;
152
+ if (restartSignal && options2.flows?.restart !== true) {
153
+ issues.push("\u68C0\u6D4B\u5230\u751F\u4EA7 Scene \u4F7F\u7528 scene.restart()\uFF1B\u5FC5\u987B\u58F0\u660E flows.restart: true \u5E76\u63D0\u4F9B kind: recovery contract\u3002");
154
+ } else if (restartSignal && (kinds.get("recovery") ?? []).length === 0) {
155
+ issues.push("\u68C0\u6D4B\u5230\u751F\u4EA7 Scene \u4F7F\u7528 scene.restart()\uFF0C\u4F46\u7F3A\u5C11 kind: recovery contract\u3002");
156
+ } else if ((options2.flows?.restart || options2.flows?.reset) && (kinds.get("recovery") ?? []).length === 0) {
157
+ issues.push("\u9879\u76EE\u58F0\u660E\u6216\u5B9E\u73B0\u4E86 restart/reset \u6D41\u7A0B\uFF0C\u4F46\u7F3A\u5C11 kind: recovery contract\u3002");
158
+ }
159
+ }
89
160
  return issues;
90
161
  }
162
+ function containsProductionRestart(projectRoot) {
163
+ const sceneRoot = join(projectRoot, "src", "scenes");
164
+ const visit = (directory) => {
165
+ let entries;
166
+ try {
167
+ entries = readdirSync(directory);
168
+ } catch {
169
+ return false;
170
+ }
171
+ for (const entry of entries) {
172
+ const path = join(directory, entry);
173
+ let stats;
174
+ try {
175
+ stats = statSync(path);
176
+ } catch {
177
+ continue;
178
+ }
179
+ if (stats.isDirectory() && visit(path)) return true;
180
+ if (!stats.isFile() || !/\.(ts|tsx)$/.test(entry)) continue;
181
+ try {
182
+ const source = readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
183
+ if (/\bscene\s*\.\s*restart\s*\(/.test(source)) return true;
184
+ } catch {
185
+ }
186
+ }
187
+ return false;
188
+ };
189
+ return visit(sceneRoot);
190
+ }
91
191
  function getDefinitionIssues(expected, actual) {
92
192
  const issues = [];
93
193
  const expectedMetadata = createGameplayContractMetadata(expected);
194
+ if (expectedMetadata.kind !== actual.kind) {
195
+ issues.push(`contract "${expected.id}" \u7684 kind \u4E0E gameplayAudit \u6E05\u5355\u4E0D\u4E00\u81F4\u3002`);
196
+ }
94
197
  if (!sameSet(expectedMetadata.scenes, actual.scenes)) {
95
198
  issues.push(
96
199
  `contract "${expected.id}" \u58F0\u660E\u7684 Scene \u4E0E gameplayAudit \u6E05\u5355\u4E0D\u4E00\u81F4\u3002`
@@ -145,12 +248,15 @@ function auditGameplayCollection(options2, file, tests) {
145
248
  function getGameplayEvidenceIssues(id, requirements, evidence) {
146
249
  const issues = [];
147
250
  const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
148
- if (requirements.requireInput && inputEvents === 0)
251
+ if (requirements.requireInput && inputEvents === 0) {
149
252
  issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165\u3002`);
150
- if (requirements.requireFrameAdvance && evidence.frames === 0)
253
+ }
254
+ if (requirements.requireFrameAdvance && evidence.frames === 0) {
151
255
  issues.push(`contract "${id}" \u672A\u63A8\u8FDB\u5B8C\u6574 Phaser \u5E27\u3002`);
152
- if (requirements.requirePhysicsStep && evidence.physicsSteps === 0)
256
+ }
257
+ if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
153
258
  issues.push(`contract "${id}" \u672A\u63A8\u8FDB Arcade Physics\u3002`);
259
+ }
154
260
  for (const target of normalizeList(requirements.requireTransition)) {
155
261
  if (!evidence.transitions.some((transition) => transition.to === target)) {
156
262
  issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u5230 ${target} \u7684 Scene \u8F6C\u573A\u3002`);
@@ -161,21 +267,24 @@ function getGameplayEvidenceIssues(id, requirements, evidence) {
161
267
  issues.push(`contract "${id}" \u672A\u8BBF\u95EE Scene "${scene}"\u3002`);
162
268
  }
163
269
  for (const scene of normalizeList(requirements.requireRestart)) {
164
- if (!evidence.restartedScenes.includes(scene))
270
+ if (!evidence.restartedScenes.includes(scene)) {
165
271
  issues.push(`contract "${id}" \u672A\u91CD\u542F Scene "${scene}"\u3002`);
272
+ }
166
273
  }
167
274
  for (const checkpoint of normalizeList(requirements.requireCheckpoint)) {
168
- if (!evidence.checkpoints.includes(checkpoint))
275
+ if (!evidence.checkpoints.includes(checkpoint)) {
169
276
  issues.push(`contract "${id}" \u7F3A\u5C11 checkpoint "${checkpoint}"\u3002`);
277
+ }
170
278
  }
171
- if (requirements.requireDestroy && !evidence.destroyed)
279
+ if (requirements.requireDestroy && !evidence.destroyed) {
172
280
  issues.push(`contract "${id}" \u672A\u5B8C\u6210 HEADLESS host \u9500\u6BC1\u3002`);
281
+ }
173
282
  return issues;
174
283
  }
175
284
  function auditGameplayRun(options2, tests) {
176
285
  const issues = [];
177
286
  const actualContracts = tests.filter((test) => test.metadata);
178
- const acceptedEvidence = [];
287
+ const observedEvidence = [];
179
288
  for (const expected of options2.contracts) {
180
289
  const matches = actualContracts.filter(
181
290
  (test2) => test2.metadata?.id === expected.id
@@ -191,6 +300,7 @@ function auditGameplayRun(options2, tests) {
191
300
  const test = matches[0];
192
301
  const metadata = test?.metadata;
193
302
  if (!test || !metadata) continue;
303
+ if (metadata.evidence) observedEvidence.push(metadata.evidence);
194
304
  if (test.file.replaceAll("\\", "/") !== expected.testFile.replaceAll("\\", "/")) {
195
305
  issues.push(
196
306
  `contract "${expected.id}" \u5FC5\u987B\u4F4D\u4E8E ${expected.testFile}\uFF0C\u5B9E\u9645\u4F4D\u4E8E ${test.file}\u3002`
@@ -211,7 +321,6 @@ function auditGameplayRun(options2, tests) {
211
321
  metadata.evidence
212
322
  );
213
323
  issues.push(...evidenceIssues);
214
- if (evidenceIssues.length === 0) acceptedEvidence.push(metadata.evidence);
215
324
  }
216
325
  for (const test of actualContracts) {
217
326
  const id = test.metadata?.id;
@@ -222,14 +331,15 @@ function auditGameplayRun(options2, tests) {
222
331
  }
223
332
  }
224
333
  const registeredScenes = normalizeSet(
225
- acceptedEvidence.flatMap((evidence) => evidence.registeredScenes)
334
+ observedEvidence.flatMap((evidence) => evidence.registeredScenes)
226
335
  );
227
336
  const expectedScenes = normalizeSet(options2.scenes);
228
337
  for (const scene of expectedScenes) {
229
- if (!registeredScenes.includes(scene))
338
+ if (!registeredScenes.includes(scene)) {
230
339
  issues.push(
231
- `\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`
232
341
  );
342
+ }
233
343
  }
234
344
  for (const scene of registeredScenes) {
235
345
  if (!expectedScenes.includes(scene))
@@ -240,12 +350,89 @@ function auditGameplayRun(options2, tests) {
240
350
  return { passed: issues.length === 0, issues: [...new Set(issues)] };
241
351
  }
242
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
+
243
428
  // src/lint/gameplay-audit.test.ts
244
429
  var options = {
430
+ mode: "interactive",
245
431
  scenes: ["IndexScene", "BattleScene"],
246
432
  contracts: [
247
433
  {
248
434
  id: "gameplay.main-flow",
435
+ kind: "playthrough",
249
436
  testFile: "tests/gameplay.test.ts",
250
437
  scenes: ["IndexScene", "BattleScene"],
251
438
  requireInput: true,
@@ -284,6 +471,123 @@ function createPassingTest() {
284
471
  };
285
472
  }
286
473
  describe("gameplay audit", () => {
474
+ it("\u62D2\u7EDD\u4EA4\u4E92\u9879\u76EE\u53EA\u6709 boot contract", () => {
475
+ const result = validateGameplayAuditOptions({
476
+ mode: "interactive",
477
+ scenes: ["IndexScene"],
478
+ contracts: [
479
+ {
480
+ id: "IndexScene.boot",
481
+ kind: "boot",
482
+ testFile: "tests/gameplay.test.ts",
483
+ scenes: ["IndexScene"]
484
+ }
485
+ ]
486
+ });
487
+ expect(result).toContain(
488
+ '\u4EA4\u4E92\u9879\u76EE\u7F3A\u5C11\u6700\u4F4E\u73A9\u6CD5\u57FA\u7EBF\uFF1A\u81F3\u5C11\u4E00\u4E2A kind: "playthrough" contract \u5FC5\u987B\u540C\u65F6\u58F0\u660E requireInput: true\u3001requireFrameAdvance: true \u548C requireCheckpoint: ["progress"]\u3002'
489
+ );
490
+ });
491
+ it("\u5141\u8BB8\u5E26\u7406\u7531\u7684\u975E\u4EA4\u4E92 boot-only \u9879\u76EE", () => {
492
+ expect(
493
+ validateGameplayAuditOptions({
494
+ mode: "non-interactive",
495
+ nonInteractiveJustification: "\u8FD9\u662F\u53EA\u5C55\u793A\u9759\u6001\u6B22\u8FCE\u753B\u9762\u7684\u6A21\u677F\u5939\u5177\uFF0C\u4E0D\u5305\u542B\u73A9\u5BB6\u8F93\u5165\u3002",
496
+ scenes: ["IndexScene"],
497
+ contracts: [
498
+ {
499
+ id: "IndexScene.boot",
500
+ kind: "boot",
501
+ testFile: "tests/gameplay.test.ts",
502
+ scenes: ["IndexScene"]
503
+ }
504
+ ]
505
+ })
506
+ ).toEqual([]);
507
+ });
508
+ it("\u5F3A\u5236 recovery contract \u540C\u65F6\u58F0\u660E restart \u548C\u4E24\u4E2A checkpoint", () => {
509
+ const result = validateGameplayAuditOptions({
510
+ mode: "non-interactive",
511
+ nonInteractiveJustification: "\u6D4B\u8BD5 recovery contract \u7684\u914D\u7F6E\u6821\u9A8C\uFF0C\u4E0D\u4EE3\u8868\u771F\u5B9E\u4EA7\u54C1\u8C41\u514D\u3002",
512
+ scenes: ["BattleScene"],
513
+ contracts: [{
514
+ id: "BattleScene.recovery",
515
+ kind: "recovery",
516
+ testFile: "tests/gameplay.test.ts",
517
+ scenes: ["BattleScene"],
518
+ requireInput: true,
519
+ requireFrameAdvance: true
520
+ }]
521
+ });
522
+ expect(result).toEqual(expect.arrayContaining([
523
+ 'recovery contract "BattleScene.recovery" \u5FC5\u987B\u58F0\u660E requireRestart\u3002',
524
+ 'recovery contract "BattleScene.recovery" \u81F3\u5C11\u9700\u8981\u4E24\u4E2A checkpoint\uFF08\u91CD\u7F6E\u524D\u540E\uFF09\u3002'
525
+ ]));
526
+ });
527
+ it("\u4EA4\u4E92\u9879\u76EE\u8981\u6C42\u6BCF\u4E2A\u975E\u5165\u53E3 Scene \u6709\u751F\u4EA7\u8F6C\u573A\u8BC1\u636E\u58F0\u660E", () => {
528
+ const result = validateGameplayAuditOptions({
529
+ mode: "interactive",
530
+ scenes: ["MenuScene", "BattleScene"],
531
+ contracts: [{
532
+ id: "MenuScene.playthrough",
533
+ kind: "playthrough",
534
+ testFile: "tests/gameplay.test.ts",
535
+ scenes: ["MenuScene"],
536
+ requireInput: true,
537
+ requireFrameAdvance: true,
538
+ requireCheckpoint: "progress"
539
+ }]
540
+ });
541
+ expect(result).toContain('\u975E\u5165\u53E3 Scene "BattleScene" \u5FC5\u987B\u7531 transition/playthrough contract \u901A\u8FC7\u751F\u4EA7\u8F6C\u573A\u8986\u76D6\u3002');
542
+ });
543
+ it("\u4E0D\u63A5\u53D7 boot contract \u4F2A\u88C5\u6210\u751F\u4EA7\u8F6C\u573A", () => {
544
+ const result = validateGameplayAuditOptions({
545
+ mode: "interactive",
546
+ scenes: ["MenuScene", "BattleScene"],
547
+ contracts: [
548
+ {
549
+ id: "MenuScene.playthrough",
550
+ kind: "playthrough",
551
+ testFile: "tests/gameplay.test.ts",
552
+ scenes: ["MenuScene"],
553
+ requireInput: true,
554
+ requireFrameAdvance: true,
555
+ requireCheckpoint: "progress"
556
+ },
557
+ {
558
+ id: "BattleScene.boot",
559
+ kind: "boot",
560
+ testFile: "tests/gameplay.test.ts",
561
+ scenes: ["BattleScene"],
562
+ requireTransition: "BattleScene"
563
+ }
564
+ ]
565
+ });
566
+ expect(result).toContain(
567
+ '\u975E\u5165\u53E3 Scene "BattleScene" \u5FC5\u987B\u7531 transition/playthrough contract \u901A\u8FC7\u751F\u4EA7\u8F6C\u573A\u8986\u76D6\u3002'
568
+ );
569
+ });
570
+ it("\u58F0\u660E restart flow \u540E\u4ECD\u5F3A\u5236 recovery contract", () => {
571
+ const result = validateGameplayAuditOptions({
572
+ mode: "interactive",
573
+ flows: { restart: true },
574
+ scenes: ["IndexScene"],
575
+ contracts: [
576
+ {
577
+ id: "IndexScene.playthrough",
578
+ kind: "playthrough",
579
+ testFile: "tests/gameplay.test.ts",
580
+ scenes: ["IndexScene"],
581
+ requireInput: true,
582
+ requireFrameAdvance: true,
583
+ requireCheckpoint: "progress"
584
+ }
585
+ ]
586
+ });
587
+ expect(result).toContain(
588
+ "\u9879\u76EE\u58F0\u660E\u6216\u5B9E\u73B0\u4E86 restart/reset \u6D41\u7A0B\uFF0C\u4F46\u7F3A\u5C11 kind: recovery contract\u3002"
589
+ );
590
+ });
287
591
  it("\u62D2\u7EDD\u672A\u88AB contract \u8986\u76D6\u7684\u751F\u4EA7 Scene", () => {
288
592
  expect(
289
593
  validateGameplayAuditOptions({
@@ -299,6 +603,7 @@ describe("gameplay audit", () => {
299
603
  contracts: [
300
604
  {
301
605
  id: " IndexScene.boot ",
606
+ kind: "boot",
302
607
  testFile: "tests/gameplay.test.ts",
303
608
  scenes: ["IndexScene"]
304
609
  }
@@ -312,6 +617,7 @@ describe("gameplay audit", () => {
312
617
  contracts: [
313
618
  {
314
619
  id: "IndexScene.boot",
620
+ kind: "boot",
315
621
  testFile,
316
622
  scenes: ["IndexScene"]
317
623
  }
@@ -378,6 +684,7 @@ describe("gameplay audit", () => {
378
684
  evidence.destroyed = false;
379
685
  evidence.registeredScenes.push("DebugScene");
380
686
  const test = createPassingTest();
687
+ if (!test.metadata) throw new Error("expected gameplay metadata");
381
688
  test.metadata.evidence = evidence;
382
689
  const result = auditGameplayRun(options, [test]);
383
690
  expect(result.passed).toBe(false);
@@ -389,8 +696,24 @@ describe("gameplay audit", () => {
389
696
  ])
390
697
  );
391
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
+ });
392
714
  it("\u62D2\u7EDD\u901A\u8FC7\u5951\u7EA6\u7684 host registry \u4E2D\u51FA\u73B0\u6E05\u5355\u5916 Scene", () => {
393
715
  const test = createPassingTest();
716
+ if (!test.metadata?.evidence) throw new Error("expected gameplay evidence");
394
717
  test.metadata.evidence.registeredScenes.push("DebugScene");
395
718
  expect(auditGameplayRun(options, [test]).issues).toContain(
396
719
  'host registry \u51FA\u73B0\u672A\u5217\u5165 gameplayAudit.scenes \u7684\u751F\u4EA7 Scene "DebugScene"\u3002'
@@ -423,4 +746,28 @@ describe("gameplay audit", () => {
423
746
  )
424
747
  ).toContain('contract "arcade-physics" \u672A\u63A8\u8FDB Arcade Physics\u3002');
425
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
+ });
426
773
  });
@@ -1051,6 +1051,8 @@ async function createHeadlessGame(scene, options = {}) {
1051
1051
  import { test } from "vitest";
1052
1052
 
1053
1053
  // src/gameplay-audit.ts
1054
+ import { readdirSync, readFileSync, statSync } from "fs";
1055
+ import { join } from "path";
1054
1056
  function normalizeList(value) {
1055
1057
  if (value === void 0) return [];
1056
1058
  const values = typeof value === "string" ? [value] : value;
@@ -1081,6 +1083,7 @@ function createGameplayContractMetadata(contract) {
1081
1083
  const scenes = normalizeSet(contract.scenes);
1082
1084
  return {
1083
1085
  id: contract.id.trim(),
1086
+ kind: contract.kind,
1084
1087
  scenes,
1085
1088
  requirements: normalizeGameplayRequirements(contract, scenes),
1086
1089
  verified: false
@@ -1185,6 +1188,7 @@ describe("gameplay contract lifecycle integration", () => {
1185
1188
  const { callbacks, context } = createTestContext();
1186
1189
  const contract = registerGameplayContract(context, {
1187
1190
  id: "InputScene.hit",
1191
+ kind: "playthrough",
1188
1192
  requireInput: true,
1189
1193
  requireFrameAdvance: true
1190
1194
  });
@@ -1213,6 +1217,7 @@ describe("gameplay contract lifecycle integration", () => {
1213
1217
  const { callbacks, context } = createTestContext();
1214
1218
  const contract = registerGameplayContract(context, {
1215
1219
  id: "InputScene.missing-input",
1220
+ kind: "playthrough",
1216
1221
  requireInput: true
1217
1222
  });
1218
1223
  const host = await createHeadlessGame(new InputScene());