miaoda-game-devkit 0.6.0 → 0.6.2

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.
@@ -3,23 +3,185 @@
3
3
 
4
4
  // src/cli/lint.ts
5
5
  var import_node_child_process = require("child_process");
6
- var import_node_fs = require("fs");
6
+ var import_node_fs2 = require("fs");
7
7
  var import_node_module = require("module");
8
8
  var import_node_os = require("os");
9
+ var import_node_path2 = require("path");
10
+
11
+ // src/cli/react-authoritative-playthrough.ts
12
+ var import_node_fs = require("fs");
9
13
  var import_node_path = require("path");
10
- var devkitRoot = (0, import_node_path.resolve)(__dirname, "../..");
14
+ var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
15
+ var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
16
+ var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
17
+ var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
18
+ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
19
+ "useGameController",
20
+ "useOwnedGameController"
21
+ ]);
22
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
23
+ function extension(path) {
24
+ const index = path.lastIndexOf(".");
25
+ return index < 0 ? "" : path.slice(index);
26
+ }
27
+ function sourceFiles(root, directory = (0, import_node_path.join)(root, "src")) {
28
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
29
+ const files = [];
30
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
31
+ const path = (0, import_node_path.join)(directory, entry.name);
32
+ const projectPath = (0, import_node_path.relative)(root, path).replaceAll("\\", "/");
33
+ if (entry.isDirectory()) {
34
+ if (projectPath === "src/game/example") continue;
35
+ files.push(...sourceFiles(root, path));
36
+ } else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
37
+ files.push(path);
38
+ }
39
+ }
40
+ return files;
41
+ }
42
+ function withoutComments(source) {
43
+ let output = "";
44
+ let state = "code";
45
+ for (let index = 0; index < source.length; index += 1) {
46
+ const char = source[index];
47
+ const next = source[index + 1];
48
+ if (state === "line") {
49
+ if (char === "\n") {
50
+ state = "code";
51
+ output += char;
52
+ } else {
53
+ output += " ";
54
+ }
55
+ continue;
56
+ }
57
+ if (state === "block") {
58
+ if (char === "*" && next === "/") {
59
+ output += " ";
60
+ index += 1;
61
+ state = "code";
62
+ } else {
63
+ output += char === "\n" ? "\n" : " ";
64
+ }
65
+ continue;
66
+ }
67
+ if (state === "code" && char === "/" && next === "/") {
68
+ output += " ";
69
+ index += 1;
70
+ state = "line";
71
+ continue;
72
+ }
73
+ if (state === "code" && char === "/" && next === "*") {
74
+ output += " ";
75
+ index += 1;
76
+ state = "block";
77
+ continue;
78
+ }
79
+ if (state === "code" && char === "'") state = "single";
80
+ else if (state === "code" && char === '"') state = "double";
81
+ else if (state === "code" && char === "`") state = "template";
82
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
83
+ state = "code";
84
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
85
+ state = "code";
86
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
87
+ state = "code";
88
+ }
89
+ output += char;
90
+ }
91
+ return output;
92
+ }
93
+ function codePositions(source) {
94
+ const positions = Array.from({ length: source.length }, () => false);
95
+ let state = "code";
96
+ for (let index = 0; index < source.length; index += 1) {
97
+ const char = source[index];
98
+ if (state === "code") positions[index] = true;
99
+ if (state === "code" && char === "'") state = "single";
100
+ else if (state === "code" && char === '"') state = "double";
101
+ else if (state === "code" && char === "`") state = "template";
102
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
103
+ state = "code";
104
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
105
+ state = "code";
106
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
107
+ state = "code";
108
+ }
109
+ }
110
+ return positions;
111
+ }
112
+ function namedImports(source, moduleName) {
113
+ const names = /* @__PURE__ */ new Set();
114
+ const clean = withoutComments(source);
115
+ const positions = codePositions(clean);
116
+ const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
117
+ const pattern = new RegExp(
118
+ `^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
119
+ "gm"
120
+ );
121
+ for (const match of clean.matchAll(pattern)) {
122
+ if (!positions[match.index]) continue;
123
+ for (const specifier of match[1].split(",")) {
124
+ const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
125
+ if (imported) names.add(imported);
126
+ }
127
+ }
128
+ return names;
129
+ }
130
+ function containsAny(values, expected) {
131
+ return [...values].some((value) => expected.has(value));
132
+ }
133
+ function declaresObserve(source) {
134
+ const clean = withoutComments(source);
135
+ const positions = codePositions(clean);
136
+ for (const match of clean.matchAll(/\bobserve\s*:/g)) {
137
+ if (positions[match.index]) return true;
138
+ }
139
+ return false;
140
+ }
141
+ function auditReactAuthoritativePlaythrough(projectRoot2) {
142
+ const clockFiles = [];
143
+ const controllerFiles = [];
144
+ for (const file of sourceFiles(projectRoot2)) {
145
+ const imports = namedImports((0, import_node_fs.readFileSync)(file, "utf8"), REACT_RUNTIME_ENTRY);
146
+ const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
147
+ if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
148
+ if (containsAny(imports, CONTROLLER_IMPORTS)) controllerFiles.push(projectPath);
149
+ }
150
+ const issues = [];
151
+ if (clockFiles.length === 0 && controllerFiles.length === 0) {
152
+ return { ok: true, issues, clockFiles, controllerFiles };
153
+ }
154
+ const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
155
+ const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
156
+ const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
157
+ const hasObserve = declaresObserve(testSource);
158
+ if (!hasObserve) {
159
+ issues.push(
160
+ `${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Observe the same production Controller rendered by <App /> through Telemetry; DOM labels are not an authoritative gameplay boundary.`
161
+ );
162
+ }
163
+ if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
164
+ issues.push(
165
+ `${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Inject it through the production <App /> factory and advance it with a non-empty deterministic step.`
166
+ );
167
+ }
168
+ return { ok: issues.length === 0, issues, clockFiles, controllerFiles };
169
+ }
170
+
171
+ // src/cli/lint.ts
172
+ var devkitRoot = (0, import_node_path2.resolve)(__dirname, "../..");
11
173
  var projectRoot = process.cwd();
12
- var requireFromDevkit = (0, import_node_module.createRequire)((0, import_node_path.join)(devkitRoot, "package.json"));
13
- var projectBiomeConfig = (0, import_node_path.join)(projectRoot, "biome.json");
14
- var biomeConfig = (0, import_node_fs.existsSync)(projectBiomeConfig) ? projectBiomeConfig : (0, import_node_path.join)(devkitRoot, "biome-config.json");
174
+ var requireFromDevkit = (0, import_node_module.createRequire)((0, import_node_path2.join)(devkitRoot, "package.json"));
175
+ var projectBiomeConfig = (0, import_node_path2.join)(projectRoot, "biome.json");
176
+ var biomeConfig = (0, import_node_fs2.existsSync)(projectBiomeConfig) ? projectBiomeConfig : (0, import_node_path2.join)(devkitRoot, "biome-config.json");
15
177
  function resolveBin(packageName, binName) {
16
178
  const packageJsonPath = requireFromDevkit.resolve(`${packageName}/package.json`);
17
- const packageJson = JSON.parse((0, import_node_fs.readFileSync)(packageJsonPath, "utf8"));
179
+ const packageJson = JSON.parse((0, import_node_fs2.readFileSync)(packageJsonPath, "utf8"));
18
180
  const relativeBin = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.[binName];
19
181
  if (!relativeBin) {
20
182
  throw new Error(`${packageName} does not expose the ${binName} executable`);
21
183
  }
22
- return (0, import_node_path.resolve)((0, import_node_path.dirname)(packageJsonPath), relativeBin);
184
+ return (0, import_node_path2.resolve)((0, import_node_path2.dirname)(packageJsonPath), relativeBin);
23
185
  }
24
186
  function run(name, packageName, binName, args) {
25
187
  return new Promise((resolveResult) => {
@@ -41,21 +203,21 @@ function run(name, packageName, binName, args) {
41
203
  });
42
204
  }
43
205
  function readProjectSource(path, checkName) {
44
- const absolutePath = (0, import_node_path.join)(projectRoot, path);
45
- if (!(0, import_node_fs.existsSync)(absolutePath)) {
206
+ const absolutePath = (0, import_node_path2.join)(projectRoot, path);
207
+ if (!(0, import_node_fs2.existsSync)(absolutePath)) {
46
208
  console.error(`[${checkName}] \u7F3A\u5C11\u6A21\u677F\u5FC5\u9700\u7684 ${path}\u3002\u8BF7\u6062\u590D\u6A21\u677F\u539F\u6587\u4EF6\u540E\u91CD\u65B0\u8FD0\u884C pnpm lint\u3002`);
47
209
  return void 0;
48
210
  }
49
- return (0, import_node_fs.readFileSync)(absolutePath, "utf8");
211
+ return (0, import_node_fs2.readFileSync)(absolutePath, "utf8");
50
212
  }
51
213
  function readProjectManifest() {
52
- const manifestPath = (0, import_node_path.join)(projectRoot, "package.json");
53
- if (!(0, import_node_fs.existsSync)(manifestPath)) {
214
+ const manifestPath = (0, import_node_path2.join)(projectRoot, "package.json");
215
+ if (!(0, import_node_fs2.existsSync)(manifestPath)) {
54
216
  console.error("[phaser-version] \u7F3A\u5C11\u9879\u76EE package.json\u3002");
55
217
  return void 0;
56
218
  }
57
219
  try {
58
- return JSON.parse((0, import_node_fs.readFileSync)(manifestPath, "utf8"));
220
+ return JSON.parse((0, import_node_fs2.readFileSync)(manifestPath, "utf8"));
59
221
  } catch (error) {
60
222
  console.error(`[phaser-version] \u65E0\u6CD5\u89E3\u6790 package.json\uFF1A${String(error)}`);
61
223
  return void 0;
@@ -113,9 +275,15 @@ function checkVitestConfig(target) {
113
275
  }
114
276
  return { name, ok: true };
115
277
  }
278
+ function checkReactAuthoritativePlaythrough() {
279
+ const name = "react-authoritative-playthrough";
280
+ const audit = auditReactAuthoritativePlaythrough(projectRoot);
281
+ for (const issue of audit.issues) console.error(`[${name}] ${issue}`);
282
+ return { name, ok: audit.ok };
283
+ }
116
284
  async function runAllChecks(target) {
117
- const targetChecks = target === "react" ? [checkVitestConfig("react")] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
118
- const lintRoots = target === "react" && (0, import_node_fs.existsSync)((0, import_node_path.join)(projectRoot, "tests")) ? ["src", "tests"] : ["src"];
285
+ const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactAuthoritativePlaythrough()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
286
+ const lintRoots = target === "react" && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(projectRoot, "tests")) ? ["src", "tests"] : ["src"];
119
287
  const [tsgo, biome] = await Promise.all([
120
288
  run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
121
289
  run("biome", "@biomejs/biome", "biome", [
@@ -125,17 +293,17 @@ async function runAllChecks(target) {
125
293
  ...lintRoots
126
294
  ])
127
295
  ]);
128
- const tailwindOutput = (0, import_node_path.join)((0, import_node_os.tmpdir)(), `miaoda-game-devkit-${process.pid}.css`);
296
+ const tailwindOutput = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `miaoda-game-devkit-${process.pid}.css`);
129
297
  const tailwind = await run("tailwind", "tailwindcss", "tailwindcss", [
130
298
  "-i",
131
- (0, import_node_path.join)(projectRoot, "src/index.css"),
299
+ (0, import_node_path2.join)(projectRoot, "src/index.css"),
132
300
  "-o",
133
301
  tailwindOutput
134
302
  ]);
135
- (0, import_node_fs.rmSync)(tailwindOutput, { force: true });
303
+ (0, import_node_fs2.rmSync)(tailwindOutput, { force: true });
136
304
  const oxlint = await run("oxlint", "oxlint", "oxlint", [
137
305
  "-c",
138
- (0, import_node_path.join)(devkitRoot, "oxlint-config.json"),
306
+ (0, import_node_path2.join)(devkitRoot, "oxlint-config.json"),
139
307
  ...lintRoots
140
308
  ]);
141
309
  return [...targetChecks, tsgo, biome, tailwind, oxlint];
@@ -3,23 +3,185 @@
3
3
 
4
4
  // src/cli/lint.ts
5
5
  var import_node_child_process = require("child_process");
6
- var import_node_fs = require("fs");
6
+ var import_node_fs2 = require("fs");
7
7
  var import_node_module = require("module");
8
8
  var import_node_os = require("os");
9
+ var import_node_path2 = require("path");
10
+
11
+ // src/cli/react-authoritative-playthrough.ts
12
+ var import_node_fs = require("fs");
9
13
  var import_node_path = require("path");
10
- var devkitRoot = (0, import_node_path.resolve)(__dirname, "../..");
14
+ var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
15
+ var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
16
+ var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
17
+ var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
18
+ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
19
+ "useGameController",
20
+ "useOwnedGameController"
21
+ ]);
22
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
23
+ function extension(path) {
24
+ const index = path.lastIndexOf(".");
25
+ return index < 0 ? "" : path.slice(index);
26
+ }
27
+ function sourceFiles(root, directory = (0, import_node_path.join)(root, "src")) {
28
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
29
+ const files = [];
30
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
31
+ const path = (0, import_node_path.join)(directory, entry.name);
32
+ const projectPath = (0, import_node_path.relative)(root, path).replaceAll("\\", "/");
33
+ if (entry.isDirectory()) {
34
+ if (projectPath === "src/game/example") continue;
35
+ files.push(...sourceFiles(root, path));
36
+ } else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
37
+ files.push(path);
38
+ }
39
+ }
40
+ return files;
41
+ }
42
+ function withoutComments(source) {
43
+ let output = "";
44
+ let state = "code";
45
+ for (let index = 0; index < source.length; index += 1) {
46
+ const char = source[index];
47
+ const next = source[index + 1];
48
+ if (state === "line") {
49
+ if (char === "\n") {
50
+ state = "code";
51
+ output += char;
52
+ } else {
53
+ output += " ";
54
+ }
55
+ continue;
56
+ }
57
+ if (state === "block") {
58
+ if (char === "*" && next === "/") {
59
+ output += " ";
60
+ index += 1;
61
+ state = "code";
62
+ } else {
63
+ output += char === "\n" ? "\n" : " ";
64
+ }
65
+ continue;
66
+ }
67
+ if (state === "code" && char === "/" && next === "/") {
68
+ output += " ";
69
+ index += 1;
70
+ state = "line";
71
+ continue;
72
+ }
73
+ if (state === "code" && char === "/" && next === "*") {
74
+ output += " ";
75
+ index += 1;
76
+ state = "block";
77
+ continue;
78
+ }
79
+ if (state === "code" && char === "'") state = "single";
80
+ else if (state === "code" && char === '"') state = "double";
81
+ else if (state === "code" && char === "`") state = "template";
82
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
83
+ state = "code";
84
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
85
+ state = "code";
86
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
87
+ state = "code";
88
+ }
89
+ output += char;
90
+ }
91
+ return output;
92
+ }
93
+ function codePositions(source) {
94
+ const positions = Array.from({ length: source.length }, () => false);
95
+ let state = "code";
96
+ for (let index = 0; index < source.length; index += 1) {
97
+ const char = source[index];
98
+ if (state === "code") positions[index] = true;
99
+ if (state === "code" && char === "'") state = "single";
100
+ else if (state === "code" && char === '"') state = "double";
101
+ else if (state === "code" && char === "`") state = "template";
102
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
103
+ state = "code";
104
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
105
+ state = "code";
106
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
107
+ state = "code";
108
+ }
109
+ }
110
+ return positions;
111
+ }
112
+ function namedImports(source, moduleName) {
113
+ const names = /* @__PURE__ */ new Set();
114
+ const clean = withoutComments(source);
115
+ const positions = codePositions(clean);
116
+ const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
117
+ const pattern = new RegExp(
118
+ `^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
119
+ "gm"
120
+ );
121
+ for (const match of clean.matchAll(pattern)) {
122
+ if (!positions[match.index]) continue;
123
+ for (const specifier of match[1].split(",")) {
124
+ const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
125
+ if (imported) names.add(imported);
126
+ }
127
+ }
128
+ return names;
129
+ }
130
+ function containsAny(values, expected) {
131
+ return [...values].some((value) => expected.has(value));
132
+ }
133
+ function declaresObserve(source) {
134
+ const clean = withoutComments(source);
135
+ const positions = codePositions(clean);
136
+ for (const match of clean.matchAll(/\bobserve\s*:/g)) {
137
+ if (positions[match.index]) return true;
138
+ }
139
+ return false;
140
+ }
141
+ function auditReactAuthoritativePlaythrough(projectRoot2) {
142
+ const clockFiles = [];
143
+ const controllerFiles = [];
144
+ for (const file of sourceFiles(projectRoot2)) {
145
+ const imports = namedImports((0, import_node_fs.readFileSync)(file, "utf8"), REACT_RUNTIME_ENTRY);
146
+ const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
147
+ if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
148
+ if (containsAny(imports, CONTROLLER_IMPORTS)) controllerFiles.push(projectPath);
149
+ }
150
+ const issues = [];
151
+ if (clockFiles.length === 0 && controllerFiles.length === 0) {
152
+ return { ok: true, issues, clockFiles, controllerFiles };
153
+ }
154
+ const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
155
+ const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
156
+ const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
157
+ const hasObserve = declaresObserve(testSource);
158
+ if (!hasObserve) {
159
+ issues.push(
160
+ `${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Observe the same production Controller rendered by <App /> through Telemetry; DOM labels are not an authoritative gameplay boundary.`
161
+ );
162
+ }
163
+ if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
164
+ issues.push(
165
+ `${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Inject it through the production <App /> factory and advance it with a non-empty deterministic step.`
166
+ );
167
+ }
168
+ return { ok: issues.length === 0, issues, clockFiles, controllerFiles };
169
+ }
170
+
171
+ // src/cli/lint.ts
172
+ var devkitRoot = (0, import_node_path2.resolve)(__dirname, "../..");
11
173
  var projectRoot = process.cwd();
12
- var requireFromDevkit = (0, import_node_module.createRequire)((0, import_node_path.join)(devkitRoot, "package.json"));
13
- var projectBiomeConfig = (0, import_node_path.join)(projectRoot, "biome.json");
14
- var biomeConfig = (0, import_node_fs.existsSync)(projectBiomeConfig) ? projectBiomeConfig : (0, import_node_path.join)(devkitRoot, "biome-config.json");
174
+ var requireFromDevkit = (0, import_node_module.createRequire)((0, import_node_path2.join)(devkitRoot, "package.json"));
175
+ var projectBiomeConfig = (0, import_node_path2.join)(projectRoot, "biome.json");
176
+ var biomeConfig = (0, import_node_fs2.existsSync)(projectBiomeConfig) ? projectBiomeConfig : (0, import_node_path2.join)(devkitRoot, "biome-config.json");
15
177
  function resolveBin(packageName, binName) {
16
178
  const packageJsonPath = requireFromDevkit.resolve(`${packageName}/package.json`);
17
- const packageJson = JSON.parse((0, import_node_fs.readFileSync)(packageJsonPath, "utf8"));
179
+ const packageJson = JSON.parse((0, import_node_fs2.readFileSync)(packageJsonPath, "utf8"));
18
180
  const relativeBin = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.[binName];
19
181
  if (!relativeBin) {
20
182
  throw new Error(`${packageName} does not expose the ${binName} executable`);
21
183
  }
22
- return (0, import_node_path.resolve)((0, import_node_path.dirname)(packageJsonPath), relativeBin);
184
+ return (0, import_node_path2.resolve)((0, import_node_path2.dirname)(packageJsonPath), relativeBin);
23
185
  }
24
186
  function run(name, packageName, binName, args) {
25
187
  return new Promise((resolveResult) => {
@@ -41,21 +203,21 @@ function run(name, packageName, binName, args) {
41
203
  });
42
204
  }
43
205
  function readProjectSource(path, checkName) {
44
- const absolutePath = (0, import_node_path.join)(projectRoot, path);
45
- if (!(0, import_node_fs.existsSync)(absolutePath)) {
206
+ const absolutePath = (0, import_node_path2.join)(projectRoot, path);
207
+ if (!(0, import_node_fs2.existsSync)(absolutePath)) {
46
208
  console.error(`[${checkName}] \u7F3A\u5C11\u6A21\u677F\u5FC5\u9700\u7684 ${path}\u3002\u8BF7\u6062\u590D\u6A21\u677F\u539F\u6587\u4EF6\u540E\u91CD\u65B0\u8FD0\u884C pnpm lint\u3002`);
47
209
  return void 0;
48
210
  }
49
- return (0, import_node_fs.readFileSync)(absolutePath, "utf8");
211
+ return (0, import_node_fs2.readFileSync)(absolutePath, "utf8");
50
212
  }
51
213
  function readProjectManifest() {
52
- const manifestPath = (0, import_node_path.join)(projectRoot, "package.json");
53
- if (!(0, import_node_fs.existsSync)(manifestPath)) {
214
+ const manifestPath = (0, import_node_path2.join)(projectRoot, "package.json");
215
+ if (!(0, import_node_fs2.existsSync)(manifestPath)) {
54
216
  console.error("[phaser-version] \u7F3A\u5C11\u9879\u76EE package.json\u3002");
55
217
  return void 0;
56
218
  }
57
219
  try {
58
- return JSON.parse((0, import_node_fs.readFileSync)(manifestPath, "utf8"));
220
+ return JSON.parse((0, import_node_fs2.readFileSync)(manifestPath, "utf8"));
59
221
  } catch (error) {
60
222
  console.error(`[phaser-version] \u65E0\u6CD5\u89E3\u6790 package.json\uFF1A${String(error)}`);
61
223
  return void 0;
@@ -113,9 +275,15 @@ function checkVitestConfig(target) {
113
275
  }
114
276
  return { name, ok: true };
115
277
  }
278
+ function checkReactAuthoritativePlaythrough() {
279
+ const name = "react-authoritative-playthrough";
280
+ const audit = auditReactAuthoritativePlaythrough(projectRoot);
281
+ for (const issue of audit.issues) console.error(`[${name}] ${issue}`);
282
+ return { name, ok: audit.ok };
283
+ }
116
284
  async function runAllChecks(target) {
117
- const targetChecks = target === "react" ? [checkVitestConfig("react")] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
118
- const lintRoots = target === "react" && (0, import_node_fs.existsSync)((0, import_node_path.join)(projectRoot, "tests")) ? ["src", "tests"] : ["src"];
285
+ const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactAuthoritativePlaythrough()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
286
+ const lintRoots = target === "react" && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(projectRoot, "tests")) ? ["src", "tests"] : ["src"];
119
287
  const [tsgo, biome] = await Promise.all([
120
288
  run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
121
289
  run("biome", "@biomejs/biome", "biome", [
@@ -125,17 +293,17 @@ async function runAllChecks(target) {
125
293
  ...lintRoots
126
294
  ])
127
295
  ]);
128
- const tailwindOutput = (0, import_node_path.join)((0, import_node_os.tmpdir)(), `miaoda-game-devkit-${process.pid}.css`);
296
+ const tailwindOutput = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `miaoda-game-devkit-${process.pid}.css`);
129
297
  const tailwind = await run("tailwind", "tailwindcss", "tailwindcss", [
130
298
  "-i",
131
- (0, import_node_path.join)(projectRoot, "src/index.css"),
299
+ (0, import_node_path2.join)(projectRoot, "src/index.css"),
132
300
  "-o",
133
301
  tailwindOutput
134
302
  ]);
135
- (0, import_node_fs.rmSync)(tailwindOutput, { force: true });
303
+ (0, import_node_fs2.rmSync)(tailwindOutput, { force: true });
136
304
  const oxlint = await run("oxlint", "oxlint", "oxlint", [
137
305
  "-c",
138
- (0, import_node_path.join)(devkitRoot, "oxlint-config.json"),
306
+ (0, import_node_path2.join)(devkitRoot, "oxlint-config.json"),
139
307
  ...lintRoots
140
308
  ]);
141
309
  return [...targetChecks, tsgo, biome, tailwind, oxlint];
@@ -63,7 +63,14 @@ interface ReactPlaythroughMetadata {
63
63
  evidence: ReactPlaythroughEvidence;
64
64
  }
65
65
  interface ReactPlaythroughOptions {
66
- /** Canvas/Controller flows expose one stable authoritative production read. */
66
+ /**
67
+ * Read authoritative production gameplay state when the DOM cannot prove the
68
+ * result, such as Canvas, Controller, time, or physics flows. The harness
69
+ * samples this state before and after each stage, not after every step.
70
+ * Individual steps may leave it unchanged, but the completed stage must
71
+ * produce a new gameplay result. Observation supplements real production
72
+ * input; it does not replace it.
73
+ */
67
74
  observe: () => unknown;
68
75
  }
69
76
  interface ReactPlaythroughAssertContext {
@@ -63,7 +63,14 @@ interface ReactPlaythroughMetadata {
63
63
  evidence: ReactPlaythroughEvidence;
64
64
  }
65
65
  interface ReactPlaythroughOptions {
66
- /** Canvas/Controller flows expose one stable authoritative production read. */
66
+ /**
67
+ * Read authoritative production gameplay state when the DOM cannot prove the
68
+ * result, such as Canvas, Controller, time, or physics flows. The harness
69
+ * samples this state before and after each stage, not after every step.
70
+ * Individual steps may leave it unchanged, but the completed stage must
71
+ * produce a new gameplay result. Observation supplements real production
72
+ * input; it does not replace it.
73
+ */
67
74
  observe: () => unknown;
68
75
  }
69
76
  interface ReactPlaythroughAssertContext {
@@ -164,6 +164,11 @@ var MIN_STAGES = 5;
164
164
  var MIN_MILESTONES = 3;
165
165
  var MAX_TRACE_VALUE_LENGTH = 140;
166
166
  var MAX_TRACE_LENGTH = 720;
167
+ function eventTargetsCanvas(event) {
168
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
169
+ if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
170
+ return event.target instanceof HTMLCanvasElement;
171
+ }
167
172
  var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
168
173
  function truncateTraceValue(value, limit) {
169
174
  const compact = value.replace(/\s+/g, " ").trim();
@@ -257,9 +262,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
257
262
  let stepTrace;
258
263
  let failureTraceFactory;
259
264
  let acceptingStageInput = false;
265
+ let activeStageTargetedCanvas = false;
260
266
  let inputCaptureAttached = false;
261
- const recordInput = () => {
262
- if (acceptingStageInput) evidence.domInputEvents += 1;
267
+ const recordInput = (event) => {
268
+ if (!acceptingStageInput) return;
269
+ evidence.domInputEvents += 1;
270
+ activeStageTargetedCanvas ||= eventTargetsCanvas(event);
263
271
  };
264
272
  const stopInputCapture = () => {
265
273
  acceptingStageInput = false;
@@ -333,12 +341,18 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
333
341
  const before = lastSample ?? sampleState(`before ${normalizedName}`);
334
342
  activeStage = { name: normalizedName, kind, before };
335
343
  stepTrace = { bound: stage.maxSteps ?? 120 };
344
+ if (stage.step && !playthroughOptions?.observe) {
345
+ throw new Error(
346
+ `${stageLabel(kind, normalizedName)} uses deterministic step advancement without an authoritative observe callback. Time- or frame-driven stages must observe the same production Controller that <App /> renders through Telemetry; DOM labels alone are not deterministic gameplay state.`
347
+ );
348
+ }
336
349
  if (stage.until()) {
337
350
  throw new Error(
338
351
  `${stageLabel(kind, normalizedName)} until condition must be false before its driver runs. Wait for a result caused by this stage, not state left by an earlier stage.`
339
352
  );
340
353
  }
341
354
  const inputsBefore = evidence.domInputEvents;
355
+ activeStageTargetedCanvas = false;
342
356
  if (stage.act) {
343
357
  acceptingStageInput = true;
344
358
  try {
@@ -351,6 +365,11 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
351
365
  `${stageLabel(kind, normalizedName)} act did not dispatch a supported production DOM input. Use the provided user to click, type, press, point, or touch the production target; do not call Controller commands directly.`
352
366
  );
353
367
  }
368
+ if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
369
+ throw new Error(
370
+ `${stageLabel(kind, normalizedName)} dispatched production input to Canvas without an authoritative observe callback. Canvas pixels and control labels are not gameplay state; observe the same production Controller that <App /> renders through Telemetry.`
371
+ );
372
+ }
354
373
  }
355
374
  let advancedSteps = 0;
356
375
  const stepBound = stage.maxSteps ?? 120;
@@ -126,6 +126,11 @@ var MIN_STAGES = 5;
126
126
  var MIN_MILESTONES = 3;
127
127
  var MAX_TRACE_VALUE_LENGTH = 140;
128
128
  var MAX_TRACE_LENGTH = 720;
129
+ function eventTargetsCanvas(event) {
130
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
131
+ if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
132
+ return event.target instanceof HTMLCanvasElement;
133
+ }
129
134
  var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
130
135
  function truncateTraceValue(value, limit) {
131
136
  const compact = value.replace(/\s+/g, " ").trim();
@@ -219,9 +224,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
219
224
  let stepTrace;
220
225
  let failureTraceFactory;
221
226
  let acceptingStageInput = false;
227
+ let activeStageTargetedCanvas = false;
222
228
  let inputCaptureAttached = false;
223
- const recordInput = () => {
224
- if (acceptingStageInput) evidence.domInputEvents += 1;
229
+ const recordInput = (event) => {
230
+ if (!acceptingStageInput) return;
231
+ evidence.domInputEvents += 1;
232
+ activeStageTargetedCanvas ||= eventTargetsCanvas(event);
225
233
  };
226
234
  const stopInputCapture = () => {
227
235
  acceptingStageInput = false;
@@ -295,12 +303,18 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
295
303
  const before = lastSample ?? sampleState(`before ${normalizedName}`);
296
304
  activeStage = { name: normalizedName, kind, before };
297
305
  stepTrace = { bound: stage.maxSteps ?? 120 };
306
+ if (stage.step && !playthroughOptions?.observe) {
307
+ throw new Error(
308
+ `${stageLabel(kind, normalizedName)} uses deterministic step advancement without an authoritative observe callback. Time- or frame-driven stages must observe the same production Controller that <App /> renders through Telemetry; DOM labels alone are not deterministic gameplay state.`
309
+ );
310
+ }
298
311
  if (stage.until()) {
299
312
  throw new Error(
300
313
  `${stageLabel(kind, normalizedName)} until condition must be false before its driver runs. Wait for a result caused by this stage, not state left by an earlier stage.`
301
314
  );
302
315
  }
303
316
  const inputsBefore = evidence.domInputEvents;
317
+ activeStageTargetedCanvas = false;
304
318
  if (stage.act) {
305
319
  acceptingStageInput = true;
306
320
  try {
@@ -313,6 +327,11 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
313
327
  `${stageLabel(kind, normalizedName)} act did not dispatch a supported production DOM input. Use the provided user to click, type, press, point, or touch the production target; do not call Controller commands directly.`
314
328
  );
315
329
  }
330
+ if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
331
+ throw new Error(
332
+ `${stageLabel(kind, normalizedName)} dispatched production input to Canvas without an authoritative observe callback. Canvas pixels and control labels are not gameplay state; observe the same production Controller that <App /> renders through Telemetry.`
333
+ );
334
+ }
316
335
  }
317
336
  let advancedSteps = 0;
318
337
  const stepBound = stage.maxSteps ?? 120;
@@ -115,6 +115,11 @@ var MIN_STAGES = 5;
115
115
  var MIN_MILESTONES = 3;
116
116
  var MAX_TRACE_VALUE_LENGTH = 140;
117
117
  var MAX_TRACE_LENGTH = 720;
118
+ function eventTargetsCanvas(event) {
119
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
120
+ if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
121
+ return event.target instanceof HTMLCanvasElement;
122
+ }
118
123
  var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
119
124
  function truncateTraceValue(value, limit) {
120
125
  const compact = value.replace(/\s+/g, " ").trim();
@@ -208,9 +213,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
208
213
  let stepTrace;
209
214
  let failureTraceFactory;
210
215
  let acceptingStageInput = false;
216
+ let activeStageTargetedCanvas = false;
211
217
  let inputCaptureAttached = false;
212
- const recordInput = () => {
213
- if (acceptingStageInput) evidence.domInputEvents += 1;
218
+ const recordInput = (event) => {
219
+ if (!acceptingStageInput) return;
220
+ evidence.domInputEvents += 1;
221
+ activeStageTargetedCanvas ||= eventTargetsCanvas(event);
214
222
  };
215
223
  const stopInputCapture = () => {
216
224
  acceptingStageInput = false;
@@ -284,12 +292,18 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
284
292
  const before = lastSample ?? sampleState(`before ${normalizedName}`);
285
293
  activeStage = { name: normalizedName, kind, before };
286
294
  stepTrace = { bound: stage.maxSteps ?? 120 };
295
+ if (stage.step && !playthroughOptions?.observe) {
296
+ throw new Error(
297
+ `${stageLabel(kind, normalizedName)} uses deterministic step advancement without an authoritative observe callback. Time- or frame-driven stages must observe the same production Controller that <App /> renders through Telemetry; DOM labels alone are not deterministic gameplay state.`
298
+ );
299
+ }
287
300
  if (stage.until()) {
288
301
  throw new Error(
289
302
  `${stageLabel(kind, normalizedName)} until condition must be false before its driver runs. Wait for a result caused by this stage, not state left by an earlier stage.`
290
303
  );
291
304
  }
292
305
  const inputsBefore = evidence.domInputEvents;
306
+ activeStageTargetedCanvas = false;
293
307
  if (stage.act) {
294
308
  acceptingStageInput = true;
295
309
  try {
@@ -302,6 +316,11 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
302
316
  `${stageLabel(kind, normalizedName)} act did not dispatch a supported production DOM input. Use the provided user to click, type, press, point, or touch the production target; do not call Controller commands directly.`
303
317
  );
304
318
  }
319
+ if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
320
+ throw new Error(
321
+ `${stageLabel(kind, normalizedName)} dispatched production input to Canvas without an authoritative observe callback. Canvas pixels and control labels are not gameplay state; observe the same production Controller that <App /> renders through Telemetry.`
322
+ );
323
+ }
305
324
  }
306
325
  let advancedSteps = 0;
307
326
  const stepBound = stage.maxSteps ?? 120;
@@ -481,6 +500,7 @@ function auditReactPlaythroughRun(tests) {
481
500
 
482
501
  // src/react/react-playthrough-reporter.ts
483
502
  var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
503
+ var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the production mechanic, deterministic driver, or authoritative observation that prevents it. Do not make the test pass by weakening or deleting assertions, replacing the outcome with back/menu/exit navigation, observing arbitrary UI text only to change a fingerprint, using a no-op step, or treating an intermediate active/in-flight/running phase as meaningful progress.";
484
504
  function isMetadata(value) {
485
505
  if (!value || typeof value !== "object") return false;
486
506
  const metadata = value;
@@ -595,17 +615,23 @@ function repairGuidance(cause) {
595
615
  if (/snapshot\(\) returned the same reference/i.test(cause)) {
596
616
  return "The game mutated state without publishing a new snapshot reference, so React skipped the render after an Object.is comparison. Publish a new top-level object before notifying subscribers, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot.";
597
617
  }
618
+ if (/deterministic step advancement without an authoritative observe callback/i.test(cause)) {
619
+ return "This stage advances production time or frames, so DOM text is not a sufficient state boundary. Keep the intended outcome, inject ManualGameClock through the production <App /> factory, and make observe read the same production Controller through Telemetry. Do not remove step or replace the outcome with an immediate UI transition.";
620
+ }
621
+ if (/production input to Canvas without an authoritative observe callback/i.test(cause)) {
622
+ return "The player input reached the production Canvas, but JSDOM cannot verify its pixels. Keep the real Canvas input and make observe read the authoritative state from the same production Controller rendered by <App /> through Telemetry. Do not replace Canvas input with a test-only Controller command or button-label assertion.";
623
+ }
598
624
  if (/until condition must be false before its driver runs/i.test(cause)) {
599
625
  return "Make this stage's until condition describe a new result that does not exist before act or step runs. Do not reuse state completed by an earlier milestone.";
600
626
  }
601
627
  if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
602
- return "The stage ran and asserted, but its observable state matched the previous milestone. Wait for a genuinely new gameplay result. Canvas or Controller games should make observe read the same production Controller that React renders.";
628
+ return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe that result directly. Canvas or Controller games should make observe read the same production Controller that React renders. Do not substitute arbitrary labels, button visibility, navigation, or another weaker state change merely to produce a different fingerprint.";
603
629
  }
604
630
  if (/No step callback was provided/i.test(cause)) {
605
631
  return "This stage is driven by time or frames, but it did not advance the game clock. Inject the devkit GameClock into the production game and pass step: () => clock.stepFrame(). Do not replace deterministic advancement with a real setTimeout.";
606
632
  }
607
633
  if (/outcome was not reached within \d+ steps/i.test(cause)) {
608
- return "The stage driver ran, but gameplay did not reach the outcome within the bound. Confirm that its production input or deterministic step changed the intended rule state, then inspect Last diagnostics to locate the stalled stage.";
634
+ return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
609
635
  }
610
636
  if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
611
637
  return "Compose one enter stage, at least three named gameplay milestones, and one finish stage. Enter must drive real DOM input; each later stage must drive input or deterministic steps. Every stage waits for a bounded new result and asserts it with the provided expect.";
@@ -683,6 +709,9 @@ function formatReactPlaythroughReport(report) {
683
709
  lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
684
710
  }
685
711
  if (report.next) lines.push(`NEXT: ${report.next}`);
712
+ if (report.status === "FAILED") {
713
+ lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
714
+ }
686
715
  return `
687
716
  ${lines.join("\n")}`;
688
717
  }
@@ -81,6 +81,11 @@ var MIN_STAGES = 5;
81
81
  var MIN_MILESTONES = 3;
82
82
  var MAX_TRACE_VALUE_LENGTH = 140;
83
83
  var MAX_TRACE_LENGTH = 720;
84
+ function eventTargetsCanvas(event) {
85
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
86
+ if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
87
+ return event.target instanceof HTMLCanvasElement;
88
+ }
84
89
  var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
85
90
  function truncateTraceValue(value, limit) {
86
91
  const compact = value.replace(/\s+/g, " ").trim();
@@ -174,9 +179,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
174
179
  let stepTrace;
175
180
  let failureTraceFactory;
176
181
  let acceptingStageInput = false;
182
+ let activeStageTargetedCanvas = false;
177
183
  let inputCaptureAttached = false;
178
- const recordInput = () => {
179
- if (acceptingStageInput) evidence.domInputEvents += 1;
184
+ const recordInput = (event) => {
185
+ if (!acceptingStageInput) return;
186
+ evidence.domInputEvents += 1;
187
+ activeStageTargetedCanvas ||= eventTargetsCanvas(event);
180
188
  };
181
189
  const stopInputCapture = () => {
182
190
  acceptingStageInput = false;
@@ -250,12 +258,18 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
250
258
  const before = lastSample ?? sampleState(`before ${normalizedName}`);
251
259
  activeStage = { name: normalizedName, kind, before };
252
260
  stepTrace = { bound: stage.maxSteps ?? 120 };
261
+ if (stage.step && !playthroughOptions?.observe) {
262
+ throw new Error(
263
+ `${stageLabel(kind, normalizedName)} uses deterministic step advancement without an authoritative observe callback. Time- or frame-driven stages must observe the same production Controller that <App /> renders through Telemetry; DOM labels alone are not deterministic gameplay state.`
264
+ );
265
+ }
253
266
  if (stage.until()) {
254
267
  throw new Error(
255
268
  `${stageLabel(kind, normalizedName)} until condition must be false before its driver runs. Wait for a result caused by this stage, not state left by an earlier stage.`
256
269
  );
257
270
  }
258
271
  const inputsBefore = evidence.domInputEvents;
272
+ activeStageTargetedCanvas = false;
259
273
  if (stage.act) {
260
274
  acceptingStageInput = true;
261
275
  try {
@@ -268,6 +282,11 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
268
282
  `${stageLabel(kind, normalizedName)} act did not dispatch a supported production DOM input. Use the provided user to click, type, press, point, or touch the production target; do not call Controller commands directly.`
269
283
  );
270
284
  }
285
+ if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
286
+ throw new Error(
287
+ `${stageLabel(kind, normalizedName)} dispatched production input to Canvas without an authoritative observe callback. Canvas pixels and control labels are not gameplay state; observe the same production Controller that <App /> renders through Telemetry.`
288
+ );
289
+ }
271
290
  }
272
291
  let advancedSteps = 0;
273
292
  const stepBound = stage.maxSteps ?? 120;
@@ -447,6 +466,7 @@ function auditReactPlaythroughRun(tests) {
447
466
 
448
467
  // src/react/react-playthrough-reporter.ts
449
468
  var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
469
+ var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the production mechanic, deterministic driver, or authoritative observation that prevents it. Do not make the test pass by weakening or deleting assertions, replacing the outcome with back/menu/exit navigation, observing arbitrary UI text only to change a fingerprint, using a no-op step, or treating an intermediate active/in-flight/running phase as meaningful progress.";
450
470
  function isMetadata(value) {
451
471
  if (!value || typeof value !== "object") return false;
452
472
  const metadata = value;
@@ -561,17 +581,23 @@ function repairGuidance(cause) {
561
581
  if (/snapshot\(\) returned the same reference/i.test(cause)) {
562
582
  return "The game mutated state without publishing a new snapshot reference, so React skipped the render after an Object.is comparison. Publish a new top-level object before notifying subscribers, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot.";
563
583
  }
584
+ if (/deterministic step advancement without an authoritative observe callback/i.test(cause)) {
585
+ return "This stage advances production time or frames, so DOM text is not a sufficient state boundary. Keep the intended outcome, inject ManualGameClock through the production <App /> factory, and make observe read the same production Controller through Telemetry. Do not remove step or replace the outcome with an immediate UI transition.";
586
+ }
587
+ if (/production input to Canvas without an authoritative observe callback/i.test(cause)) {
588
+ return "The player input reached the production Canvas, but JSDOM cannot verify its pixels. Keep the real Canvas input and make observe read the authoritative state from the same production Controller rendered by <App /> through Telemetry. Do not replace Canvas input with a test-only Controller command or button-label assertion.";
589
+ }
564
590
  if (/until condition must be false before its driver runs/i.test(cause)) {
565
591
  return "Make this stage's until condition describe a new result that does not exist before act or step runs. Do not reuse state completed by an earlier milestone.";
566
592
  }
567
593
  if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
568
- return "The stage ran and asserted, but its observable state matched the previous milestone. Wait for a genuinely new gameplay result. Canvas or Controller games should make observe read the same production Controller that React renders.";
594
+ return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe that result directly. Canvas or Controller games should make observe read the same production Controller that React renders. Do not substitute arbitrary labels, button visibility, navigation, or another weaker state change merely to produce a different fingerprint.";
569
595
  }
570
596
  if (/No step callback was provided/i.test(cause)) {
571
597
  return "This stage is driven by time or frames, but it did not advance the game clock. Inject the devkit GameClock into the production game and pass step: () => clock.stepFrame(). Do not replace deterministic advancement with a real setTimeout.";
572
598
  }
573
599
  if (/outcome was not reached within \d+ steps/i.test(cause)) {
574
- return "The stage driver ran, but gameplay did not reach the outcome within the bound. Confirm that its production input or deterministic step changed the intended rule state, then inspect Last diagnostics to locate the stalled stage.";
600
+ return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
575
601
  }
576
602
  if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
577
603
  return "Compose one enter stage, at least three named gameplay milestones, and one finish stage. Enter must drive real DOM input; each later stage must drive input or deterministic steps. Every stage waits for a bounded new result and asserts it with the provided expect.";
@@ -649,6 +675,9 @@ function formatReactPlaythroughReport(report) {
649
675
  lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
650
676
  }
651
677
  if (report.next) lines.push(`NEXT: ${report.next}`);
678
+ if (report.status === "FAILED") {
679
+ lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
680
+ }
652
681
  return `
653
682
  ${lines.join("\n")}`;
654
683
  }
@@ -40,6 +40,12 @@ function internalGameImports(node) {
40
40
  return importKind === "type" ? [] : [specifier.local.name];
41
41
  });
42
42
  }
43
+ function isStepProperty(node) {
44
+ return node.key.type === "Identifier" && node.key.name === "step" || node.key.type === "Literal" && node.key.value === "step";
45
+ }
46
+ function isEmptyFunction(value) {
47
+ return (value.type === "ArrowFunctionExpression" || value.type === "FunctionExpression") && value.body.type === "BlockStatement" && value.body.body.length === 0;
48
+ }
43
49
  var rule = {
44
50
  meta: {
45
51
  type: "problem",
@@ -50,7 +56,8 @@ var rule = {
50
56
  boundExpect: "Use the expect provided by playthroughTest. An imported Vitest expect may belong to a different module instance and cannot provide reliable assertion evidence.",
51
57
  providedUser: "Use the user provided by playthroughTest; do not import or create another userEvent instance in the production playthrough.",
52
58
  productionTestingImport: "Production source must not import miaoda-game-devkit/react/testing. Inject test clocks and observers through the production App factory boundary.",
53
- productionEntry: "Render <App /> from the production playthrough. Import Controller and Telemetry helpers when needed, but do not render an internal game component directly."
59
+ productionEntry: "Render <App /> from the production playthrough. Import Controller and Telemetry helpers when needed, but do not render an internal game component directly.",
60
+ emptyStep: "A deterministic step must advance production-owned time, frames, or queued work. An empty step is not gameplay evidence. Inject ManualGameClock through <App /> and call clock.stepFrame(...)."
54
61
  },
55
62
  schema: []
56
63
  },
@@ -85,6 +92,12 @@ var rule = {
85
92
  if (opening.name?.type === "JSXIdentifier" && opening.name.name && internalGameBindings.has(opening.name.name)) {
86
93
  context.report({ node, messageId: "productionEntry" });
87
94
  }
95
+ },
96
+ Property(node) {
97
+ if (!isProductionPlaythrough) return;
98
+ if (isStepProperty(node) && isEmptyFunction(node.value)) {
99
+ context.report({ node, messageId: "emptyStep" });
100
+ }
88
101
  }
89
102
  };
90
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Shared React and Phaser game lint plus deterministic testing tools for Miaoda games",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",