miaoda-game-devkit 0.2.1 → 0.2.3
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/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 +226 -2
- package/dist/lint/gameplay-contract.contract.mjs +3 -0
- package/dist/lint/vitest-config.contract.mjs +136 -18
- package/dist/vitest-config.d.mts +4 -2
- package/dist/vitest-config.d.ts +4 -2
- package/dist/vitest-config.js +117 -17
- package/dist/vitest-config.mjs +115 -15
- package/package.json +28 -1
|
@@ -162,6 +162,8 @@ declare function createHeadlessGame<TScene extends Phaser.Scene>(scene: TScene,
|
|
|
162
162
|
|
|
163
163
|
/** 一个必须由指定测试文件证明的玩法契约。 */
|
|
164
164
|
interface GameplayAuditContract extends HeadlessGameplayEvidenceRequirements {
|
|
165
|
+
/** 契约用途;boot 不得替代交互游戏的 playthrough。 */
|
|
166
|
+
kind: "boot" | "playthrough" | "transition" | "recovery";
|
|
165
167
|
/** 在整个游戏项目中唯一且稳定的契约标识。 */
|
|
166
168
|
id: string;
|
|
167
169
|
/** 相对于游戏项目根目录的 Vitest 测试文件。 */
|
|
@@ -171,6 +173,15 @@ interface GameplayAuditContract extends HeadlessGameplayEvidenceRequirements {
|
|
|
171
173
|
}
|
|
172
174
|
/** 游戏项目在一次 Vitest 运行中必须完成的玩法审计清单。 */
|
|
173
175
|
interface GameplayAuditOptions {
|
|
176
|
+
/** 默认按交互游戏审计;纯演示项目必须显式豁免并说明原因。 */
|
|
177
|
+
mode?: "interactive" | "non-interactive";
|
|
178
|
+
/** non-interactive 模式的可审计豁免理由。 */
|
|
179
|
+
nonInteractiveJustification?: string;
|
|
180
|
+
/** 应用声明的可恢复流程;restart 会触发 recovery contract 基线。 */
|
|
181
|
+
flows?: {
|
|
182
|
+
restart?: boolean;
|
|
183
|
+
reset?: boolean;
|
|
184
|
+
};
|
|
174
185
|
/** 生产 Scene registry 中预期出现的全部 Scene key。 */
|
|
175
186
|
scenes: string[];
|
|
176
187
|
/** 必须定义、执行并满足证据要求的玩法契约。 */
|
|
@@ -178,6 +189,8 @@ interface GameplayAuditOptions {
|
|
|
178
189
|
}
|
|
179
190
|
/** 通过 Vitest task metadata 在 worker 与 reporter 之间传递的契约数据。 */
|
|
180
191
|
interface GameplayContractMetadata {
|
|
192
|
+
/** 契约用途。 */
|
|
193
|
+
kind: GameplayAuditContract["kind"];
|
|
181
194
|
/** 契约唯一标识。 */
|
|
182
195
|
id: string;
|
|
183
196
|
/** 契约负责证明的生产 Scene key。 */
|
|
@@ -162,6 +162,8 @@ declare function createHeadlessGame<TScene extends Phaser.Scene>(scene: TScene,
|
|
|
162
162
|
|
|
163
163
|
/** 一个必须由指定测试文件证明的玩法契约。 */
|
|
164
164
|
interface GameplayAuditContract extends HeadlessGameplayEvidenceRequirements {
|
|
165
|
+
/** 契约用途;boot 不得替代交互游戏的 playthrough。 */
|
|
166
|
+
kind: "boot" | "playthrough" | "transition" | "recovery";
|
|
165
167
|
/** 在整个游戏项目中唯一且稳定的契约标识。 */
|
|
166
168
|
id: string;
|
|
167
169
|
/** 相对于游戏项目根目录的 Vitest 测试文件。 */
|
|
@@ -171,6 +173,15 @@ interface GameplayAuditContract extends HeadlessGameplayEvidenceRequirements {
|
|
|
171
173
|
}
|
|
172
174
|
/** 游戏项目在一次 Vitest 运行中必须完成的玩法审计清单。 */
|
|
173
175
|
interface GameplayAuditOptions {
|
|
176
|
+
/** 默认按交互游戏审计;纯演示项目必须显式豁免并说明原因。 */
|
|
177
|
+
mode?: "interactive" | "non-interactive";
|
|
178
|
+
/** non-interactive 模式的可审计豁免理由。 */
|
|
179
|
+
nonInteractiveJustification?: string;
|
|
180
|
+
/** 应用声明的可恢复流程;restart 会触发 recovery contract 基线。 */
|
|
181
|
+
flows?: {
|
|
182
|
+
restart?: boolean;
|
|
183
|
+
reset?: boolean;
|
|
184
|
+
};
|
|
174
185
|
/** 生产 Scene registry 中预期出现的全部 Scene key。 */
|
|
175
186
|
scenes: string[];
|
|
176
187
|
/** 必须定义、执行并满足证据要求的玩法契约。 */
|
|
@@ -178,6 +189,8 @@ interface GameplayAuditOptions {
|
|
|
178
189
|
}
|
|
179
190
|
/** 通过 Vitest task metadata 在 worker 与 reporter 之间传递的契约数据。 */
|
|
180
191
|
interface GameplayContractMetadata {
|
|
192
|
+
/** 契约用途。 */
|
|
193
|
+
kind: GameplayAuditContract["kind"];
|
|
181
194
|
/** 契约唯一标识。 */
|
|
182
195
|
id: string;
|
|
183
196
|
/** 契约负责证明的生产 Scene key。 */
|
package/dist/index.d.mts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { G as GameplayContractMetadata, H as HeadlessGameHost, a as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-
|
|
2
|
-
export { b as GameplayAuditContract, c as GameplayAuditOptions, d as HeadlessCanvasBounds, e as HeadlessGameOptions, f as HeadlessGameplayEvidence, g as HeadlessInputDriver, h as HeadlessInputPoint, i as HeadlessKeyboardEventOptions, j as HeadlessKeyboardInput, k as HeadlessMouseInput, l as HeadlessTouchInput, m as createHeadlessGame } from './gameplay-audit-
|
|
1
|
+
import { G as GameplayContractMetadata, H as HeadlessGameHost, a as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-XBGq_jIV.mjs';
|
|
2
|
+
export { b as GameplayAuditContract, c as GameplayAuditOptions, d as HeadlessCanvasBounds, e as HeadlessGameOptions, f as HeadlessGameplayEvidence, g as HeadlessInputDriver, h as HeadlessInputPoint, i as HeadlessKeyboardEventOptions, j as HeadlessKeyboardInput, k as HeadlessMouseInput, l as HeadlessTouchInput, m as createHeadlessGame } from './gameplay-audit-XBGq_jIV.mjs';
|
|
3
3
|
import * as Phaser from 'phaser';
|
|
4
4
|
import { TestContext, TestOptions } from 'vitest';
|
|
5
5
|
|
|
6
6
|
/** 将单个 Vitest 测试绑定到玩法 Ledger 的契约选项。 */
|
|
7
7
|
interface GameplayContractOptions extends HeadlessGameplayEvidenceRequirements {
|
|
8
|
+
/** 契约用途;交互项目不能只使用 boot。 */
|
|
9
|
+
kind: "boot" | "playthrough" | "transition" | "recovery";
|
|
8
10
|
/** 生产玩法清单中稳定且唯一的 contract 标识。 */
|
|
9
11
|
id: string;
|
|
10
12
|
/** 当前契约负责证明的生产 Scene key。 */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { G as GameplayContractMetadata, H as HeadlessGameHost, a as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-
|
|
2
|
-
export { b as GameplayAuditContract, c as GameplayAuditOptions, d as HeadlessCanvasBounds, e as HeadlessGameOptions, f as HeadlessGameplayEvidence, g as HeadlessInputDriver, h as HeadlessInputPoint, i as HeadlessKeyboardEventOptions, j as HeadlessKeyboardInput, k as HeadlessMouseInput, l as HeadlessTouchInput, m as createHeadlessGame } from './gameplay-audit-
|
|
1
|
+
import { G as GameplayContractMetadata, H as HeadlessGameHost, a as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-XBGq_jIV.js';
|
|
2
|
+
export { b as GameplayAuditContract, c as GameplayAuditOptions, d as HeadlessCanvasBounds, e as HeadlessGameOptions, f as HeadlessGameplayEvidence, g as HeadlessInputDriver, h as HeadlessInputPoint, i as HeadlessKeyboardEventOptions, j as HeadlessKeyboardInput, k as HeadlessMouseInput, l as HeadlessTouchInput, m as createHeadlessGame } from './gameplay-audit-XBGq_jIV.js';
|
|
3
3
|
import * as Phaser from 'phaser';
|
|
4
4
|
import { TestContext, TestOptions } from 'vitest';
|
|
5
5
|
|
|
6
6
|
/** 将单个 Vitest 测试绑定到玩法 Ledger 的契约选项。 */
|
|
7
7
|
interface GameplayContractOptions extends HeadlessGameplayEvidenceRequirements {
|
|
8
|
+
/** 契约用途;交互项目不能只使用 boot。 */
|
|
9
|
+
kind: "boot" | "playthrough" | "transition" | "recovery";
|
|
8
10
|
/** 生产玩法清单中稳定且唯一的 contract 标识。 */
|
|
9
11
|
id: string;
|
|
10
12
|
/** 当前契约负责证明的生产 Scene key。 */
|
package/dist/index.js
CHANGED
|
@@ -1089,6 +1089,8 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
1089
1089
|
var import_vitest = require("vitest");
|
|
1090
1090
|
|
|
1091
1091
|
// src/gameplay-audit.ts
|
|
1092
|
+
var import_node_fs = require("fs");
|
|
1093
|
+
var import_node_path = require("path");
|
|
1092
1094
|
function normalizeList(value) {
|
|
1093
1095
|
if (value === void 0) return [];
|
|
1094
1096
|
const values = typeof value === "string" ? [value] : value;
|
|
@@ -1119,6 +1121,7 @@ function createGameplayContractMetadata(contract) {
|
|
|
1119
1121
|
const scenes = normalizeSet(contract.scenes);
|
|
1120
1122
|
return {
|
|
1121
1123
|
id: contract.id.trim(),
|
|
1124
|
+
kind: contract.kind,
|
|
1122
1125
|
scenes,
|
|
1123
1126
|
requirements: normalizeGameplayRequirements(contract, scenes),
|
|
1124
1127
|
verified: false
|
package/dist/index.mjs
CHANGED
|
@@ -1047,6 +1047,8 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
1047
1047
|
import { test } from "vitest";
|
|
1048
1048
|
|
|
1049
1049
|
// src/gameplay-audit.ts
|
|
1050
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
1051
|
+
import { join } from "path";
|
|
1050
1052
|
function normalizeList(value) {
|
|
1051
1053
|
if (value === void 0) return [];
|
|
1052
1054
|
const values = typeof value === "string" ? [value] : value;
|
|
@@ -1077,6 +1079,7 @@ function createGameplayContractMetadata(contract) {
|
|
|
1077
1079
|
const scenes = normalizeSet(contract.scenes);
|
|
1078
1080
|
return {
|
|
1079
1081
|
id: contract.id.trim(),
|
|
1082
|
+
kind: contract.kind,
|
|
1080
1083
|
scenes,
|
|
1081
1084
|
requirements: normalizeGameplayRequirements(contract, scenes),
|
|
1082
1085
|
verified: false
|
|
@@ -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`
|
|
@@ -242,10 +345,12 @@ function auditGameplayRun(options2, tests) {
|
|
|
242
345
|
|
|
243
346
|
// src/lint/gameplay-audit.test.ts
|
|
244
347
|
var options = {
|
|
348
|
+
mode: "interactive",
|
|
245
349
|
scenes: ["IndexScene", "BattleScene"],
|
|
246
350
|
contracts: [
|
|
247
351
|
{
|
|
248
352
|
id: "gameplay.main-flow",
|
|
353
|
+
kind: "playthrough",
|
|
249
354
|
testFile: "tests/gameplay.test.ts",
|
|
250
355
|
scenes: ["IndexScene", "BattleScene"],
|
|
251
356
|
requireInput: true,
|
|
@@ -284,6 +389,123 @@ function createPassingTest() {
|
|
|
284
389
|
};
|
|
285
390
|
}
|
|
286
391
|
describe("gameplay audit", () => {
|
|
392
|
+
it("\u62D2\u7EDD\u4EA4\u4E92\u9879\u76EE\u53EA\u6709 boot contract", () => {
|
|
393
|
+
const result = validateGameplayAuditOptions({
|
|
394
|
+
mode: "interactive",
|
|
395
|
+
scenes: ["IndexScene"],
|
|
396
|
+
contracts: [
|
|
397
|
+
{
|
|
398
|
+
id: "IndexScene.boot",
|
|
399
|
+
kind: "boot",
|
|
400
|
+
testFile: "tests/gameplay.test.ts",
|
|
401
|
+
scenes: ["IndexScene"]
|
|
402
|
+
}
|
|
403
|
+
]
|
|
404
|
+
});
|
|
405
|
+
expect(result).toContain(
|
|
406
|
+
'\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'
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
it("\u5141\u8BB8\u5E26\u7406\u7531\u7684\u975E\u4EA4\u4E92 boot-only \u9879\u76EE", () => {
|
|
410
|
+
expect(
|
|
411
|
+
validateGameplayAuditOptions({
|
|
412
|
+
mode: "non-interactive",
|
|
413
|
+
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",
|
|
414
|
+
scenes: ["IndexScene"],
|
|
415
|
+
contracts: [
|
|
416
|
+
{
|
|
417
|
+
id: "IndexScene.boot",
|
|
418
|
+
kind: "boot",
|
|
419
|
+
testFile: "tests/gameplay.test.ts",
|
|
420
|
+
scenes: ["IndexScene"]
|
|
421
|
+
}
|
|
422
|
+
]
|
|
423
|
+
})
|
|
424
|
+
).toEqual([]);
|
|
425
|
+
});
|
|
426
|
+
it("\u5F3A\u5236 recovery contract \u540C\u65F6\u58F0\u660E restart \u548C\u4E24\u4E2A checkpoint", () => {
|
|
427
|
+
const result = validateGameplayAuditOptions({
|
|
428
|
+
mode: "non-interactive",
|
|
429
|
+
nonInteractiveJustification: "\u6D4B\u8BD5 recovery contract \u7684\u914D\u7F6E\u6821\u9A8C\uFF0C\u4E0D\u4EE3\u8868\u771F\u5B9E\u4EA7\u54C1\u8C41\u514D\u3002",
|
|
430
|
+
scenes: ["BattleScene"],
|
|
431
|
+
contracts: [{
|
|
432
|
+
id: "BattleScene.recovery",
|
|
433
|
+
kind: "recovery",
|
|
434
|
+
testFile: "tests/gameplay.test.ts",
|
|
435
|
+
scenes: ["BattleScene"],
|
|
436
|
+
requireInput: true,
|
|
437
|
+
requireFrameAdvance: true
|
|
438
|
+
}]
|
|
439
|
+
});
|
|
440
|
+
expect(result).toEqual(expect.arrayContaining([
|
|
441
|
+
'recovery contract "BattleScene.recovery" \u5FC5\u987B\u58F0\u660E requireRestart\u3002',
|
|
442
|
+
'recovery contract "BattleScene.recovery" \u81F3\u5C11\u9700\u8981\u4E24\u4E2A checkpoint\uFF08\u91CD\u7F6E\u524D\u540E\uFF09\u3002'
|
|
443
|
+
]));
|
|
444
|
+
});
|
|
445
|
+
it("\u4EA4\u4E92\u9879\u76EE\u8981\u6C42\u6BCF\u4E2A\u975E\u5165\u53E3 Scene \u6709\u751F\u4EA7\u8F6C\u573A\u8BC1\u636E\u58F0\u660E", () => {
|
|
446
|
+
const result = validateGameplayAuditOptions({
|
|
447
|
+
mode: "interactive",
|
|
448
|
+
scenes: ["MenuScene", "BattleScene"],
|
|
449
|
+
contracts: [{
|
|
450
|
+
id: "MenuScene.playthrough",
|
|
451
|
+
kind: "playthrough",
|
|
452
|
+
testFile: "tests/gameplay.test.ts",
|
|
453
|
+
scenes: ["MenuScene"],
|
|
454
|
+
requireInput: true,
|
|
455
|
+
requireFrameAdvance: true,
|
|
456
|
+
requireCheckpoint: "progress"
|
|
457
|
+
}]
|
|
458
|
+
});
|
|
459
|
+
expect(result).toContain('\u975E\u5165\u53E3 Scene "BattleScene" \u5FC5\u987B\u7531 transition/playthrough contract \u901A\u8FC7\u751F\u4EA7\u8F6C\u573A\u8986\u76D6\u3002');
|
|
460
|
+
});
|
|
461
|
+
it("\u4E0D\u63A5\u53D7 boot contract \u4F2A\u88C5\u6210\u751F\u4EA7\u8F6C\u573A", () => {
|
|
462
|
+
const result = validateGameplayAuditOptions({
|
|
463
|
+
mode: "interactive",
|
|
464
|
+
scenes: ["MenuScene", "BattleScene"],
|
|
465
|
+
contracts: [
|
|
466
|
+
{
|
|
467
|
+
id: "MenuScene.playthrough",
|
|
468
|
+
kind: "playthrough",
|
|
469
|
+
testFile: "tests/gameplay.test.ts",
|
|
470
|
+
scenes: ["MenuScene"],
|
|
471
|
+
requireInput: true,
|
|
472
|
+
requireFrameAdvance: true,
|
|
473
|
+
requireCheckpoint: "progress"
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
id: "BattleScene.boot",
|
|
477
|
+
kind: "boot",
|
|
478
|
+
testFile: "tests/gameplay.test.ts",
|
|
479
|
+
scenes: ["BattleScene"],
|
|
480
|
+
requireTransition: "BattleScene"
|
|
481
|
+
}
|
|
482
|
+
]
|
|
483
|
+
});
|
|
484
|
+
expect(result).toContain(
|
|
485
|
+
'\u975E\u5165\u53E3 Scene "BattleScene" \u5FC5\u987B\u7531 transition/playthrough contract \u901A\u8FC7\u751F\u4EA7\u8F6C\u573A\u8986\u76D6\u3002'
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
it("\u58F0\u660E restart flow \u540E\u4ECD\u5F3A\u5236 recovery contract", () => {
|
|
489
|
+
const result = validateGameplayAuditOptions({
|
|
490
|
+
mode: "interactive",
|
|
491
|
+
flows: { restart: true },
|
|
492
|
+
scenes: ["IndexScene"],
|
|
493
|
+
contracts: [
|
|
494
|
+
{
|
|
495
|
+
id: "IndexScene.playthrough",
|
|
496
|
+
kind: "playthrough",
|
|
497
|
+
testFile: "tests/gameplay.test.ts",
|
|
498
|
+
scenes: ["IndexScene"],
|
|
499
|
+
requireInput: true,
|
|
500
|
+
requireFrameAdvance: true,
|
|
501
|
+
requireCheckpoint: "progress"
|
|
502
|
+
}
|
|
503
|
+
]
|
|
504
|
+
});
|
|
505
|
+
expect(result).toContain(
|
|
506
|
+
"\u9879\u76EE\u58F0\u660E\u6216\u5B9E\u73B0\u4E86 restart/reset \u6D41\u7A0B\uFF0C\u4F46\u7F3A\u5C11 kind: recovery contract\u3002"
|
|
507
|
+
);
|
|
508
|
+
});
|
|
287
509
|
it("\u62D2\u7EDD\u672A\u88AB contract \u8986\u76D6\u7684\u751F\u4EA7 Scene", () => {
|
|
288
510
|
expect(
|
|
289
511
|
validateGameplayAuditOptions({
|
|
@@ -299,6 +521,7 @@ describe("gameplay audit", () => {
|
|
|
299
521
|
contracts: [
|
|
300
522
|
{
|
|
301
523
|
id: " IndexScene.boot ",
|
|
524
|
+
kind: "boot",
|
|
302
525
|
testFile: "tests/gameplay.test.ts",
|
|
303
526
|
scenes: ["IndexScene"]
|
|
304
527
|
}
|
|
@@ -312,6 +535,7 @@ describe("gameplay audit", () => {
|
|
|
312
535
|
contracts: [
|
|
313
536
|
{
|
|
314
537
|
id: "IndexScene.boot",
|
|
538
|
+
kind: "boot",
|
|
315
539
|
testFile,
|
|
316
540
|
scenes: ["IndexScene"]
|
|
317
541
|
}
|
|
@@ -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
|
|
@@ -3,11 +3,12 @@ import { resolve as resolve2 } from "path";
|
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
5
|
// src/vitest-config.ts
|
|
6
|
-
import {
|
|
7
|
-
import { dirname, resolve } from "path";
|
|
6
|
+
import { resolve } from "path";
|
|
8
7
|
import { defineConfig } from "vitest/config";
|
|
9
8
|
|
|
10
9
|
// src/gameplay-audit.ts
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
11
12
|
function normalizeList(value) {
|
|
12
13
|
if (value === void 0) return [];
|
|
13
14
|
const values = typeof value === "string" ? [value] : value;
|
|
@@ -41,20 +42,35 @@ function createGameplayContractMetadata(contract) {
|
|
|
41
42
|
const scenes = normalizeSet(contract.scenes);
|
|
42
43
|
return {
|
|
43
44
|
id: contract.id.trim(),
|
|
45
|
+
kind: contract.kind,
|
|
44
46
|
scenes,
|
|
45
47
|
requirements: normalizeGameplayRequirements(contract, scenes),
|
|
46
48
|
verified: false
|
|
47
49
|
};
|
|
48
50
|
}
|
|
49
|
-
function validateGameplayAuditOptions(options) {
|
|
51
|
+
function validateGameplayAuditOptions(options, projectRoot2) {
|
|
50
52
|
const issues = [];
|
|
51
|
-
const scenes =
|
|
53
|
+
const scenes = [...new Set(options.scenes.map((scene) => scene.trim()).filter(Boolean))];
|
|
54
|
+
const mode = options.mode ?? "interactive";
|
|
55
|
+
if (mode === "non-interactive") {
|
|
56
|
+
const justification = options.nonInteractiveJustification?.trim() ?? "";
|
|
57
|
+
if (justification.length < 20) {
|
|
58
|
+
issues.push(
|
|
59
|
+
"non-interactive \u6A21\u5F0F\u5FC5\u987B\u63D0\u4F9B\u81F3\u5C11 20 \u4E2A\u5B57\u7B26\u7684 nonInteractiveJustification\u3002"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (mode !== "interactive" && mode !== "non-interactive") {
|
|
64
|
+
issues.push("gameplayAudit.mode \u5FC5\u987B\u662F interactive \u6216 non-interactive\u3002");
|
|
65
|
+
}
|
|
52
66
|
if (scenes.length === 0) issues.push("gameplayAudit.scenes \u4E0D\u80FD\u4E3A\u7A7A\u3002");
|
|
53
67
|
if (scenes.length !== options.scenes.length) {
|
|
54
68
|
issues.push("gameplayAudit.scenes \u4E0D\u80FD\u5305\u542B\u7A7A\u503C\u6216\u91CD\u590D Scene key\u3002");
|
|
55
69
|
}
|
|
56
70
|
const contractIds = /* @__PURE__ */ new Set();
|
|
57
71
|
const coveredScenes = /* @__PURE__ */ new Set();
|
|
72
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
73
|
+
const invalidKindContracts = [];
|
|
58
74
|
for (const contract of options.contracts) {
|
|
59
75
|
const id = contract.id.trim();
|
|
60
76
|
if (!id) {
|
|
@@ -73,6 +89,27 @@ function validateGameplayAuditOptions(options) {
|
|
|
73
89
|
);
|
|
74
90
|
}
|
|
75
91
|
const contractScenes = normalizeSet(contract.scenes);
|
|
92
|
+
if (!["boot", "playthrough", "transition", "recovery"].includes(contract.kind)) {
|
|
93
|
+
invalidKindContracts.push(id || "<empty>");
|
|
94
|
+
} else {
|
|
95
|
+
const list = kinds.get(contract.kind) ?? [];
|
|
96
|
+
list.push(contract);
|
|
97
|
+
kinds.set(contract.kind, list);
|
|
98
|
+
}
|
|
99
|
+
if (contract.kind === "playthrough") {
|
|
100
|
+
if (!contract.requireInput) issues.push(`playthrough contract "${id}" \u5FC5\u987B requireInput: true\u3002`);
|
|
101
|
+
if (!contract.requireFrameAdvance) issues.push(`playthrough contract "${id}" \u5FC5\u987B requireFrameAdvance: true\u3002`);
|
|
102
|
+
if (normalizeList(contract.requireCheckpoint).length === 0) issues.push(`playthrough contract "${id}" \u5FC5\u987B\u58F0\u660E requireCheckpoint\u3002`);
|
|
103
|
+
}
|
|
104
|
+
if (contract.kind === "transition" && normalizeList(contract.requireTransition).length === 0) {
|
|
105
|
+
issues.push(`transition contract "${id}" \u5FC5\u987B\u58F0\u660E requireTransition\u3002`);
|
|
106
|
+
}
|
|
107
|
+
if (contract.kind === "recovery") {
|
|
108
|
+
if (!contract.requireInput) issues.push(`recovery contract "${id}" \u5FC5\u987B requireInput: true\u3002`);
|
|
109
|
+
if (!contract.requireFrameAdvance) issues.push(`recovery contract "${id}" \u5FC5\u987B requireFrameAdvance: true\u3002`);
|
|
110
|
+
if (normalizeList(contract.requireRestart).length === 0) issues.push(`recovery contract "${id}" \u5FC5\u987B\u58F0\u660E requireRestart\u3002`);
|
|
111
|
+
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`);
|
|
112
|
+
}
|
|
76
113
|
if (contractScenes.length === 0) {
|
|
77
114
|
issues.push(`contract "${id || "<empty>"}" \u5FC5\u987B\u58F0\u660E\u81F3\u5C11\u4E00\u4E2A Scene\u3002`);
|
|
78
115
|
}
|
|
@@ -85,6 +122,11 @@ function validateGameplayAuditOptions(options) {
|
|
|
85
122
|
}
|
|
86
123
|
}
|
|
87
124
|
}
|
|
125
|
+
if (invalidKindContracts.length > 0) {
|
|
126
|
+
issues.push(
|
|
127
|
+
`\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`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
88
130
|
for (const scene of scenes) {
|
|
89
131
|
if (!coveredScenes.has(scene)) {
|
|
90
132
|
issues.push(`Scene "${scene}" \u6CA1\u6709\u4EFB\u4F55 gameplay contract \u8D1F\u8D23\u9A8C\u8BC1\u3002`);
|
|
@@ -92,11 +134,71 @@ function validateGameplayAuditOptions(options) {
|
|
|
92
134
|
}
|
|
93
135
|
if (options.contracts.length === 0)
|
|
94
136
|
issues.push("gameplayAudit.contracts \u4E0D\u80FD\u4E3A\u7A7A\u3002");
|
|
137
|
+
if (mode === "interactive") {
|
|
138
|
+
const hasPlaythrough = (kinds.get("playthrough") ?? []).length > 0;
|
|
139
|
+
const hasInput = options.contracts.some((contract) => contract.requireInput);
|
|
140
|
+
const hasCheckpoint = options.contracts.some((contract) => normalizeList(contract.requireCheckpoint).length > 0);
|
|
141
|
+
if (!hasPlaythrough || !hasInput || !hasCheckpoint) {
|
|
142
|
+
issues.push(
|
|
143
|
+
'\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'
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const entryScene = scenes[0];
|
|
147
|
+
for (const scene of scenes.slice(1)) {
|
|
148
|
+
const transitioned = options.contracts.some(
|
|
149
|
+
(contract) => (contract.kind === "transition" || contract.kind === "playthrough") && normalizeList(contract.requireTransition).includes(scene)
|
|
150
|
+
);
|
|
151
|
+
if (!transitioned) issues.push(`\u975E\u5165\u53E3 Scene "${scene}" \u5FC5\u987B\u7531 transition/playthrough contract \u901A\u8FC7\u751F\u4EA7\u8F6C\u573A\u8986\u76D6\u3002`);
|
|
152
|
+
}
|
|
153
|
+
if (entryScene && scenes.length > 1 && (kinds.get("transition") ?? []).length === 0 && (kinds.get("playthrough") ?? []).length === 0 && invalidKindContracts.length === 0) {
|
|
154
|
+
issues.push(`\u4EA4\u4E92\u9879\u76EE\u5165\u53E3 Scene "${entryScene}" \u7F3A\u5C11 transition/playthrough flow\u3002`);
|
|
155
|
+
}
|
|
156
|
+
const restartSignal = projectRoot2 ? containsProductionRestart(projectRoot2) : false;
|
|
157
|
+
if (restartSignal && options.flows?.restart !== true) {
|
|
158
|
+
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");
|
|
159
|
+
} else if (restartSignal && (kinds.get("recovery") ?? []).length === 0) {
|
|
160
|
+
issues.push("\u68C0\u6D4B\u5230\u751F\u4EA7 Scene \u4F7F\u7528 scene.restart()\uFF0C\u4F46\u7F3A\u5C11 kind: recovery contract\u3002");
|
|
161
|
+
} else if ((options.flows?.restart || options.flows?.reset) && (kinds.get("recovery") ?? []).length === 0) {
|
|
162
|
+
issues.push("\u9879\u76EE\u58F0\u660E\u6216\u5B9E\u73B0\u4E86 restart/reset \u6D41\u7A0B\uFF0C\u4F46\u7F3A\u5C11 kind: recovery contract\u3002");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
95
165
|
return issues;
|
|
96
166
|
}
|
|
167
|
+
function containsProductionRestart(projectRoot2) {
|
|
168
|
+
const sceneRoot = join(projectRoot2, "src", "scenes");
|
|
169
|
+
const visit = (directory) => {
|
|
170
|
+
let entries;
|
|
171
|
+
try {
|
|
172
|
+
entries = readdirSync(directory);
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
const path = join(directory, entry);
|
|
178
|
+
let stats;
|
|
179
|
+
try {
|
|
180
|
+
stats = statSync(path);
|
|
181
|
+
} catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (stats.isDirectory() && visit(path)) return true;
|
|
185
|
+
if (!stats.isFile() || !/\.(ts|tsx)$/.test(entry)) continue;
|
|
186
|
+
try {
|
|
187
|
+
const source = readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
|
188
|
+
if (/\bscene\s*\.\s*restart\s*\(/.test(source)) return true;
|
|
189
|
+
} catch {
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
};
|
|
194
|
+
return visit(sceneRoot);
|
|
195
|
+
}
|
|
97
196
|
function getDefinitionIssues(expected, actual) {
|
|
98
197
|
const issues = [];
|
|
99
198
|
const expectedMetadata = createGameplayContractMetadata(expected);
|
|
199
|
+
if (expectedMetadata.kind !== actual.kind) {
|
|
200
|
+
issues.push(`contract "${expected.id}" \u7684 kind \u4E0E gameplayAudit \u6E05\u5355\u4E0D\u4E00\u81F4\u3002`);
|
|
201
|
+
}
|
|
100
202
|
if (!sameSet(expectedMetadata.scenes, actual.scenes)) {
|
|
101
203
|
issues.push(
|
|
102
204
|
`contract "${expected.id}" \u58F0\u660E\u7684 Scene \u4E0E gameplayAudit \u6E05\u5355\u4E0D\u4E00\u81F4\u3002`
|
|
@@ -331,28 +433,25 @@ GAMEPLAY_AUDIT: ALL CONTRACTS PASSED (${this.options.contracts.length} contracts
|
|
|
331
433
|
|
|
332
434
|
// src/vitest-config.ts
|
|
333
435
|
function defineGameVitestConfig(options) {
|
|
334
|
-
const auditIssues = validateGameplayAuditOptions(options.gameplayAudit);
|
|
436
|
+
const auditIssues = validateGameplayAuditOptions(options.gameplayAudit, options.projectRoot);
|
|
335
437
|
if (auditIssues.length > 0) {
|
|
336
438
|
throw new Error(
|
|
337
439
|
`\u65E0\u6548\u7684 gameplayAudit \u914D\u7F6E\uFF1A
|
|
338
440
|
- ${auditIssues.join("\n- ")}`
|
|
339
441
|
);
|
|
340
442
|
}
|
|
341
|
-
const projectRequire = createRequire(
|
|
342
|
-
resolve(options.projectRoot, "package.json")
|
|
343
|
-
);
|
|
344
|
-
const phaserPackageRoot = dirname(
|
|
345
|
-
projectRequire.resolve("phaser/package.json")
|
|
346
|
-
);
|
|
347
|
-
const phaserBrowserBuild = resolve(phaserPackageRoot, "dist/phaser.js");
|
|
348
443
|
return defineConfig({
|
|
349
444
|
resolve: {
|
|
350
445
|
alias: {
|
|
351
446
|
...options.aliases,
|
|
352
|
-
"@": resolve(options.projectRoot, "src")
|
|
353
|
-
phaser: phaserBrowserBuild
|
|
447
|
+
"@": resolve(options.projectRoot, "src")
|
|
354
448
|
}
|
|
355
449
|
},
|
|
450
|
+
// devkit 作为 external ESM 加载时,应用测试也必须 externalize Phaser,
|
|
451
|
+
// 否则 Vite alias 与 Node 条件导出会创建两个 Phaser 单例,Scene 无法启动。
|
|
452
|
+
ssr: {
|
|
453
|
+
external: ["phaser"]
|
|
454
|
+
},
|
|
356
455
|
test: {
|
|
357
456
|
include: ["tests/**/*.test.ts"],
|
|
358
457
|
testTimeout: options.testTimeout,
|
|
@@ -362,7 +461,8 @@ function defineGameVitestConfig(options) {
|
|
|
362
461
|
// devkit 模块需要在配置和审计阶段使用 Node 内置模块。
|
|
363
462
|
// 如果强制内联,Vite 会把这些模块按浏览器模块转换,
|
|
364
463
|
// 最终触发 "No such built-in module: node:"。
|
|
365
|
-
external: [/miaoda-game-devkit/]
|
|
464
|
+
external: [/miaoda-game-devkit/],
|
|
465
|
+
inline: options.inlineDependencies
|
|
366
466
|
}
|
|
367
467
|
},
|
|
368
468
|
environment: "jsdom",
|
|
@@ -403,10 +503,13 @@ function defineGameVitestConfig(options) {
|
|
|
403
503
|
// src/lint/vitest-config.test.ts
|
|
404
504
|
var projectRoot = resolve2(import.meta.dirname, "../..");
|
|
405
505
|
var gameplayAudit = {
|
|
506
|
+
mode: "non-interactive",
|
|
507
|
+
nonInteractiveJustification: "\u56FA\u5B9A\u5939\u5177\u53EA\u9A8C\u8BC1\u5171\u4EAB host \u57FA\u7EBF\uFF0C\u4E0D\u5305\u542B\u73A9\u6CD5\u8F93\u5165\u3002",
|
|
406
508
|
scenes: ["InputScene"],
|
|
407
509
|
contracts: [
|
|
408
510
|
{
|
|
409
511
|
id: "InputScene.boot",
|
|
512
|
+
kind: "boot",
|
|
410
513
|
testFile: "tests/input.test.ts",
|
|
411
514
|
scenes: ["InputScene"],
|
|
412
515
|
requireFrameAdvance: true
|
|
@@ -417,14 +520,16 @@ describe("defineGameVitestConfig", () => {
|
|
|
417
520
|
it("\u63D0\u4F9B Phaser \u8FD0\u884C\u65F6\u6D4B\u8BD5\u9700\u8981\u7684\u56FA\u5B9A\u57FA\u7EBF", () => {
|
|
418
521
|
const config = defineGameVitestConfig({ projectRoot, gameplayAudit });
|
|
419
522
|
expect(config.resolve?.alias).toMatchObject({
|
|
420
|
-
"@": resolve2(projectRoot, "src")
|
|
421
|
-
phaser: expect.stringContaining("phaser/dist/phaser.js")
|
|
523
|
+
"@": resolve2(projectRoot, "src")
|
|
422
524
|
});
|
|
525
|
+
expect(config.resolve?.alias).not.toHaveProperty("phaser");
|
|
526
|
+
expect(config.ssr).toMatchObject({ external: ["phaser"] });
|
|
423
527
|
expect(config.test).toMatchObject({
|
|
424
528
|
include: ["tests/**/*.test.ts"],
|
|
425
529
|
server: {
|
|
426
530
|
deps: {
|
|
427
|
-
external: [/miaoda-game-devkit/]
|
|
531
|
+
external: [/miaoda-game-devkit/],
|
|
532
|
+
inline: void 0
|
|
428
533
|
}
|
|
429
534
|
},
|
|
430
535
|
environment: "jsdom",
|
|
@@ -454,6 +559,7 @@ describe("defineGameVitestConfig", () => {
|
|
|
454
559
|
const config = defineGameVitestConfig({
|
|
455
560
|
projectRoot,
|
|
456
561
|
gameplayAudit,
|
|
562
|
+
inlineDependencies: [/phaser4-rex-plugins/],
|
|
457
563
|
additionalSetupFiles: ["tests/custom-setup.ts"],
|
|
458
564
|
testTimeout: 5e3,
|
|
459
565
|
hookTimeout: 2e3
|
|
@@ -476,4 +582,16 @@ describe("defineGameVitestConfig", () => {
|
|
|
476
582
|
})
|
|
477
583
|
).toThrow(/ResultScene.*没有任何 gameplay contract/s);
|
|
478
584
|
});
|
|
585
|
+
it("\u62D2\u7EDD\u4EA4\u4E92\u9879\u76EE\u53EA\u6709 boot smoke test", () => {
|
|
586
|
+
expect(
|
|
587
|
+
() => defineGameVitestConfig({
|
|
588
|
+
projectRoot,
|
|
589
|
+
gameplayAudit: {
|
|
590
|
+
mode: "interactive",
|
|
591
|
+
scenes: ["InputScene"],
|
|
592
|
+
contracts: gameplayAudit.contracts
|
|
593
|
+
}
|
|
594
|
+
})
|
|
595
|
+
).toThrow(/缺少最低玩法基线/);
|
|
596
|
+
});
|
|
479
597
|
});
|
package/dist/vitest-config.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ViteUserConfig } from 'vitest/config';
|
|
2
|
-
import { c as GameplayAuditOptions } from './gameplay-audit-
|
|
2
|
+
import { c as GameplayAuditOptions } from './gameplay-audit-XBGq_jIV.mjs';
|
|
3
3
|
import 'phaser';
|
|
4
4
|
|
|
5
5
|
interface GameVitestConfigOptions {
|
|
@@ -7,10 +7,12 @@ interface GameVitestConfigOptions {
|
|
|
7
7
|
projectRoot: string;
|
|
8
8
|
/** 测试前后都必须审计的生产 Scene 和玩法契约清单。 */
|
|
9
9
|
gameplayAudit: GameplayAuditOptions;
|
|
10
|
-
/** 项目需要的额外路径别名;`@`
|
|
10
|
+
/** 项目需要的额外路径别名;`@` 由 devkit 统一维护。 */
|
|
11
11
|
aliases?: Record<string, string>;
|
|
12
12
|
/** 在 devkit 基础 setup 之后执行的项目级 setup 文件。 */
|
|
13
13
|
additionalSetupFiles?: string[];
|
|
14
|
+
/** 需要由 Vite 转换的项目依赖,例如包含无扩展名 import 的插件。 */
|
|
15
|
+
inlineDependencies?: RegExp[];
|
|
14
16
|
/** 单个游戏运行时测试的超时时间。 */
|
|
15
17
|
testTimeout?: number;
|
|
16
18
|
/** 单个游戏测试钩子的超时时间。 */
|
package/dist/vitest-config.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ViteUserConfig } from 'vitest/config';
|
|
2
|
-
import { c as GameplayAuditOptions } from './gameplay-audit-
|
|
2
|
+
import { c as GameplayAuditOptions } from './gameplay-audit-XBGq_jIV.js';
|
|
3
3
|
import 'phaser';
|
|
4
4
|
|
|
5
5
|
interface GameVitestConfigOptions {
|
|
@@ -7,10 +7,12 @@ interface GameVitestConfigOptions {
|
|
|
7
7
|
projectRoot: string;
|
|
8
8
|
/** 测试前后都必须审计的生产 Scene 和玩法契约清单。 */
|
|
9
9
|
gameplayAudit: GameplayAuditOptions;
|
|
10
|
-
/** 项目需要的额外路径别名;`@`
|
|
10
|
+
/** 项目需要的额外路径别名;`@` 由 devkit 统一维护。 */
|
|
11
11
|
aliases?: Record<string, string>;
|
|
12
12
|
/** 在 devkit 基础 setup 之后执行的项目级 setup 文件。 */
|
|
13
13
|
additionalSetupFiles?: string[];
|
|
14
|
+
/** 需要由 Vite 转换的项目依赖,例如包含无扩展名 import 的插件。 */
|
|
15
|
+
inlineDependencies?: RegExp[];
|
|
14
16
|
/** 单个游戏运行时测试的超时时间。 */
|
|
15
17
|
testTimeout?: number;
|
|
16
18
|
/** 单个游戏测试钩子的超时时间。 */
|
package/dist/vitest-config.js
CHANGED
|
@@ -23,11 +23,12 @@ __export(vitest_config_exports, {
|
|
|
23
23
|
defineGameVitestConfig: () => defineGameVitestConfig
|
|
24
24
|
});
|
|
25
25
|
module.exports = __toCommonJS(vitest_config_exports);
|
|
26
|
-
var
|
|
27
|
-
var import_node_path2 = require("path");
|
|
26
|
+
var import_node_path3 = require("path");
|
|
28
27
|
var import_config = require("vitest/config");
|
|
29
28
|
|
|
30
29
|
// src/gameplay-audit.ts
|
|
30
|
+
var import_node_fs = require("fs");
|
|
31
|
+
var import_node_path = require("path");
|
|
31
32
|
function normalizeList(value) {
|
|
32
33
|
if (value === void 0) return [];
|
|
33
34
|
const values = typeof value === "string" ? [value] : value;
|
|
@@ -61,20 +62,35 @@ function createGameplayContractMetadata(contract) {
|
|
|
61
62
|
const scenes = normalizeSet(contract.scenes);
|
|
62
63
|
return {
|
|
63
64
|
id: contract.id.trim(),
|
|
65
|
+
kind: contract.kind,
|
|
64
66
|
scenes,
|
|
65
67
|
requirements: normalizeGameplayRequirements(contract, scenes),
|
|
66
68
|
verified: false
|
|
67
69
|
};
|
|
68
70
|
}
|
|
69
|
-
function validateGameplayAuditOptions(options) {
|
|
71
|
+
function validateGameplayAuditOptions(options, projectRoot) {
|
|
70
72
|
const issues = [];
|
|
71
|
-
const scenes =
|
|
73
|
+
const scenes = [...new Set(options.scenes.map((scene) => scene.trim()).filter(Boolean))];
|
|
74
|
+
const mode = options.mode ?? "interactive";
|
|
75
|
+
if (mode === "non-interactive") {
|
|
76
|
+
const justification = options.nonInteractiveJustification?.trim() ?? "";
|
|
77
|
+
if (justification.length < 20) {
|
|
78
|
+
issues.push(
|
|
79
|
+
"non-interactive \u6A21\u5F0F\u5FC5\u987B\u63D0\u4F9B\u81F3\u5C11 20 \u4E2A\u5B57\u7B26\u7684 nonInteractiveJustification\u3002"
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (mode !== "interactive" && mode !== "non-interactive") {
|
|
84
|
+
issues.push("gameplayAudit.mode \u5FC5\u987B\u662F interactive \u6216 non-interactive\u3002");
|
|
85
|
+
}
|
|
72
86
|
if (scenes.length === 0) issues.push("gameplayAudit.scenes \u4E0D\u80FD\u4E3A\u7A7A\u3002");
|
|
73
87
|
if (scenes.length !== options.scenes.length) {
|
|
74
88
|
issues.push("gameplayAudit.scenes \u4E0D\u80FD\u5305\u542B\u7A7A\u503C\u6216\u91CD\u590D Scene key\u3002");
|
|
75
89
|
}
|
|
76
90
|
const contractIds = /* @__PURE__ */ new Set();
|
|
77
91
|
const coveredScenes = /* @__PURE__ */ new Set();
|
|
92
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
93
|
+
const invalidKindContracts = [];
|
|
78
94
|
for (const contract of options.contracts) {
|
|
79
95
|
const id = contract.id.trim();
|
|
80
96
|
if (!id) {
|
|
@@ -93,6 +109,27 @@ function validateGameplayAuditOptions(options) {
|
|
|
93
109
|
);
|
|
94
110
|
}
|
|
95
111
|
const contractScenes = normalizeSet(contract.scenes);
|
|
112
|
+
if (!["boot", "playthrough", "transition", "recovery"].includes(contract.kind)) {
|
|
113
|
+
invalidKindContracts.push(id || "<empty>");
|
|
114
|
+
} else {
|
|
115
|
+
const list = kinds.get(contract.kind) ?? [];
|
|
116
|
+
list.push(contract);
|
|
117
|
+
kinds.set(contract.kind, list);
|
|
118
|
+
}
|
|
119
|
+
if (contract.kind === "playthrough") {
|
|
120
|
+
if (!contract.requireInput) issues.push(`playthrough contract "${id}" \u5FC5\u987B requireInput: true\u3002`);
|
|
121
|
+
if (!contract.requireFrameAdvance) issues.push(`playthrough contract "${id}" \u5FC5\u987B requireFrameAdvance: true\u3002`);
|
|
122
|
+
if (normalizeList(contract.requireCheckpoint).length === 0) issues.push(`playthrough contract "${id}" \u5FC5\u987B\u58F0\u660E requireCheckpoint\u3002`);
|
|
123
|
+
}
|
|
124
|
+
if (contract.kind === "transition" && normalizeList(contract.requireTransition).length === 0) {
|
|
125
|
+
issues.push(`transition contract "${id}" \u5FC5\u987B\u58F0\u660E requireTransition\u3002`);
|
|
126
|
+
}
|
|
127
|
+
if (contract.kind === "recovery") {
|
|
128
|
+
if (!contract.requireInput) issues.push(`recovery contract "${id}" \u5FC5\u987B requireInput: true\u3002`);
|
|
129
|
+
if (!contract.requireFrameAdvance) issues.push(`recovery contract "${id}" \u5FC5\u987B requireFrameAdvance: true\u3002`);
|
|
130
|
+
if (normalizeList(contract.requireRestart).length === 0) issues.push(`recovery contract "${id}" \u5FC5\u987B\u58F0\u660E requireRestart\u3002`);
|
|
131
|
+
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`);
|
|
132
|
+
}
|
|
96
133
|
if (contractScenes.length === 0) {
|
|
97
134
|
issues.push(`contract "${id || "<empty>"}" \u5FC5\u987B\u58F0\u660E\u81F3\u5C11\u4E00\u4E2A Scene\u3002`);
|
|
98
135
|
}
|
|
@@ -105,6 +142,11 @@ function validateGameplayAuditOptions(options) {
|
|
|
105
142
|
}
|
|
106
143
|
}
|
|
107
144
|
}
|
|
145
|
+
if (invalidKindContracts.length > 0) {
|
|
146
|
+
issues.push(
|
|
147
|
+
`\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`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
108
150
|
for (const scene of scenes) {
|
|
109
151
|
if (!coveredScenes.has(scene)) {
|
|
110
152
|
issues.push(`Scene "${scene}" \u6CA1\u6709\u4EFB\u4F55 gameplay contract \u8D1F\u8D23\u9A8C\u8BC1\u3002`);
|
|
@@ -112,11 +154,71 @@ function validateGameplayAuditOptions(options) {
|
|
|
112
154
|
}
|
|
113
155
|
if (options.contracts.length === 0)
|
|
114
156
|
issues.push("gameplayAudit.contracts \u4E0D\u80FD\u4E3A\u7A7A\u3002");
|
|
157
|
+
if (mode === "interactive") {
|
|
158
|
+
const hasPlaythrough = (kinds.get("playthrough") ?? []).length > 0;
|
|
159
|
+
const hasInput = options.contracts.some((contract) => contract.requireInput);
|
|
160
|
+
const hasCheckpoint = options.contracts.some((contract) => normalizeList(contract.requireCheckpoint).length > 0);
|
|
161
|
+
if (!hasPlaythrough || !hasInput || !hasCheckpoint) {
|
|
162
|
+
issues.push(
|
|
163
|
+
'\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'
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const entryScene = scenes[0];
|
|
167
|
+
for (const scene of scenes.slice(1)) {
|
|
168
|
+
const transitioned = options.contracts.some(
|
|
169
|
+
(contract) => (contract.kind === "transition" || contract.kind === "playthrough") && normalizeList(contract.requireTransition).includes(scene)
|
|
170
|
+
);
|
|
171
|
+
if (!transitioned) issues.push(`\u975E\u5165\u53E3 Scene "${scene}" \u5FC5\u987B\u7531 transition/playthrough contract \u901A\u8FC7\u751F\u4EA7\u8F6C\u573A\u8986\u76D6\u3002`);
|
|
172
|
+
}
|
|
173
|
+
if (entryScene && scenes.length > 1 && (kinds.get("transition") ?? []).length === 0 && (kinds.get("playthrough") ?? []).length === 0 && invalidKindContracts.length === 0) {
|
|
174
|
+
issues.push(`\u4EA4\u4E92\u9879\u76EE\u5165\u53E3 Scene "${entryScene}" \u7F3A\u5C11 transition/playthrough flow\u3002`);
|
|
175
|
+
}
|
|
176
|
+
const restartSignal = projectRoot ? containsProductionRestart(projectRoot) : false;
|
|
177
|
+
if (restartSignal && options.flows?.restart !== true) {
|
|
178
|
+
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");
|
|
179
|
+
} else if (restartSignal && (kinds.get("recovery") ?? []).length === 0) {
|
|
180
|
+
issues.push("\u68C0\u6D4B\u5230\u751F\u4EA7 Scene \u4F7F\u7528 scene.restart()\uFF0C\u4F46\u7F3A\u5C11 kind: recovery contract\u3002");
|
|
181
|
+
} else if ((options.flows?.restart || options.flows?.reset) && (kinds.get("recovery") ?? []).length === 0) {
|
|
182
|
+
issues.push("\u9879\u76EE\u58F0\u660E\u6216\u5B9E\u73B0\u4E86 restart/reset \u6D41\u7A0B\uFF0C\u4F46\u7F3A\u5C11 kind: recovery contract\u3002");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
115
185
|
return issues;
|
|
116
186
|
}
|
|
187
|
+
function containsProductionRestart(projectRoot) {
|
|
188
|
+
const sceneRoot = (0, import_node_path.join)(projectRoot, "src", "scenes");
|
|
189
|
+
const visit = (directory) => {
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = (0, import_node_fs.readdirSync)(directory);
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
const path = (0, import_node_path.join)(directory, entry);
|
|
198
|
+
let stats;
|
|
199
|
+
try {
|
|
200
|
+
stats = (0, import_node_fs.statSync)(path);
|
|
201
|
+
} catch {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (stats.isDirectory() && visit(path)) return true;
|
|
205
|
+
if (!stats.isFile() || !/\.(ts|tsx)$/.test(entry)) continue;
|
|
206
|
+
try {
|
|
207
|
+
const source = (0, import_node_fs.readFileSync)(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
|
208
|
+
if (/\bscene\s*\.\s*restart\s*\(/.test(source)) return true;
|
|
209
|
+
} catch {
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
};
|
|
214
|
+
return visit(sceneRoot);
|
|
215
|
+
}
|
|
117
216
|
function getDefinitionIssues(expected, actual) {
|
|
118
217
|
const issues = [];
|
|
119
218
|
const expectedMetadata = createGameplayContractMetadata(expected);
|
|
219
|
+
if (expectedMetadata.kind !== actual.kind) {
|
|
220
|
+
issues.push(`contract "${expected.id}" \u7684 kind \u4E0E gameplayAudit \u6E05\u5355\u4E0D\u4E00\u81F4\u3002`);
|
|
221
|
+
}
|
|
120
222
|
if (!sameSet(expectedMetadata.scenes, actual.scenes)) {
|
|
121
223
|
issues.push(
|
|
122
224
|
`contract "${expected.id}" \u58F0\u660E\u7684 Scene \u4E0E gameplayAudit \u6E05\u5355\u4E0D\u4E00\u81F4\u3002`
|
|
@@ -267,7 +369,7 @@ function auditGameplayRun(options, tests) {
|
|
|
267
369
|
}
|
|
268
370
|
|
|
269
371
|
// src/gameplay-audit-reporter.ts
|
|
270
|
-
var
|
|
372
|
+
var import_node_path2 = require("path");
|
|
271
373
|
function normalizePath(path) {
|
|
272
374
|
return path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
273
375
|
}
|
|
@@ -305,7 +407,7 @@ var GameplayAuditReporter = class {
|
|
|
305
407
|
this.preflightIssues.clear();
|
|
306
408
|
const scheduledFiles = new Set(
|
|
307
409
|
specifications.map(
|
|
308
|
-
(specification) => normalizePath((0,
|
|
410
|
+
(specification) => normalizePath((0, import_node_path2.relative)(this.projectRoot, specification.moduleId))
|
|
309
411
|
)
|
|
310
412
|
);
|
|
311
413
|
for (const contract of this.options.contracts) {
|
|
@@ -351,28 +453,25 @@ GAMEPLAY_AUDIT: ALL CONTRACTS PASSED (${this.options.contracts.length} contracts
|
|
|
351
453
|
|
|
352
454
|
// src/vitest-config.ts
|
|
353
455
|
function defineGameVitestConfig(options) {
|
|
354
|
-
const auditIssues = validateGameplayAuditOptions(options.gameplayAudit);
|
|
456
|
+
const auditIssues = validateGameplayAuditOptions(options.gameplayAudit, options.projectRoot);
|
|
355
457
|
if (auditIssues.length > 0) {
|
|
356
458
|
throw new Error(
|
|
357
459
|
`\u65E0\u6548\u7684 gameplayAudit \u914D\u7F6E\uFF1A
|
|
358
460
|
- ${auditIssues.join("\n- ")}`
|
|
359
461
|
);
|
|
360
462
|
}
|
|
361
|
-
const projectRequire = (0, import_node_module.createRequire)(
|
|
362
|
-
(0, import_node_path2.resolve)(options.projectRoot, "package.json")
|
|
363
|
-
);
|
|
364
|
-
const phaserPackageRoot = (0, import_node_path2.dirname)(
|
|
365
|
-
projectRequire.resolve("phaser/package.json")
|
|
366
|
-
);
|
|
367
|
-
const phaserBrowserBuild = (0, import_node_path2.resolve)(phaserPackageRoot, "dist/phaser.js");
|
|
368
463
|
return (0, import_config.defineConfig)({
|
|
369
464
|
resolve: {
|
|
370
465
|
alias: {
|
|
371
466
|
...options.aliases,
|
|
372
|
-
"@": (0,
|
|
373
|
-
phaser: phaserBrowserBuild
|
|
467
|
+
"@": (0, import_node_path3.resolve)(options.projectRoot, "src")
|
|
374
468
|
}
|
|
375
469
|
},
|
|
470
|
+
// devkit 作为 external ESM 加载时,应用测试也必须 externalize Phaser,
|
|
471
|
+
// 否则 Vite alias 与 Node 条件导出会创建两个 Phaser 单例,Scene 无法启动。
|
|
472
|
+
ssr: {
|
|
473
|
+
external: ["phaser"]
|
|
474
|
+
},
|
|
376
475
|
test: {
|
|
377
476
|
include: ["tests/**/*.test.ts"],
|
|
378
477
|
testTimeout: options.testTimeout,
|
|
@@ -382,7 +481,8 @@ function defineGameVitestConfig(options) {
|
|
|
382
481
|
// devkit 模块需要在配置和审计阶段使用 Node 内置模块。
|
|
383
482
|
// 如果强制内联,Vite 会把这些模块按浏览器模块转换,
|
|
384
483
|
// 最终触发 "No such built-in module: node:"。
|
|
385
|
-
external: [/miaoda-game-devkit/]
|
|
484
|
+
external: [/miaoda-game-devkit/],
|
|
485
|
+
inline: options.inlineDependencies
|
|
386
486
|
}
|
|
387
487
|
},
|
|
388
488
|
environment: "jsdom",
|
package/dist/vitest-config.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// src/vitest-config.ts
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, resolve } from "path";
|
|
2
|
+
import { resolve } from "path";
|
|
4
3
|
import { defineConfig } from "vitest/config";
|
|
5
4
|
|
|
6
5
|
// src/gameplay-audit.ts
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
7
8
|
function normalizeList(value) {
|
|
8
9
|
if (value === void 0) return [];
|
|
9
10
|
const values = typeof value === "string" ? [value] : value;
|
|
@@ -37,20 +38,35 @@ function createGameplayContractMetadata(contract) {
|
|
|
37
38
|
const scenes = normalizeSet(contract.scenes);
|
|
38
39
|
return {
|
|
39
40
|
id: contract.id.trim(),
|
|
41
|
+
kind: contract.kind,
|
|
40
42
|
scenes,
|
|
41
43
|
requirements: normalizeGameplayRequirements(contract, scenes),
|
|
42
44
|
verified: false
|
|
43
45
|
};
|
|
44
46
|
}
|
|
45
|
-
function validateGameplayAuditOptions(options) {
|
|
47
|
+
function validateGameplayAuditOptions(options, projectRoot) {
|
|
46
48
|
const issues = [];
|
|
47
|
-
const scenes =
|
|
49
|
+
const scenes = [...new Set(options.scenes.map((scene) => scene.trim()).filter(Boolean))];
|
|
50
|
+
const mode = options.mode ?? "interactive";
|
|
51
|
+
if (mode === "non-interactive") {
|
|
52
|
+
const justification = options.nonInteractiveJustification?.trim() ?? "";
|
|
53
|
+
if (justification.length < 20) {
|
|
54
|
+
issues.push(
|
|
55
|
+
"non-interactive \u6A21\u5F0F\u5FC5\u987B\u63D0\u4F9B\u81F3\u5C11 20 \u4E2A\u5B57\u7B26\u7684 nonInteractiveJustification\u3002"
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (mode !== "interactive" && mode !== "non-interactive") {
|
|
60
|
+
issues.push("gameplayAudit.mode \u5FC5\u987B\u662F interactive \u6216 non-interactive\u3002");
|
|
61
|
+
}
|
|
48
62
|
if (scenes.length === 0) issues.push("gameplayAudit.scenes \u4E0D\u80FD\u4E3A\u7A7A\u3002");
|
|
49
63
|
if (scenes.length !== options.scenes.length) {
|
|
50
64
|
issues.push("gameplayAudit.scenes \u4E0D\u80FD\u5305\u542B\u7A7A\u503C\u6216\u91CD\u590D Scene key\u3002");
|
|
51
65
|
}
|
|
52
66
|
const contractIds = /* @__PURE__ */ new Set();
|
|
53
67
|
const coveredScenes = /* @__PURE__ */ new Set();
|
|
68
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
69
|
+
const invalidKindContracts = [];
|
|
54
70
|
for (const contract of options.contracts) {
|
|
55
71
|
const id = contract.id.trim();
|
|
56
72
|
if (!id) {
|
|
@@ -69,6 +85,27 @@ function validateGameplayAuditOptions(options) {
|
|
|
69
85
|
);
|
|
70
86
|
}
|
|
71
87
|
const contractScenes = normalizeSet(contract.scenes);
|
|
88
|
+
if (!["boot", "playthrough", "transition", "recovery"].includes(contract.kind)) {
|
|
89
|
+
invalidKindContracts.push(id || "<empty>");
|
|
90
|
+
} else {
|
|
91
|
+
const list = kinds.get(contract.kind) ?? [];
|
|
92
|
+
list.push(contract);
|
|
93
|
+
kinds.set(contract.kind, list);
|
|
94
|
+
}
|
|
95
|
+
if (contract.kind === "playthrough") {
|
|
96
|
+
if (!contract.requireInput) issues.push(`playthrough contract "${id}" \u5FC5\u987B requireInput: true\u3002`);
|
|
97
|
+
if (!contract.requireFrameAdvance) issues.push(`playthrough contract "${id}" \u5FC5\u987B requireFrameAdvance: true\u3002`);
|
|
98
|
+
if (normalizeList(contract.requireCheckpoint).length === 0) issues.push(`playthrough contract "${id}" \u5FC5\u987B\u58F0\u660E requireCheckpoint\u3002`);
|
|
99
|
+
}
|
|
100
|
+
if (contract.kind === "transition" && normalizeList(contract.requireTransition).length === 0) {
|
|
101
|
+
issues.push(`transition contract "${id}" \u5FC5\u987B\u58F0\u660E requireTransition\u3002`);
|
|
102
|
+
}
|
|
103
|
+
if (contract.kind === "recovery") {
|
|
104
|
+
if (!contract.requireInput) issues.push(`recovery contract "${id}" \u5FC5\u987B requireInput: true\u3002`);
|
|
105
|
+
if (!contract.requireFrameAdvance) issues.push(`recovery contract "${id}" \u5FC5\u987B requireFrameAdvance: true\u3002`);
|
|
106
|
+
if (normalizeList(contract.requireRestart).length === 0) issues.push(`recovery contract "${id}" \u5FC5\u987B\u58F0\u660E requireRestart\u3002`);
|
|
107
|
+
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`);
|
|
108
|
+
}
|
|
72
109
|
if (contractScenes.length === 0) {
|
|
73
110
|
issues.push(`contract "${id || "<empty>"}" \u5FC5\u987B\u58F0\u660E\u81F3\u5C11\u4E00\u4E2A Scene\u3002`);
|
|
74
111
|
}
|
|
@@ -81,6 +118,11 @@ function validateGameplayAuditOptions(options) {
|
|
|
81
118
|
}
|
|
82
119
|
}
|
|
83
120
|
}
|
|
121
|
+
if (invalidKindContracts.length > 0) {
|
|
122
|
+
issues.push(
|
|
123
|
+
`\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`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
84
126
|
for (const scene of scenes) {
|
|
85
127
|
if (!coveredScenes.has(scene)) {
|
|
86
128
|
issues.push(`Scene "${scene}" \u6CA1\u6709\u4EFB\u4F55 gameplay contract \u8D1F\u8D23\u9A8C\u8BC1\u3002`);
|
|
@@ -88,11 +130,71 @@ function validateGameplayAuditOptions(options) {
|
|
|
88
130
|
}
|
|
89
131
|
if (options.contracts.length === 0)
|
|
90
132
|
issues.push("gameplayAudit.contracts \u4E0D\u80FD\u4E3A\u7A7A\u3002");
|
|
133
|
+
if (mode === "interactive") {
|
|
134
|
+
const hasPlaythrough = (kinds.get("playthrough") ?? []).length > 0;
|
|
135
|
+
const hasInput = options.contracts.some((contract) => contract.requireInput);
|
|
136
|
+
const hasCheckpoint = options.contracts.some((contract) => normalizeList(contract.requireCheckpoint).length > 0);
|
|
137
|
+
if (!hasPlaythrough || !hasInput || !hasCheckpoint) {
|
|
138
|
+
issues.push(
|
|
139
|
+
'\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'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const entryScene = scenes[0];
|
|
143
|
+
for (const scene of scenes.slice(1)) {
|
|
144
|
+
const transitioned = options.contracts.some(
|
|
145
|
+
(contract) => (contract.kind === "transition" || contract.kind === "playthrough") && normalizeList(contract.requireTransition).includes(scene)
|
|
146
|
+
);
|
|
147
|
+
if (!transitioned) issues.push(`\u975E\u5165\u53E3 Scene "${scene}" \u5FC5\u987B\u7531 transition/playthrough contract \u901A\u8FC7\u751F\u4EA7\u8F6C\u573A\u8986\u76D6\u3002`);
|
|
148
|
+
}
|
|
149
|
+
if (entryScene && scenes.length > 1 && (kinds.get("transition") ?? []).length === 0 && (kinds.get("playthrough") ?? []).length === 0 && invalidKindContracts.length === 0) {
|
|
150
|
+
issues.push(`\u4EA4\u4E92\u9879\u76EE\u5165\u53E3 Scene "${entryScene}" \u7F3A\u5C11 transition/playthrough flow\u3002`);
|
|
151
|
+
}
|
|
152
|
+
const restartSignal = projectRoot ? containsProductionRestart(projectRoot) : false;
|
|
153
|
+
if (restartSignal && options.flows?.restart !== true) {
|
|
154
|
+
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");
|
|
155
|
+
} else if (restartSignal && (kinds.get("recovery") ?? []).length === 0) {
|
|
156
|
+
issues.push("\u68C0\u6D4B\u5230\u751F\u4EA7 Scene \u4F7F\u7528 scene.restart()\uFF0C\u4F46\u7F3A\u5C11 kind: recovery contract\u3002");
|
|
157
|
+
} else if ((options.flows?.restart || options.flows?.reset) && (kinds.get("recovery") ?? []).length === 0) {
|
|
158
|
+
issues.push("\u9879\u76EE\u58F0\u660E\u6216\u5B9E\u73B0\u4E86 restart/reset \u6D41\u7A0B\uFF0C\u4F46\u7F3A\u5C11 kind: recovery contract\u3002");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
91
161
|
return issues;
|
|
92
162
|
}
|
|
163
|
+
function containsProductionRestart(projectRoot) {
|
|
164
|
+
const sceneRoot = join(projectRoot, "src", "scenes");
|
|
165
|
+
const visit = (directory) => {
|
|
166
|
+
let entries;
|
|
167
|
+
try {
|
|
168
|
+
entries = readdirSync(directory);
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
const path = join(directory, entry);
|
|
174
|
+
let stats;
|
|
175
|
+
try {
|
|
176
|
+
stats = statSync(path);
|
|
177
|
+
} catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (stats.isDirectory() && visit(path)) return true;
|
|
181
|
+
if (!stats.isFile() || !/\.(ts|tsx)$/.test(entry)) continue;
|
|
182
|
+
try {
|
|
183
|
+
const source = readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
|
184
|
+
if (/\bscene\s*\.\s*restart\s*\(/.test(source)) return true;
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
};
|
|
190
|
+
return visit(sceneRoot);
|
|
191
|
+
}
|
|
93
192
|
function getDefinitionIssues(expected, actual) {
|
|
94
193
|
const issues = [];
|
|
95
194
|
const expectedMetadata = createGameplayContractMetadata(expected);
|
|
195
|
+
if (expectedMetadata.kind !== actual.kind) {
|
|
196
|
+
issues.push(`contract "${expected.id}" \u7684 kind \u4E0E gameplayAudit \u6E05\u5355\u4E0D\u4E00\u81F4\u3002`);
|
|
197
|
+
}
|
|
96
198
|
if (!sameSet(expectedMetadata.scenes, actual.scenes)) {
|
|
97
199
|
issues.push(
|
|
98
200
|
`contract "${expected.id}" \u58F0\u660E\u7684 Scene \u4E0E gameplayAudit \u6E05\u5355\u4E0D\u4E00\u81F4\u3002`
|
|
@@ -327,28 +429,25 @@ GAMEPLAY_AUDIT: ALL CONTRACTS PASSED (${this.options.contracts.length} contracts
|
|
|
327
429
|
|
|
328
430
|
// src/vitest-config.ts
|
|
329
431
|
function defineGameVitestConfig(options) {
|
|
330
|
-
const auditIssues = validateGameplayAuditOptions(options.gameplayAudit);
|
|
432
|
+
const auditIssues = validateGameplayAuditOptions(options.gameplayAudit, options.projectRoot);
|
|
331
433
|
if (auditIssues.length > 0) {
|
|
332
434
|
throw new Error(
|
|
333
435
|
`\u65E0\u6548\u7684 gameplayAudit \u914D\u7F6E\uFF1A
|
|
334
436
|
- ${auditIssues.join("\n- ")}`
|
|
335
437
|
);
|
|
336
438
|
}
|
|
337
|
-
const projectRequire = createRequire(
|
|
338
|
-
resolve(options.projectRoot, "package.json")
|
|
339
|
-
);
|
|
340
|
-
const phaserPackageRoot = dirname(
|
|
341
|
-
projectRequire.resolve("phaser/package.json")
|
|
342
|
-
);
|
|
343
|
-
const phaserBrowserBuild = resolve(phaserPackageRoot, "dist/phaser.js");
|
|
344
439
|
return defineConfig({
|
|
345
440
|
resolve: {
|
|
346
441
|
alias: {
|
|
347
442
|
...options.aliases,
|
|
348
|
-
"@": resolve(options.projectRoot, "src")
|
|
349
|
-
phaser: phaserBrowserBuild
|
|
443
|
+
"@": resolve(options.projectRoot, "src")
|
|
350
444
|
}
|
|
351
445
|
},
|
|
446
|
+
// devkit 作为 external ESM 加载时,应用测试也必须 externalize Phaser,
|
|
447
|
+
// 否则 Vite alias 与 Node 条件导出会创建两个 Phaser 单例,Scene 无法启动。
|
|
448
|
+
ssr: {
|
|
449
|
+
external: ["phaser"]
|
|
450
|
+
},
|
|
352
451
|
test: {
|
|
353
452
|
include: ["tests/**/*.test.ts"],
|
|
354
453
|
testTimeout: options.testTimeout,
|
|
@@ -358,7 +457,8 @@ function defineGameVitestConfig(options) {
|
|
|
358
457
|
// devkit 模块需要在配置和审计阶段使用 Node 内置模块。
|
|
359
458
|
// 如果强制内联,Vite 会把这些模块按浏览器模块转换,
|
|
360
459
|
// 最终触发 "No such built-in module: node:"。
|
|
361
|
-
external: [/miaoda-game-devkit/]
|
|
460
|
+
external: [/miaoda-game-devkit/],
|
|
461
|
+
inline: options.inlineDependencies
|
|
362
462
|
}
|
|
363
463
|
},
|
|
364
464
|
environment: "jsdom",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "miaoda-game-devkit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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,6 +43,18 @@
|
|
|
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
|
+
},
|
|
46
58
|
"nx": {
|
|
47
59
|
"tags": [
|
|
48
60
|
"type:package",
|
|
@@ -82,8 +94,23 @@
|
|
|
82
94
|
"peerDependencies": {
|
|
83
95
|
"phaser": ">=4.0.0 <5"
|
|
84
96
|
},
|
|
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
|
+
},
|
|
85
106
|
"publishConfig": {
|
|
86
107
|
"registry": "https://registry.npmjs.org/",
|
|
87
108
|
"access": "public"
|
|
109
|
+
},
|
|
110
|
+
"clean-publish": {
|
|
111
|
+
"fields": [
|
|
112
|
+
"devDependencies",
|
|
113
|
+
"scripts"
|
|
114
|
+
]
|
|
88
115
|
}
|
|
89
116
|
}
|