miaoda-game-devkit 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {
@@ -96,14 +96,112 @@ var import_react2 = require("@testing-library/react");
96
96
  var import_user_event = __toESM(require("@testing-library/user-event"));
97
97
  var import_vitest = require("vitest");
98
98
 
99
+ // src/react/react-error-diagnostics.ts
100
+ var MAX_DIAGNOSTIC_LENGTH = 1e3;
101
+ function truncate(value) {
102
+ const trimmed = value.trim();
103
+ if (trimmed.length <= MAX_DIAGNOSTIC_LENGTH) return trimmed;
104
+ return `${trimmed.slice(0, MAX_DIAGNOSTIC_LENGTH - 1)}\u2026`;
105
+ }
106
+ function safeJson(value) {
107
+ const seen = /* @__PURE__ */ new WeakSet();
108
+ try {
109
+ return JSON.stringify(value, (_key, nested) => {
110
+ if (typeof nested === "bigint") return `${nested}n`;
111
+ if (typeof nested === "function") {
112
+ return `Function<${nested.name || "anonymous"}>`;
113
+ }
114
+ if (typeof nested === "symbol") return nested.toString();
115
+ if (nested && typeof nested === "object") {
116
+ if (seen.has(nested)) return "[Circular]";
117
+ seen.add(nested);
118
+ }
119
+ return nested;
120
+ });
121
+ } catch {
122
+ return void 0;
123
+ }
124
+ }
125
+ function collectEntries(value, fallbackCode, seen) {
126
+ if (typeof value === "string") {
127
+ return value.trim() ? [{ code: fallbackCode, message: truncate(value) }] : [];
128
+ }
129
+ if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") {
130
+ return [{ code: fallbackCode, message: String(value) }];
131
+ }
132
+ if (typeof value === "function") {
133
+ return [
134
+ { code: fallbackCode, message: `Function<${value.name || "anonymous"}>` }
135
+ ];
136
+ }
137
+ if (seen.has(value)) return [];
138
+ seen.add(value);
139
+ if (Array.isArray(value)) {
140
+ return value.flatMap((item) => collectEntries(item, fallbackCode, seen));
141
+ }
142
+ const record = value;
143
+ const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : fallbackCode;
144
+ const entries = [];
145
+ if (typeof record.message === "string" && record.message.trim()) {
146
+ entries.push({ code, message: truncate(record.message) });
147
+ }
148
+ if (record.cause !== void 0) {
149
+ entries.push(...collectEntries(record.cause, fallbackCode, seen));
150
+ }
151
+ if (Array.isArray(record.errors)) {
152
+ entries.push(...collectEntries(record.errors, fallbackCode, seen));
153
+ }
154
+ if (entries.length > 0) return entries;
155
+ if (typeof record.stack === "string" && record.stack.trim()) {
156
+ return [{ code, message: truncate(record.stack) }];
157
+ }
158
+ const json = safeJson(value);
159
+ return json && json !== "{}" ? [{ code, message: truncate(json) }] : [];
160
+ }
161
+ function extractFailureEntries(value, fallbackCode = "TEST_FAILURE") {
162
+ const entries = collectEntries(value, fallbackCode, /* @__PURE__ */ new WeakSet());
163
+ const keys = /* @__PURE__ */ new Set();
164
+ return entries.filter((entry) => {
165
+ const key = `${entry.code}\0${entry.message}`;
166
+ if (keys.has(key)) return false;
167
+ keys.add(key);
168
+ return true;
169
+ });
170
+ }
171
+ function createFailureDiagnostic(source, value) {
172
+ return { source, entries: extractFailureEntries(value) };
173
+ }
174
+ function appendCurrentAttemptFailures(current, runnerValue) {
175
+ const runner = createFailureDiagnostic("test-runtime", runnerValue);
176
+ if (!current || current.entries.length === 0) return runner;
177
+ const primary = current.entries[0];
178
+ const currentStart = runner.entries.findIndex(
179
+ (entry) => entry.code === primary.code && entry.message === primary.message
180
+ );
181
+ if (currentStart < 0) return current;
182
+ return {
183
+ source: current.source,
184
+ entries: extractFailureEntries([
185
+ ...current.entries,
186
+ ...runner.entries.slice(currentStart + 1)
187
+ ])
188
+ };
189
+ }
190
+ function codedError(code, message) {
191
+ const error = new Error(message);
192
+ error.code = code;
193
+ return error;
194
+ }
195
+
99
196
  // src/react/react-playthrough-core.ts
100
197
  var import_react = require("@testing-library/react");
101
198
  function throwIfAborted(signal) {
102
199
  if (!signal?.aborted) return;
103
200
  if (signal.reason instanceof Error) throw signal.reason;
104
- throw new Error("Playthrough advancement was cancelled.", {
105
- cause: signal.reason
106
- });
201
+ throw codedError(
202
+ "PLAYTHROUGH_CANCELLED",
203
+ `Playthrough advancement was cancelled. Cause: ${String(signal.reason)}`
204
+ );
107
205
  }
108
206
  function formatDiagnostics(read) {
109
207
  if (!read) return void 0;
@@ -119,7 +217,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
119
217
  if (waiverReason === void 0) return void 0;
120
218
  const reason = waiverReason.trim();
121
219
  if (reason.length < 20) {
122
- throw new Error(
220
+ throw codedError(
221
+ "INVALID_PLAYTHROUGH_WAIVER",
123
222
  "playthroughTest.skip reason must contain at least 20 characters."
124
223
  );
125
224
  }
@@ -128,7 +227,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
128
227
  async function runBoundedUntil(condition, options = {}) {
129
228
  const maxSteps = options.maxSteps ?? 120;
130
229
  if (!Number.isSafeInteger(maxSteps) || maxSteps < 0 || maxSteps > 1e4) {
131
- throw new RangeError(
230
+ throw codedError(
231
+ "INVALID_STEP_BOUND",
132
232
  "stepUntil maxSteps must be a safe integer between 0 and 10000."
133
233
  );
134
234
  }
@@ -145,7 +245,8 @@ async function runBoundedUntil(condition, options = {}) {
145
245
  const diagnostics = formatDiagnostics(options.diagnostics);
146
246
  const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, so time-driven gameplay was not advanced. Inject a ManualGameClock for this test and pass step: () => clock.stepFrame().";
147
247
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
148
- throw new Error(
248
+ throw codedError(
249
+ options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
149
250
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
150
251
  );
151
252
  }
@@ -198,14 +299,18 @@ function sampleObservedState(observe, stage) {
198
299
  try {
199
300
  value = observe();
200
301
  } catch (error) {
201
- throw new Error(`observe() threw at ${stage}: ${String(error)}`);
302
+ throw codedError(
303
+ "OBSERVE_FAILED",
304
+ `observe() threw at ${stage}: ${String(error)}`
305
+ );
202
306
  }
203
307
  try {
204
308
  const fingerprint = JSON.stringify(value);
205
309
  if (fingerprint === void 0) throw new Error("unsupported value");
206
310
  return { fingerprint, formatted: formatState(fingerprint) };
207
311
  } catch {
208
- throw new Error(
312
+ throw codedError(
313
+ "OBSERVE_NOT_SERIALIZABLE",
209
314
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
210
315
  );
211
316
  }
@@ -218,7 +323,7 @@ function createEvidence() {
218
323
  return { domInputEvents: 0, stages: [], verified: false };
219
324
  }
220
325
  function createMetadata(waiverReason) {
221
- return { version: 4, waiverReason, evidence: createEvidence() };
326
+ return { version: 5, waiverReason, evidence: createEvidence() };
222
327
  }
223
328
  function stageLabel(kind, name) {
224
329
  return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
@@ -254,6 +359,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
254
359
  async ({ annotate, expect, onTestFailed, signal }) => {
255
360
  metadata.evidence = createEvidence();
256
361
  metadata.trace = void 0;
362
+ metadata.failure = void 0;
257
363
  const evidence = metadata.evidence;
258
364
  let entered = false;
259
365
  let finished = false;
@@ -284,8 +390,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
284
390
  return "stages=none";
285
391
  }
286
392
  };
287
- onTestFailed(() => {
393
+ onTestFailed(({ task }) => {
288
394
  metadata.trace ??= captureFailureTrace();
395
+ metadata.failure = appendCurrentAttemptFailures(
396
+ metadata.failure,
397
+ task.result?.errors ?? []
398
+ );
289
399
  });
290
400
  for (const event of INPUT_EVENTS) {
291
401
  document.addEventListener(event, recordInput, true);
@@ -295,7 +405,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
295
405
  try {
296
406
  const view = (0, import_react2.render)(element);
297
407
  if (view.container.childNodes.length === 0) {
298
- throw new Error(
408
+ throw codedError(
409
+ "PRODUCTION_ENTRY_NOT_RENDERED",
299
410
  "playthroughTest must render the production game entry."
300
411
  );
301
412
  }
@@ -324,17 +435,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
324
435
  const executeStage = async (name, kind, stage) => {
325
436
  const normalizedName = name.trim();
326
437
  if (normalizedName.length === 0) {
327
- throw new Error("playthrough stage names must be non-empty strings.");
438
+ throw codedError(
439
+ "INVALID_STAGE_NAME",
440
+ "playthrough stage names must be non-empty strings."
441
+ );
328
442
  }
329
443
  if (evidence.stages.some(
330
444
  (completed) => completed.name === normalizedName
331
445
  )) {
332
- throw new Error(
446
+ throw codedError(
447
+ "DUPLICATE_STAGE_NAME",
333
448
  `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
334
449
  );
335
450
  }
336
451
  if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
337
- throw new Error(
452
+ throw codedError(
453
+ "RESERVED_STAGE_NAME",
338
454
  `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
339
455
  );
340
456
  }
@@ -342,12 +458,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
342
458
  activeStage = { name: normalizedName, kind, before };
343
459
  stepTrace = { bound: stage.maxSteps ?? 120 };
344
460
  if (stage.step && !playthroughOptions?.observe) {
345
- throw new Error(
461
+ throw codedError(
462
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
346
463
  `${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
464
  );
348
465
  }
349
466
  if (stage.until()) {
350
- throw new Error(
467
+ throw codedError(
468
+ "STAGE_OUTCOME_ALREADY_REACHED",
351
469
  `${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.`
352
470
  );
353
471
  }
@@ -361,12 +479,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
361
479
  acceptingStageInput = false;
362
480
  }
363
481
  if (evidence.domInputEvents === inputsBefore) {
364
- throw new Error(
482
+ throw codedError(
483
+ "PRODUCTION_INPUT_NOT_DISPATCHED",
365
484
  `${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.`
366
485
  );
367
486
  }
368
487
  if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
369
- throw new Error(
488
+ throw codedError(
489
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
370
490
  `${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
491
  );
372
492
  }
@@ -385,7 +505,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
385
505
  });
386
506
  stepTrace = { bound: stepBound, completed: steps };
387
507
  if (!stage.act && advancedSteps === 0) {
388
- throw new Error(
508
+ throw codedError(
509
+ "AUTONOMOUS_STAGE_NOT_ADVANCED",
389
510
  `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
390
511
  );
391
512
  }
@@ -393,14 +514,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
393
514
  await stage.assert({ expect, user, view });
394
515
  const assertions = expect.getState().assertionCalls - assertionsBefore;
395
516
  if (assertions === 0) {
396
- throw new Error(
517
+ throw codedError(
518
+ "STAGE_ASSERTION_MISSING",
397
519
  `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
398
520
  );
399
521
  }
400
522
  const after = sampleState(`after ${normalizedName}`);
401
523
  if (after.fingerprint === before.fingerprint) {
402
524
  const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
403
- throw new Error(
525
+ throw codedError(
526
+ "STAGE_STATE_UNCHANGED",
404
527
  `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
405
528
  );
406
529
  }
@@ -422,27 +545,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
422
545
  user,
423
546
  async enter(stage) {
424
547
  if (entered) {
425
- throw new Error("enter may only be called once.");
548
+ throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
426
549
  }
427
550
  if (evidence.stages.length > 0) {
428
- throw new Error("enter must be the first playthrough stage.");
551
+ throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
429
552
  }
430
553
  await executeStage("entered", "entered", stage);
431
554
  entered = true;
432
555
  },
433
556
  async milestone(name, stage) {
434
557
  if (!entered) {
435
- throw new Error("milestone must follow enter.");
558
+ throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
436
559
  }
437
560
  if (finished) {
438
- throw new Error("milestone cannot run after finish.");
561
+ throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
439
562
  }
440
563
  await executeStage(name, "milestone", stage);
441
564
  },
442
565
  async finish(name, stage) {
443
- if (!entered) throw new Error("finish must follow enter.");
566
+ if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
444
567
  if (finished) {
445
- throw new Error("finish may only be called once.");
568
+ throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
446
569
  }
447
570
  await executeStage(name, stage.kind, stage);
448
571
  finished = true;
@@ -451,19 +574,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
451
574
  const milestones = evidence.stages.filter(
452
575
  (stage) => stage.kind === "milestone"
453
576
  );
454
- if (!entered) throw new Error("playthroughTest must call enter once.");
577
+ if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
455
578
  if (milestones.length < MIN_MILESTONES) {
456
- throw new Error(
579
+ throw codedError(
580
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
457
581
  `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
458
582
  );
459
583
  }
460
584
  if (!finished) {
461
- throw new Error(
585
+ throw codedError(
586
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
462
587
  'playthroughTest must call finish with kind "progress" or "terminal".'
463
588
  );
464
589
  }
465
590
  if (evidence.stages.length < MIN_STAGES) {
466
- throw new Error(
591
+ throw codedError(
592
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
467
593
  `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
468
594
  );
469
595
  }
@@ -471,6 +597,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
471
597
  } catch (error) {
472
598
  const trace = captureFailureTrace();
473
599
  metadata.trace = trace;
600
+ metadata.failure = createFailureDiagnostic("playthrough", error);
474
601
  try {
475
602
  await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
476
603
  } catch {