miaoda-game-devkit 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,8 @@ var import_node_path2 = require("path");
12
12
  var import_node_fs = require("fs");
13
13
  var import_node_path = require("path");
14
14
  var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
15
+ var PRODUCTION_APP = "src/App.tsx";
16
+ var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
15
17
  var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
16
18
  var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
17
19
  var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
@@ -20,6 +22,19 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
20
22
  "useOwnedGameController"
21
23
  ]);
22
24
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
25
+ function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
26
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
27
+ const files = [];
28
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
29
+ const path = (0, import_node_path.join)(directory, entry.name);
30
+ if (entry.isDirectory()) {
31
+ files.push(...runnableTestFiles(root, path));
32
+ } else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
33
+ files.push(path);
34
+ }
35
+ }
36
+ return files;
37
+ }
23
38
  function extension(path) {
24
39
  const index = path.lastIndexOf(".");
25
40
  return index < 0 ? "" : path.slice(index);
@@ -109,6 +124,32 @@ function codePositions(source) {
109
124
  }
110
125
  return positions;
111
126
  }
127
+ function importedModuleSpecifiers(source) {
128
+ const clean = withoutComments(source);
129
+ const positions = codePositions(clean);
130
+ const modules = [];
131
+ const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
132
+ for (const match of clean.matchAll(pattern)) {
133
+ if (positions[match.index]) modules.push(match[1]);
134
+ }
135
+ return modules;
136
+ }
137
+ function productionFileImportsExample(file, projectRoot2) {
138
+ const exampleRoot = (0, import_node_path.join)(projectRoot2, "src/game/example");
139
+ return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
140
+ (moduleName) => {
141
+ if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
142
+ if (!moduleName.startsWith(".")) return false;
143
+ const target = (0, import_node_path.resolve)((0, import_node_path.dirname)(file), moduleName);
144
+ return target === exampleRoot || target.startsWith(`${exampleRoot}${import_node_path.sep}`);
145
+ }
146
+ );
147
+ }
148
+ function importsExampleAlias(source) {
149
+ return importedModuleSpecifiers(source).some(
150
+ (moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
151
+ );
152
+ }
112
153
  function namedImports(source, moduleName) {
113
154
  const names = /* @__PURE__ */ new Set();
114
155
  const clean = withoutComments(source);
@@ -141,15 +182,39 @@ function declaresObserve(source) {
141
182
  function auditReactAuthoritativePlaythrough(projectRoot2) {
142
183
  const clockFiles = [];
143
184
  const controllerFiles = [];
144
- for (const file of sourceFiles(projectRoot2)) {
145
- const imports = namedImports((0, import_node_fs.readFileSync)(file, "utf8"), REACT_RUNTIME_ENTRY);
185
+ const productionFiles = sourceFiles(projectRoot2);
186
+ for (const file of productionFiles) {
187
+ const imports = namedImports(
188
+ (0, import_node_fs.readFileSync)(file, "utf8"),
189
+ REACT_RUNTIME_ENTRY
190
+ );
146
191
  const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
147
192
  if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
148
- if (containsAny(imports, CONTROLLER_IMPORTS)) controllerFiles.push(projectPath);
193
+ if (containsAny(imports, CONTROLLER_IMPORTS))
194
+ controllerFiles.push(projectPath);
149
195
  }
196
+ const appPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_APP);
197
+ const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
198
+ const productionUsesExample = productionFiles.some(
199
+ (file) => productionFileImportsExample(file, projectRoot2)
200
+ );
201
+ const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot2).filter((file) => importsExampleAlias((0, import_node_fs.readFileSync)(file, "utf8"))).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
150
202
  const issues = [];
203
+ if (staleExampleTestFiles.length > 0) {
204
+ issues.push(
205
+ `Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
206
+ );
207
+ }
151
208
  if (clockFiles.length === 0 && controllerFiles.length === 0) {
152
- return { ok: true, issues, clockFiles, controllerFiles };
209
+ return {
210
+ ok: issues.length === 0,
211
+ issues,
212
+ clockFiles,
213
+ controllerFiles,
214
+ productionEntryExists,
215
+ productionUsesExample,
216
+ staleExampleTestFiles
217
+ };
153
218
  }
154
219
  const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
155
220
  const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
@@ -165,7 +230,15 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
165
230
  `${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
231
  );
167
232
  }
168
- return { ok: issues.length === 0, issues, clockFiles, controllerFiles };
233
+ return {
234
+ ok: issues.length === 0,
235
+ issues,
236
+ clockFiles,
237
+ controllerFiles,
238
+ productionEntryExists,
239
+ productionUsesExample,
240
+ staleExampleTestFiles
241
+ };
169
242
  }
170
243
 
171
244
  // src/cli/lint.ts
@@ -12,6 +12,8 @@ var import_node_path2 = require("path");
12
12
  var import_node_fs = require("fs");
13
13
  var import_node_path = require("path");
14
14
  var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
15
+ var PRODUCTION_APP = "src/App.tsx";
16
+ var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
15
17
  var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
16
18
  var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
17
19
  var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
@@ -20,6 +22,19 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
20
22
  "useOwnedGameController"
21
23
  ]);
22
24
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
25
+ function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
26
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
27
+ const files = [];
28
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
29
+ const path = (0, import_node_path.join)(directory, entry.name);
30
+ if (entry.isDirectory()) {
31
+ files.push(...runnableTestFiles(root, path));
32
+ } else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
33
+ files.push(path);
34
+ }
35
+ }
36
+ return files;
37
+ }
23
38
  function extension(path) {
24
39
  const index = path.lastIndexOf(".");
25
40
  return index < 0 ? "" : path.slice(index);
@@ -109,6 +124,32 @@ function codePositions(source) {
109
124
  }
110
125
  return positions;
111
126
  }
127
+ function importedModuleSpecifiers(source) {
128
+ const clean = withoutComments(source);
129
+ const positions = codePositions(clean);
130
+ const modules = [];
131
+ const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
132
+ for (const match of clean.matchAll(pattern)) {
133
+ if (positions[match.index]) modules.push(match[1]);
134
+ }
135
+ return modules;
136
+ }
137
+ function productionFileImportsExample(file, projectRoot2) {
138
+ const exampleRoot = (0, import_node_path.join)(projectRoot2, "src/game/example");
139
+ return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
140
+ (moduleName) => {
141
+ if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
142
+ if (!moduleName.startsWith(".")) return false;
143
+ const target = (0, import_node_path.resolve)((0, import_node_path.dirname)(file), moduleName);
144
+ return target === exampleRoot || target.startsWith(`${exampleRoot}${import_node_path.sep}`);
145
+ }
146
+ );
147
+ }
148
+ function importsExampleAlias(source) {
149
+ return importedModuleSpecifiers(source).some(
150
+ (moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
151
+ );
152
+ }
112
153
  function namedImports(source, moduleName) {
113
154
  const names = /* @__PURE__ */ new Set();
114
155
  const clean = withoutComments(source);
@@ -141,15 +182,39 @@ function declaresObserve(source) {
141
182
  function auditReactAuthoritativePlaythrough(projectRoot2) {
142
183
  const clockFiles = [];
143
184
  const controllerFiles = [];
144
- for (const file of sourceFiles(projectRoot2)) {
145
- const imports = namedImports((0, import_node_fs.readFileSync)(file, "utf8"), REACT_RUNTIME_ENTRY);
185
+ const productionFiles = sourceFiles(projectRoot2);
186
+ for (const file of productionFiles) {
187
+ const imports = namedImports(
188
+ (0, import_node_fs.readFileSync)(file, "utf8"),
189
+ REACT_RUNTIME_ENTRY
190
+ );
146
191
  const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
147
192
  if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
148
- if (containsAny(imports, CONTROLLER_IMPORTS)) controllerFiles.push(projectPath);
193
+ if (containsAny(imports, CONTROLLER_IMPORTS))
194
+ controllerFiles.push(projectPath);
149
195
  }
196
+ const appPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_APP);
197
+ const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
198
+ const productionUsesExample = productionFiles.some(
199
+ (file) => productionFileImportsExample(file, projectRoot2)
200
+ );
201
+ const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot2).filter((file) => importsExampleAlias((0, import_node_fs.readFileSync)(file, "utf8"))).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
150
202
  const issues = [];
203
+ if (staleExampleTestFiles.length > 0) {
204
+ issues.push(
205
+ `Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
206
+ );
207
+ }
151
208
  if (clockFiles.length === 0 && controllerFiles.length === 0) {
152
- return { ok: true, issues, clockFiles, controllerFiles };
209
+ return {
210
+ ok: issues.length === 0,
211
+ issues,
212
+ clockFiles,
213
+ controllerFiles,
214
+ productionEntryExists,
215
+ productionUsesExample,
216
+ staleExampleTestFiles
217
+ };
153
218
  }
154
219
  const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
155
220
  const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
@@ -165,7 +230,15 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
165
230
  `${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
231
  );
167
232
  }
168
- return { ok: issues.length === 0, issues, clockFiles, controllerFiles };
233
+ return {
234
+ ok: issues.length === 0,
235
+ issues,
236
+ clockFiles,
237
+ controllerFiles,
238
+ productionEntryExists,
239
+ productionUsesExample,
240
+ staleExampleTestFiles
241
+ };
169
242
  }
170
243
 
171
244
  // src/cli/lint.ts
@@ -54,6 +54,15 @@ function useOwnedGameController(createController) {
54
54
 
55
55
  // src/react/use-game-controller.ts
56
56
  var import_react2 = require("react");
57
+
58
+ // src/react/react-error-diagnostics.ts
59
+ function codedError(code, message) {
60
+ const error = new Error(message);
61
+ error.code = code;
62
+ return error;
63
+ }
64
+
65
+ // src/react/use-game-controller.ts
57
66
  function useGameController(createController) {
58
67
  const game = useOwnedGameController(createController);
59
68
  useSnapshotIntegrityCheck(game);
@@ -91,7 +100,8 @@ function useSnapshotIntegrityCheck(game) {
91
100
  }
92
101
  if (previousJson !== void 0 && nextJson !== previousJson) {
93
102
  reported.current = true;
94
- throw new Error(
103
+ throw codedError(
104
+ "GAME_SNAPSHOT_REFERENCE_REUSED",
95
105
  "Game state changed while snapshot() returned the same reference. React compares snapshots by reference and skips the render when Object.is(previous, next) is true, so the UI freezes. Publish a new top-level snapshot before each notification, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot."
96
106
  );
97
107
  }
@@ -26,6 +26,15 @@ function useOwnedGameController(createController) {
26
26
 
27
27
  // src/react/use-game-controller.ts
28
28
  import { useEffect as useEffect2, useRef as useRef2, useSyncExternalStore } from "react";
29
+
30
+ // src/react/react-error-diagnostics.ts
31
+ function codedError(code, message) {
32
+ const error = new Error(message);
33
+ error.code = code;
34
+ return error;
35
+ }
36
+
37
+ // src/react/use-game-controller.ts
29
38
  function useGameController(createController) {
30
39
  const game = useOwnedGameController(createController);
31
40
  useSnapshotIntegrityCheck(game);
@@ -63,7 +72,8 @@ function useSnapshotIntegrityCheck(game) {
63
72
  }
64
73
  if (previousJson !== void 0 && nextJson !== previousJson) {
65
74
  reported.current = true;
66
- throw new Error(
75
+ throw codedError(
76
+ "GAME_SNAPSHOT_REFERENCE_REUSED",
67
77
  "Game state changed while snapshot() returned the same reference. React compares snapshots by reference and skips the render when Object.is(previous, next) is true, so the UI freezes. Publish a new top-level snapshot before each notification, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot."
68
78
  );
69
79
  }
@@ -22,6 +22,15 @@ declare class ManualGameClock implements GameClock {
22
22
  pendingTimerCount(): number;
23
23
  }
24
24
 
25
+ interface ReactFailureEntry {
26
+ code: string;
27
+ message: string;
28
+ }
29
+ interface ReactFailureDiagnostic {
30
+ source: "playthrough" | "test-runtime";
31
+ entries: ReactFailureEntry[];
32
+ }
33
+
25
34
  /** 有界推进的通用配置,不绑定任何特定 Core、phase 或时钟实现。 */
26
35
  interface StepUntilOptions {
27
36
  /** 条件仍不成立时允许执行的最大生产步数,默认 120。 */
@@ -57,9 +66,10 @@ interface ReactPlaythroughEvidence {
57
66
  }
58
67
  /** 通过 Vitest task metadata 从 worker 传递给主线程 reporter 的数据。 */
59
68
  interface ReactPlaythroughMetadata {
60
- version: 4;
69
+ version: 5;
61
70
  waiverReason?: string;
62
71
  trace?: string;
72
+ failure?: ReactFailureDiagnostic;
63
73
  evidence: ReactPlaythroughEvidence;
64
74
  }
65
75
  interface ReactPlaythroughOptions {
@@ -22,6 +22,15 @@ declare class ManualGameClock implements GameClock {
22
22
  pendingTimerCount(): number;
23
23
  }
24
24
 
25
+ interface ReactFailureEntry {
26
+ code: string;
27
+ message: string;
28
+ }
29
+ interface ReactFailureDiagnostic {
30
+ source: "playthrough" | "test-runtime";
31
+ entries: ReactFailureEntry[];
32
+ }
33
+
25
34
  /** 有界推进的通用配置,不绑定任何特定 Core、phase 或时钟实现。 */
26
35
  interface StepUntilOptions {
27
36
  /** 条件仍不成立时允许执行的最大生产步数,默认 120。 */
@@ -57,9 +66,10 @@ interface ReactPlaythroughEvidence {
57
66
  }
58
67
  /** 通过 Vitest task metadata 从 worker 传递给主线程 reporter 的数据。 */
59
68
  interface ReactPlaythroughMetadata {
60
- version: 4;
69
+ version: 5;
61
70
  waiverReason?: string;
62
71
  trace?: string;
72
+ failure?: ReactFailureDiagnostic;
63
73
  evidence: ReactPlaythroughEvidence;
64
74
  }
65
75
  interface ReactPlaythroughOptions {