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.
- package/README.md +14 -5
- package/dist/{gameplay-audit-CYqLL8gY.d.mts → gameplay-audit-XBGq_jIV.d.mts} +13 -0
- package/dist/{gameplay-audit-CYqLL8gY.d.ts → gameplay-audit-XBGq_jIV.d.ts} +13 -0
- package/dist/index.d.mts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -0
- package/dist/index.mjs +3 -0
- package/dist/lint/gameplay-audit.contract.mjs +360 -13
- package/dist/lint/gameplay-contract.contract.mjs +5 -0
- package/dist/lint/vitest-config.contract.mjs +245 -22
- package/dist/vitest-config.d.mts +1 -1
- package/dist/vitest-config.d.ts +1 -1
- package/dist/vitest-config.js +232 -24
- package/dist/vitest-config.mjs +228 -20
- package/package.json +1 -1
|
@@ -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 =
|
|
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
|
-
|
|
253
|
+
}
|
|
254
|
+
if (requirements.requireFrameAdvance && evidence.frames === 0) {
|
|
151
255
|
issues.push(`contract "${id}" \u672A\u63A8\u8FDB\u5B8C\u6574 Phaser \u5E27\u3002`);
|
|
152
|
-
|
|
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
|
|
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
|
-
|
|
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\
|
|
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());
|