miaoda-game-devkit 0.10.0 → 0.10.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.
@@ -1,8 +1,92 @@
1
1
  // src/lint/setup.ts
2
2
  import { afterEach } from "vitest";
3
3
 
4
- // src/testing/jsdom-canvas.ts
4
+ // src/testing/jsdom-webgl.ts
5
+ var CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/;
6
+ var TEXTURE_UNIT_LIMIT = 32;
7
+ var TEXTURE_SIZE_LIMIT = 4096;
8
+ var VIEWPORT_LIMIT = 4096;
9
+ function parameterValue(name) {
10
+ if (name === "VERSION") return "WebGL 2.0 (miaoda-game-devkit stub)";
11
+ if (name === "SHADING_LANGUAGE_VERSION") return "WebGL GLSL ES 3.00 (stub)";
12
+ if (name === "VENDOR" || name === "RENDERER") return "miaoda-game-devkit";
13
+ if (name === "MAX_VIEWPORT_DIMS") {
14
+ return new Int32Array([VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
15
+ }
16
+ if (name === "VIEWPORT" || name === "SCISSOR_BOX") {
17
+ return new Int32Array([0, 0, VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
18
+ }
19
+ if (name.includes("MAX_") && name.includes("SIZE")) return TEXTURE_SIZE_LIMIT;
20
+ if (name.startsWith("MAX_")) return TEXTURE_UNIT_LIMIT;
21
+ if (name.endsWith("_BITS")) return 8;
22
+ return 0;
23
+ }
24
+ function createWebGLContext(canvas, attributes) {
25
+ const constants = /* @__PURE__ */ new Map();
26
+ const constantNames = /* @__PURE__ */ new Map();
27
+ const state = {
28
+ canvas,
29
+ drawingBufferWidth: canvas.width,
30
+ drawingBufferHeight: canvas.height
31
+ };
32
+ const method = (name) => {
33
+ if (name === "getContextAttributes") return () => attributes;
34
+ if (name === "getExtension") return () => null;
35
+ if (name === "getSupportedExtensions") return () => [];
36
+ if (name === "getParameter") {
37
+ return (pname) => parameterValue(constantNames.get(pname) ?? "UNKNOWN");
38
+ }
39
+ if (name === "getShaderPrecisionFormat") {
40
+ return () => ({ rangeMin: 127, rangeMax: 127, precision: 23 });
41
+ }
42
+ if (name === "getShaderInfoLog" || name === "getProgramInfoLog") return () => "";
43
+ if (name === "getError") return () => 0;
44
+ if (name === "isContextLost") return () => false;
45
+ if (name.startsWith("get") && name.endsWith("Parameter")) return () => true;
46
+ if (name.startsWith("get")) return () => null;
47
+ if (name.startsWith("create") || name.startsWith("getUniformLocation")) {
48
+ return () => ({});
49
+ }
50
+ return () => void 0;
51
+ };
52
+ return new Proxy(state, {
53
+ get(target, property) {
54
+ if (property in target) return target[property];
55
+ if (typeof property !== "string") return void 0;
56
+ if (CONSTANT_NAME.test(property)) {
57
+ const existing = constants.get(property);
58
+ if (existing !== void 0) return existing;
59
+ const value = constants.size + 1;
60
+ constants.set(property, value);
61
+ constantNames.set(value, property);
62
+ return value;
63
+ }
64
+ const implementation = method(property);
65
+ target[property] = implementation;
66
+ return implementation;
67
+ }
68
+ });
69
+ }
5
70
  var contexts = /* @__PURE__ */ new WeakMap();
71
+ function getJSDOMWebGLContext(canvas, options) {
72
+ const existing = contexts.get(canvas);
73
+ if (existing) return existing;
74
+ const attributes = {
75
+ alpha: true,
76
+ antialias: false,
77
+ depth: true,
78
+ premultipliedAlpha: true,
79
+ preserveDrawingBuffer: false,
80
+ stencil: false,
81
+ ...typeof options === "object" && options !== null ? options : {}
82
+ };
83
+ const context = createWebGLContext(canvas, attributes);
84
+ contexts.set(canvas, context);
85
+ return context;
86
+ }
87
+
88
+ // src/testing/jsdom-canvas.ts
89
+ var contexts2 = /* @__PURE__ */ new WeakMap();
6
90
  function createCanvasContext(canvas) {
7
91
  const imageData = (width = 1, height = 1) => ({
8
92
  data: new Uint8ClampedArray(width * height * 4),
@@ -49,12 +133,15 @@ function createCanvasContext(canvas) {
49
133
  return context;
50
134
  }
51
135
  function installJSDOMCanvasContext() {
52
- HTMLCanvasElement.prototype.getContext = function getContext(contextId) {
136
+ HTMLCanvasElement.prototype.getContext = function getContext(contextId, options) {
137
+ if (contextId === "webgl" || contextId === "webgl2") {
138
+ return getJSDOMWebGLContext(this, options);
139
+ }
53
140
  if (contextId !== "2d") return null;
54
- const existing = contexts.get(this);
141
+ const existing = contexts2.get(this);
55
142
  if (existing) return existing;
56
143
  const context = createCanvasContext(this);
57
- contexts.set(this, context);
144
+ contexts2.set(this, context);
58
145
  return context;
59
146
  };
60
147
  }
@@ -50,7 +50,10 @@ interface StepUntilOptions {
50
50
  step?: (step: number) => void | Promise<void>;
51
51
  /**
52
52
  * Optional read-only state used to make bounded failures actionable.
53
- * The value is sampled only when the bound is exhausted.
53
+ *
54
+ * The value is sampled when the bound is exhausted, and additionally at a fixed
55
+ * step interval so an exhausted bound can distinguish a run that is still
56
+ * progressing from one whose state already settled.
54
57
  */
55
58
  diagnostics?: () => unknown;
56
59
  /** 测试超时或运行取消时停止继续推进,通常由 playthroughTest 自动传入。 */
@@ -50,7 +50,10 @@ interface StepUntilOptions {
50
50
  step?: (step: number) => void | Promise<void>;
51
51
  /**
52
52
  * Optional read-only state used to make bounded failures actionable.
53
- * The value is sampled only when the bound is exhausted.
53
+ *
54
+ * The value is sampled when the bound is exhausted, and additionally at a fixed
55
+ * step interval so an exhausted bound can distinguish a run that is still
56
+ * progressing from one whose state already settled.
54
57
  */
55
58
  diagnostics?: () => unknown;
56
59
  /** 测试超时或运行取消时停止继续推进,通常由 playthroughTest 自动传入。 */
@@ -311,6 +311,39 @@ function formatDiagnostics(read) {
311
311
  return `diagnostics() threw: ${String(error)}`;
312
312
  }
313
313
  }
314
+ var STAGNATION_SAMPLE_INTERVAL = 32;
315
+ var STAGNATION_SAMPLE_MINIMUM = 3;
316
+ var StagnationProbe = class {
317
+ constructor(read) {
318
+ this.read = read;
319
+ this.disabled = !read;
320
+ }
321
+ read;
322
+ fingerprint;
323
+ unchangedSamples = 0;
324
+ disabled = false;
325
+ settledAtStep;
326
+ sample(step) {
327
+ if (this.disabled || step % STAGNATION_SAMPLE_INTERVAL !== 0) return;
328
+ let next;
329
+ try {
330
+ next = JSON.stringify(this.read?.()) ?? "undefined";
331
+ } catch {
332
+ this.disabled = true;
333
+ return;
334
+ }
335
+ if (this.fingerprint === next) {
336
+ this.unchangedSamples += 1;
337
+ return;
338
+ }
339
+ this.fingerprint = next;
340
+ this.unchangedSamples = 1;
341
+ this.settledAtStep = step;
342
+ }
343
+ get settled() {
344
+ return !this.disabled && this.unchangedSamples >= STAGNATION_SAMPLE_MINIMUM;
345
+ }
346
+ };
314
347
  function normalizePlaythroughWaiverReason(waiverReason) {
315
348
  if (waiverReason === void 0) return void 0;
316
349
  const reason = waiverReason.trim();
@@ -330,6 +363,7 @@ async function runBoundedUntil(condition, options = {}) {
330
363
  "stepUntil maxSteps must be a safe integer between 0 and 10000."
331
364
  );
332
365
  }
366
+ const probe = new StagnationProbe(options.diagnostics);
333
367
  for (let step = 0; step <= maxSteps; step += 1) {
334
368
  throwIfAborted(options.signal);
335
369
  if (condition()) return step;
@@ -338,11 +372,18 @@ async function runBoundedUntil(condition, options = {}) {
338
372
  await options.step?.(step + 1);
339
373
  });
340
374
  throwIfAborted(options.signal);
375
+ probe.sample(step + 1);
341
376
  }
342
377
  }
343
378
  const diagnostics = formatDiagnostics(options.diagnostics);
344
- const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
345
379
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
380
+ if (probe.settled) {
381
+ throw codedError(
382
+ "PLAYTHROUGH_STATE_STAGNANT",
383
+ `Playthrough state stopped changing at step ${probe.settledAtStep} of ${maxSteps} and stayed identical until the bound. The run is already settled, so raising maxSteps cannot help: compare the settled state against the outcome this stage asserts. A settled state usually means the game reached a terminal result other than the asserted one, or that the stage never advances the production Controller at all.${suffix}`
384
+ );
385
+ }
386
+ const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
346
387
  throw codedError(
347
388
  options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_OUTCOME_NOT_REACHED",
348
389
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
@@ -273,6 +273,39 @@ function formatDiagnostics(read) {
273
273
  return `diagnostics() threw: ${String(error)}`;
274
274
  }
275
275
  }
276
+ var STAGNATION_SAMPLE_INTERVAL = 32;
277
+ var STAGNATION_SAMPLE_MINIMUM = 3;
278
+ var StagnationProbe = class {
279
+ constructor(read) {
280
+ this.read = read;
281
+ this.disabled = !read;
282
+ }
283
+ read;
284
+ fingerprint;
285
+ unchangedSamples = 0;
286
+ disabled = false;
287
+ settledAtStep;
288
+ sample(step) {
289
+ if (this.disabled || step % STAGNATION_SAMPLE_INTERVAL !== 0) return;
290
+ let next;
291
+ try {
292
+ next = JSON.stringify(this.read?.()) ?? "undefined";
293
+ } catch {
294
+ this.disabled = true;
295
+ return;
296
+ }
297
+ if (this.fingerprint === next) {
298
+ this.unchangedSamples += 1;
299
+ return;
300
+ }
301
+ this.fingerprint = next;
302
+ this.unchangedSamples = 1;
303
+ this.settledAtStep = step;
304
+ }
305
+ get settled() {
306
+ return !this.disabled && this.unchangedSamples >= STAGNATION_SAMPLE_MINIMUM;
307
+ }
308
+ };
276
309
  function normalizePlaythroughWaiverReason(waiverReason) {
277
310
  if (waiverReason === void 0) return void 0;
278
311
  const reason = waiverReason.trim();
@@ -292,6 +325,7 @@ async function runBoundedUntil(condition, options = {}) {
292
325
  "stepUntil maxSteps must be a safe integer between 0 and 10000."
293
326
  );
294
327
  }
328
+ const probe = new StagnationProbe(options.diagnostics);
295
329
  for (let step = 0; step <= maxSteps; step += 1) {
296
330
  throwIfAborted(options.signal);
297
331
  if (condition()) return step;
@@ -300,11 +334,18 @@ async function runBoundedUntil(condition, options = {}) {
300
334
  await options.step?.(step + 1);
301
335
  });
302
336
  throwIfAborted(options.signal);
337
+ probe.sample(step + 1);
303
338
  }
304
339
  }
305
340
  const diagnostics = formatDiagnostics(options.diagnostics);
306
- const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
307
341
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
342
+ if (probe.settled) {
343
+ throw codedError(
344
+ "PLAYTHROUGH_STATE_STAGNANT",
345
+ `Playthrough state stopped changing at step ${probe.settledAtStep} of ${maxSteps} and stayed identical until the bound. The run is already settled, so raising maxSteps cannot help: compare the settled state against the outcome this stage asserts. A settled state usually means the game reached a terminal result other than the asserted one, or that the stage never advances the production Controller at all.${suffix}`
346
+ );
347
+ }
348
+ const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
308
349
  throw codedError(
309
350
  options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_OUTCOME_NOT_REACHED",
310
351
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
@@ -265,6 +265,39 @@ function formatDiagnostics(read) {
265
265
  return `diagnostics() threw: ${String(error)}`;
266
266
  }
267
267
  }
268
+ var STAGNATION_SAMPLE_INTERVAL = 32;
269
+ var STAGNATION_SAMPLE_MINIMUM = 3;
270
+ var StagnationProbe = class {
271
+ constructor(read) {
272
+ this.read = read;
273
+ this.disabled = !read;
274
+ }
275
+ read;
276
+ fingerprint;
277
+ unchangedSamples = 0;
278
+ disabled = false;
279
+ settledAtStep;
280
+ sample(step) {
281
+ if (this.disabled || step % STAGNATION_SAMPLE_INTERVAL !== 0) return;
282
+ let next;
283
+ try {
284
+ next = JSON.stringify(this.read?.()) ?? "undefined";
285
+ } catch {
286
+ this.disabled = true;
287
+ return;
288
+ }
289
+ if (this.fingerprint === next) {
290
+ this.unchangedSamples += 1;
291
+ return;
292
+ }
293
+ this.fingerprint = next;
294
+ this.unchangedSamples = 1;
295
+ this.settledAtStep = step;
296
+ }
297
+ get settled() {
298
+ return !this.disabled && this.unchangedSamples >= STAGNATION_SAMPLE_MINIMUM;
299
+ }
300
+ };
268
301
  function normalizePlaythroughWaiverReason(waiverReason) {
269
302
  if (waiverReason === void 0) return void 0;
270
303
  const reason = waiverReason.trim();
@@ -284,6 +317,7 @@ async function runBoundedUntil(condition, options = {}) {
284
317
  "stepUntil maxSteps must be a safe integer between 0 and 10000."
285
318
  );
286
319
  }
320
+ const probe = new StagnationProbe(options.diagnostics);
287
321
  for (let step = 0; step <= maxSteps; step += 1) {
288
322
  throwIfAborted(options.signal);
289
323
  if (condition()) return step;
@@ -292,11 +326,18 @@ async function runBoundedUntil(condition, options = {}) {
292
326
  await options.step?.(step + 1);
293
327
  });
294
328
  throwIfAborted(options.signal);
329
+ probe.sample(step + 1);
295
330
  }
296
331
  }
297
332
  const diagnostics = formatDiagnostics(options.diagnostics);
298
- const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
299
333
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
334
+ if (probe.settled) {
335
+ throw codedError(
336
+ "PLAYTHROUGH_STATE_STAGNANT",
337
+ `Playthrough state stopped changing at step ${probe.settledAtStep} of ${maxSteps} and stayed identical until the bound. The run is already settled, so raising maxSteps cannot help: compare the settled state against the outcome this stage asserts. A settled state usually means the game reached a terminal result other than the asserted one, or that the stage never advances the production Controller at all.${suffix}`
338
+ );
339
+ }
340
+ const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
300
341
  throw codedError(
301
342
  options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_OUTCOME_NOT_REACHED",
302
343
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
@@ -1266,7 +1307,9 @@ function repairGuidance(code) {
1266
1307
  case "STAGE_STATE_UNCHANGED":
1267
1308
  return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe the same production Controller rendered by <App />. Do not substitute arbitrary labels or a weaker state change.";
1268
1309
  case "PLAYTHROUGH_BOUND_EXHAUSTED":
1269
- 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.";
1310
+ return "First read the phase or result field of Last diagnostics. If the run already reached a terminal result other than the one asserted, the game is over and the bound is irrelevant: assert the result the shipped constants actually produce (loss, draw, and ranking are legitimate terminal results), or fix the mechanic that was supposed to make the intended objective reachable. Raising maxSteps or rewriting the in-test play order cannot pass a finished game. If the run is still mid-game, keep the intended outcome unchanged and 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. Never replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion, and never retune the production constants that define the challenge.";
1311
+ case "PLAYTHROUGH_STATE_STAGNANT":
1312
+ return "The observed state stopped changing well before the bound, so the bound is not the problem and raising maxSteps cannot help. Two causes: the run already reached a terminal result other than the one asserted \u2014 assert the result the shipped constants actually produce, or fix the mechanic meant to make the intended objective reachable \u2014 or the stage never advances the production Controller at all, in which case inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not retune production constants and do not weaken the assertion.";
1270
1313
  case "PLAYTHROUGH_OUTCOME_NOT_REACHED":
1271
1314
  return "The stage action completed, but its until condition never became true. Inspect TRACE and Last diagnostics, then confirm that the production DOM input reaches the rendered App, until describes the result caused by this stage, and observe reads the matching authoritative state when used. Add a deterministic step only if the gameplay is actually driven by time or frames; do not add a no-op step or weaken the intended outcome.";
1272
1315
  case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
@@ -1452,7 +1495,36 @@ function resolvePhaser3BrowserEntry(projectRoot) {
1452
1495
  return void 0;
1453
1496
  }
1454
1497
  }
1498
+ var COVERAGE_INCLUDE = [
1499
+ "src/game/core/**/*.{ts,tsx}",
1500
+ "src/game/runtime/**/*.{ts,tsx}"
1501
+ ];
1502
+ var COVERAGE_EXCLUDE = [
1503
+ "src/game/{core,runtime}/**/*.d.ts",
1504
+ "src/game/{core,runtime}/**/*.{spec,test}.{ts,tsx}"
1505
+ ];
1506
+ var GATE_SCAN_EXCLUDE = [
1507
+ "src/**/*.d.ts",
1508
+ "src/**/*.{spec,test}.{ts,tsx}",
1509
+ "src/game/example/**"
1510
+ ];
1511
+ function assertCoverageScopeIsPopulated(projectRoot) {
1512
+ const scan = (pattern) => (0, import_node_fs3.globSync)(pattern, { cwd: projectRoot, exclude: GATE_SCAN_EXCLUDE });
1513
+ if (COVERAGE_INCLUDE.flatMap(scan).length > 0) return;
1514
+ const misplaced = scan("src/**/{core,runtime}/**/*.{ts,tsx}");
1515
+ if (misplaced.length === 0) return;
1516
+ throw new Error(
1517
+ [
1518
+ "The coverage gate matched no source file, so it proves nothing.",
1519
+ "Deterministic rules belong in src/game/core/; the loop, scheduling,",
1520
+ "Controller, and Telemetry belong in src/game/runtime/.",
1521
+ "Move these files under those two directories:",
1522
+ ...misplaced.slice(0, 10).map((file) => ` ${file}`)
1523
+ ].join("\n")
1524
+ );
1525
+ }
1455
1526
  function defineReactGameVitestConfig(options) {
1527
+ assertCoverageScopeIsPopulated(options.projectRoot);
1456
1528
  const phaser3BrowserEntry = resolvePhaser3BrowserEntry(options.projectRoot);
1457
1529
  return (0, import_config.defineConfig)({
1458
1530
  // Keep discovery and dependency resolution anchored to the generated app even
@@ -1495,14 +1567,8 @@ function defineReactGameVitestConfig(options) {
1495
1567
  clearMocks: true,
1496
1568
  coverage: {
1497
1569
  provider: "v8",
1498
- include: [
1499
- "src/game/core/**/*.{ts,tsx}",
1500
- "src/game/runtime/**/*.{ts,tsx}"
1501
- ],
1502
- exclude: [
1503
- "src/game/{core,runtime}/**/*.d.ts",
1504
- "src/game/{core,runtime}/**/*.{spec,test}.{ts,tsx}"
1505
- ],
1570
+ include: COVERAGE_INCLUDE,
1571
+ exclude: COVERAGE_EXCLUDE,
1506
1572
  reporter: ["text-summary"],
1507
1573
  thresholds: {
1508
1574
  perFile: true,
@@ -1,5 +1,5 @@
1
1
  // src/react-vitest-config.ts
2
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
2
+ import { existsSync as existsSync3, globSync, readFileSync as readFileSync2 } from "fs";
3
3
  import { dirname as dirname2, resolve as resolve3 } from "path";
4
4
  import { defineConfig } from "vitest/config";
5
5
 
@@ -231,6 +231,39 @@ function formatDiagnostics(read) {
231
231
  return `diagnostics() threw: ${String(error)}`;
232
232
  }
233
233
  }
234
+ var STAGNATION_SAMPLE_INTERVAL = 32;
235
+ var STAGNATION_SAMPLE_MINIMUM = 3;
236
+ var StagnationProbe = class {
237
+ constructor(read) {
238
+ this.read = read;
239
+ this.disabled = !read;
240
+ }
241
+ read;
242
+ fingerprint;
243
+ unchangedSamples = 0;
244
+ disabled = false;
245
+ settledAtStep;
246
+ sample(step) {
247
+ if (this.disabled || step % STAGNATION_SAMPLE_INTERVAL !== 0) return;
248
+ let next;
249
+ try {
250
+ next = JSON.stringify(this.read?.()) ?? "undefined";
251
+ } catch {
252
+ this.disabled = true;
253
+ return;
254
+ }
255
+ if (this.fingerprint === next) {
256
+ this.unchangedSamples += 1;
257
+ return;
258
+ }
259
+ this.fingerprint = next;
260
+ this.unchangedSamples = 1;
261
+ this.settledAtStep = step;
262
+ }
263
+ get settled() {
264
+ return !this.disabled && this.unchangedSamples >= STAGNATION_SAMPLE_MINIMUM;
265
+ }
266
+ };
234
267
  function normalizePlaythroughWaiverReason(waiverReason) {
235
268
  if (waiverReason === void 0) return void 0;
236
269
  const reason = waiverReason.trim();
@@ -250,6 +283,7 @@ async function runBoundedUntil(condition, options = {}) {
250
283
  "stepUntil maxSteps must be a safe integer between 0 and 10000."
251
284
  );
252
285
  }
286
+ const probe = new StagnationProbe(options.diagnostics);
253
287
  for (let step = 0; step <= maxSteps; step += 1) {
254
288
  throwIfAborted(options.signal);
255
289
  if (condition()) return step;
@@ -258,11 +292,18 @@ async function runBoundedUntil(condition, options = {}) {
258
292
  await options.step?.(step + 1);
259
293
  });
260
294
  throwIfAborted(options.signal);
295
+ probe.sample(step + 1);
261
296
  }
262
297
  }
263
298
  const diagnostics = formatDiagnostics(options.diagnostics);
264
- const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
265
299
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
300
+ if (probe.settled) {
301
+ throw codedError(
302
+ "PLAYTHROUGH_STATE_STAGNANT",
303
+ `Playthrough state stopped changing at step ${probe.settledAtStep} of ${maxSteps} and stayed identical until the bound. The run is already settled, so raising maxSteps cannot help: compare the settled state against the outcome this stage asserts. A settled state usually means the game reached a terminal result other than the asserted one, or that the stage never advances the production Controller at all.${suffix}`
304
+ );
305
+ }
306
+ const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
266
307
  throw codedError(
267
308
  options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_OUTCOME_NOT_REACHED",
268
309
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
@@ -1232,7 +1273,9 @@ function repairGuidance(code) {
1232
1273
  case "STAGE_STATE_UNCHANGED":
1233
1274
  return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe the same production Controller rendered by <App />. Do not substitute arbitrary labels or a weaker state change.";
1234
1275
  case "PLAYTHROUGH_BOUND_EXHAUSTED":
1235
- 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.";
1276
+ return "First read the phase or result field of Last diagnostics. If the run already reached a terminal result other than the one asserted, the game is over and the bound is irrelevant: assert the result the shipped constants actually produce (loss, draw, and ranking are legitimate terminal results), or fix the mechanic that was supposed to make the intended objective reachable. Raising maxSteps or rewriting the in-test play order cannot pass a finished game. If the run is still mid-game, keep the intended outcome unchanged and 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. Never replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion, and never retune the production constants that define the challenge.";
1277
+ case "PLAYTHROUGH_STATE_STAGNANT":
1278
+ return "The observed state stopped changing well before the bound, so the bound is not the problem and raising maxSteps cannot help. Two causes: the run already reached a terminal result other than the one asserted \u2014 assert the result the shipped constants actually produce, or fix the mechanic meant to make the intended objective reachable \u2014 or the stage never advances the production Controller at all, in which case inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not retune production constants and do not weaken the assertion.";
1236
1279
  case "PLAYTHROUGH_OUTCOME_NOT_REACHED":
1237
1280
  return "The stage action completed, but its until condition never became true. Inspect TRACE and Last diagnostics, then confirm that the production DOM input reaches the rendered App, until describes the result caused by this stage, and observe reads the matching authoritative state when used. Add a deterministic step only if the gameplay is actually driven by time or frames; do not add a no-op step or weaken the intended outcome.";
1238
1281
  case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
@@ -1418,7 +1461,36 @@ function resolvePhaser3BrowserEntry(projectRoot) {
1418
1461
  return void 0;
1419
1462
  }
1420
1463
  }
1464
+ var COVERAGE_INCLUDE = [
1465
+ "src/game/core/**/*.{ts,tsx}",
1466
+ "src/game/runtime/**/*.{ts,tsx}"
1467
+ ];
1468
+ var COVERAGE_EXCLUDE = [
1469
+ "src/game/{core,runtime}/**/*.d.ts",
1470
+ "src/game/{core,runtime}/**/*.{spec,test}.{ts,tsx}"
1471
+ ];
1472
+ var GATE_SCAN_EXCLUDE = [
1473
+ "src/**/*.d.ts",
1474
+ "src/**/*.{spec,test}.{ts,tsx}",
1475
+ "src/game/example/**"
1476
+ ];
1477
+ function assertCoverageScopeIsPopulated(projectRoot) {
1478
+ const scan = (pattern) => globSync(pattern, { cwd: projectRoot, exclude: GATE_SCAN_EXCLUDE });
1479
+ if (COVERAGE_INCLUDE.flatMap(scan).length > 0) return;
1480
+ const misplaced = scan("src/**/{core,runtime}/**/*.{ts,tsx}");
1481
+ if (misplaced.length === 0) return;
1482
+ throw new Error(
1483
+ [
1484
+ "The coverage gate matched no source file, so it proves nothing.",
1485
+ "Deterministic rules belong in src/game/core/; the loop, scheduling,",
1486
+ "Controller, and Telemetry belong in src/game/runtime/.",
1487
+ "Move these files under those two directories:",
1488
+ ...misplaced.slice(0, 10).map((file) => ` ${file}`)
1489
+ ].join("\n")
1490
+ );
1491
+ }
1421
1492
  function defineReactGameVitestConfig(options) {
1493
+ assertCoverageScopeIsPopulated(options.projectRoot);
1422
1494
  const phaser3BrowserEntry = resolvePhaser3BrowserEntry(options.projectRoot);
1423
1495
  return defineConfig({
1424
1496
  // Keep discovery and dependency resolution anchored to the generated app even
@@ -1461,14 +1533,8 @@ function defineReactGameVitestConfig(options) {
1461
1533
  clearMocks: true,
1462
1534
  coverage: {
1463
1535
  provider: "v8",
1464
- include: [
1465
- "src/game/core/**/*.{ts,tsx}",
1466
- "src/game/runtime/**/*.{ts,tsx}"
1467
- ],
1468
- exclude: [
1469
- "src/game/{core,runtime}/**/*.d.ts",
1470
- "src/game/{core,runtime}/**/*.{spec,test}.{ts,tsx}"
1471
- ],
1536
+ include: COVERAGE_INCLUDE,
1537
+ exclude: COVERAGE_EXCLUDE,
1472
1538
  reporter: ["text-summary"],
1473
1539
  thresholds: {
1474
1540
  perFile: true,
@@ -184,8 +184,92 @@ function runtimeFailureError(code, value) {
184
184
  return error;
185
185
  }
186
186
 
187
- // src/testing/jsdom-canvas.ts
187
+ // src/testing/jsdom-webgl.ts
188
+ var CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/;
189
+ var TEXTURE_UNIT_LIMIT = 32;
190
+ var TEXTURE_SIZE_LIMIT = 4096;
191
+ var VIEWPORT_LIMIT = 4096;
192
+ function parameterValue(name) {
193
+ if (name === "VERSION") return "WebGL 2.0 (miaoda-game-devkit stub)";
194
+ if (name === "SHADING_LANGUAGE_VERSION") return "WebGL GLSL ES 3.00 (stub)";
195
+ if (name === "VENDOR" || name === "RENDERER") return "miaoda-game-devkit";
196
+ if (name === "MAX_VIEWPORT_DIMS") {
197
+ return new Int32Array([VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
198
+ }
199
+ if (name === "VIEWPORT" || name === "SCISSOR_BOX") {
200
+ return new Int32Array([0, 0, VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
201
+ }
202
+ if (name.includes("MAX_") && name.includes("SIZE")) return TEXTURE_SIZE_LIMIT;
203
+ if (name.startsWith("MAX_")) return TEXTURE_UNIT_LIMIT;
204
+ if (name.endsWith("_BITS")) return 8;
205
+ return 0;
206
+ }
207
+ function createWebGLContext(canvas, attributes) {
208
+ const constants = /* @__PURE__ */ new Map();
209
+ const constantNames = /* @__PURE__ */ new Map();
210
+ const state = {
211
+ canvas,
212
+ drawingBufferWidth: canvas.width,
213
+ drawingBufferHeight: canvas.height
214
+ };
215
+ const method = (name) => {
216
+ if (name === "getContextAttributes") return () => attributes;
217
+ if (name === "getExtension") return () => null;
218
+ if (name === "getSupportedExtensions") return () => [];
219
+ if (name === "getParameter") {
220
+ return (pname) => parameterValue(constantNames.get(pname) ?? "UNKNOWN");
221
+ }
222
+ if (name === "getShaderPrecisionFormat") {
223
+ return () => ({ rangeMin: 127, rangeMax: 127, precision: 23 });
224
+ }
225
+ if (name === "getShaderInfoLog" || name === "getProgramInfoLog") return () => "";
226
+ if (name === "getError") return () => 0;
227
+ if (name === "isContextLost") return () => false;
228
+ if (name.startsWith("get") && name.endsWith("Parameter")) return () => true;
229
+ if (name.startsWith("get")) return () => null;
230
+ if (name.startsWith("create") || name.startsWith("getUniformLocation")) {
231
+ return () => ({});
232
+ }
233
+ return () => void 0;
234
+ };
235
+ return new Proxy(state, {
236
+ get(target, property) {
237
+ if (property in target) return target[property];
238
+ if (typeof property !== "string") return void 0;
239
+ if (CONSTANT_NAME.test(property)) {
240
+ const existing = constants.get(property);
241
+ if (existing !== void 0) return existing;
242
+ const value = constants.size + 1;
243
+ constants.set(property, value);
244
+ constantNames.set(value, property);
245
+ return value;
246
+ }
247
+ const implementation = method(property);
248
+ target[property] = implementation;
249
+ return implementation;
250
+ }
251
+ });
252
+ }
188
253
  var contexts = /* @__PURE__ */ new WeakMap();
254
+ function getJSDOMWebGLContext(canvas, options) {
255
+ const existing = contexts.get(canvas);
256
+ if (existing) return existing;
257
+ const attributes = {
258
+ alpha: true,
259
+ antialias: false,
260
+ depth: true,
261
+ premultipliedAlpha: true,
262
+ preserveDrawingBuffer: false,
263
+ stencil: false,
264
+ ...typeof options === "object" && options !== null ? options : {}
265
+ };
266
+ const context = createWebGLContext(canvas, attributes);
267
+ contexts.set(canvas, context);
268
+ return context;
269
+ }
270
+
271
+ // src/testing/jsdom-canvas.ts
272
+ var contexts2 = /* @__PURE__ */ new WeakMap();
189
273
  function createCanvasContext(canvas) {
190
274
  const imageData = (width = 1, height = 1) => ({
191
275
  data: new Uint8ClampedArray(width * height * 4),
@@ -232,12 +316,15 @@ function createCanvasContext(canvas) {
232
316
  return context;
233
317
  }
234
318
  function installJSDOMCanvasContext() {
235
- HTMLCanvasElement.prototype.getContext = function getContext(contextId) {
319
+ HTMLCanvasElement.prototype.getContext = function getContext(contextId, options) {
320
+ if (contextId === "webgl" || contextId === "webgl2") {
321
+ return getJSDOMWebGLContext(this, options);
322
+ }
236
323
  if (contextId !== "2d") return null;
237
- const existing = contexts.get(this);
324
+ const existing = contexts2.get(this);
238
325
  if (existing) return existing;
239
326
  const context = createCanvasContext(this);
240
- contexts.set(this, context);
327
+ contexts2.set(this, context);
241
328
  return context;
242
329
  };
243
330
  }
@@ -182,8 +182,92 @@ function runtimeFailureError(code, value) {
182
182
  return error;
183
183
  }
184
184
 
185
- // src/testing/jsdom-canvas.ts
185
+ // src/testing/jsdom-webgl.ts
186
+ var CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/;
187
+ var TEXTURE_UNIT_LIMIT = 32;
188
+ var TEXTURE_SIZE_LIMIT = 4096;
189
+ var VIEWPORT_LIMIT = 4096;
190
+ function parameterValue(name) {
191
+ if (name === "VERSION") return "WebGL 2.0 (miaoda-game-devkit stub)";
192
+ if (name === "SHADING_LANGUAGE_VERSION") return "WebGL GLSL ES 3.00 (stub)";
193
+ if (name === "VENDOR" || name === "RENDERER") return "miaoda-game-devkit";
194
+ if (name === "MAX_VIEWPORT_DIMS") {
195
+ return new Int32Array([VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
196
+ }
197
+ if (name === "VIEWPORT" || name === "SCISSOR_BOX") {
198
+ return new Int32Array([0, 0, VIEWPORT_LIMIT, VIEWPORT_LIMIT]);
199
+ }
200
+ if (name.includes("MAX_") && name.includes("SIZE")) return TEXTURE_SIZE_LIMIT;
201
+ if (name.startsWith("MAX_")) return TEXTURE_UNIT_LIMIT;
202
+ if (name.endsWith("_BITS")) return 8;
203
+ return 0;
204
+ }
205
+ function createWebGLContext(canvas, attributes) {
206
+ const constants = /* @__PURE__ */ new Map();
207
+ const constantNames = /* @__PURE__ */ new Map();
208
+ const state = {
209
+ canvas,
210
+ drawingBufferWidth: canvas.width,
211
+ drawingBufferHeight: canvas.height
212
+ };
213
+ const method = (name) => {
214
+ if (name === "getContextAttributes") return () => attributes;
215
+ if (name === "getExtension") return () => null;
216
+ if (name === "getSupportedExtensions") return () => [];
217
+ if (name === "getParameter") {
218
+ return (pname) => parameterValue(constantNames.get(pname) ?? "UNKNOWN");
219
+ }
220
+ if (name === "getShaderPrecisionFormat") {
221
+ return () => ({ rangeMin: 127, rangeMax: 127, precision: 23 });
222
+ }
223
+ if (name === "getShaderInfoLog" || name === "getProgramInfoLog") return () => "";
224
+ if (name === "getError") return () => 0;
225
+ if (name === "isContextLost") return () => false;
226
+ if (name.startsWith("get") && name.endsWith("Parameter")) return () => true;
227
+ if (name.startsWith("get")) return () => null;
228
+ if (name.startsWith("create") || name.startsWith("getUniformLocation")) {
229
+ return () => ({});
230
+ }
231
+ return () => void 0;
232
+ };
233
+ return new Proxy(state, {
234
+ get(target, property) {
235
+ if (property in target) return target[property];
236
+ if (typeof property !== "string") return void 0;
237
+ if (CONSTANT_NAME.test(property)) {
238
+ const existing = constants.get(property);
239
+ if (existing !== void 0) return existing;
240
+ const value = constants.size + 1;
241
+ constants.set(property, value);
242
+ constantNames.set(value, property);
243
+ return value;
244
+ }
245
+ const implementation = method(property);
246
+ target[property] = implementation;
247
+ return implementation;
248
+ }
249
+ });
250
+ }
186
251
  var contexts = /* @__PURE__ */ new WeakMap();
252
+ function getJSDOMWebGLContext(canvas, options) {
253
+ const existing = contexts.get(canvas);
254
+ if (existing) return existing;
255
+ const attributes = {
256
+ alpha: true,
257
+ antialias: false,
258
+ depth: true,
259
+ premultipliedAlpha: true,
260
+ preserveDrawingBuffer: false,
261
+ stencil: false,
262
+ ...typeof options === "object" && options !== null ? options : {}
263
+ };
264
+ const context = createWebGLContext(canvas, attributes);
265
+ contexts.set(canvas, context);
266
+ return context;
267
+ }
268
+
269
+ // src/testing/jsdom-canvas.ts
270
+ var contexts2 = /* @__PURE__ */ new WeakMap();
187
271
  function createCanvasContext(canvas) {
188
272
  const imageData = (width = 1, height = 1) => ({
189
273
  data: new Uint8ClampedArray(width * height * 4),
@@ -230,12 +314,15 @@ function createCanvasContext(canvas) {
230
314
  return context;
231
315
  }
232
316
  function installJSDOMCanvasContext() {
233
- HTMLCanvasElement.prototype.getContext = function getContext(contextId) {
317
+ HTMLCanvasElement.prototype.getContext = function getContext(contextId, options) {
318
+ if (contextId === "webgl" || contextId === "webgl2") {
319
+ return getJSDOMWebGLContext(this, options);
320
+ }
234
321
  if (contextId !== "2d") return null;
235
- const existing = contexts.get(this);
322
+ const existing = contexts2.get(this);
236
323
  if (existing) return existing;
237
324
  const context = createCanvasContext(this);
238
- contexts.set(this, context);
325
+ contexts2.set(this, context);
239
326
  return context;
240
327
  };
241
328
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.10.0",
3
+ "version": "0.10.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",