miaoda-game-devkit 0.4.0 → 0.6.0

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.
@@ -98,6 +98,13 @@ var import_vitest = require("vitest");
98
98
 
99
99
  // src/react/react-playthrough-core.ts
100
100
  var import_react = require("@testing-library/react");
101
+ function throwIfAborted(signal) {
102
+ if (!signal?.aborted) return;
103
+ if (signal.reason instanceof Error) throw signal.reason;
104
+ throw new Error("Playthrough advancement was cancelled.", {
105
+ cause: signal.reason
106
+ });
107
+ }
101
108
  function formatDiagnostics(read) {
102
109
  if (!read) return void 0;
103
110
  try {
@@ -126,11 +133,13 @@ async function runBoundedUntil(condition, options = {}) {
126
133
  );
127
134
  }
128
135
  for (let step = 0; step <= maxSteps; step += 1) {
136
+ throwIfAborted(options.signal);
129
137
  if (condition()) return step;
130
138
  if (step < maxSteps) {
131
139
  await (0, import_react.act)(async () => {
132
140
  await options.step?.(step + 1);
133
141
  });
142
+ throwIfAborted(options.signal);
134
143
  }
135
144
  }
136
145
  const diagnostics = formatDiagnostics(options.diagnostics);
@@ -151,13 +160,35 @@ var INPUT_EVENTS = [
151
160
  "touchstart",
152
161
  "touchend"
153
162
  ];
154
- var MIN_CHECKPOINTS = 2;
155
- var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
156
- function formatObservation(fingerprint) {
157
- if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
158
- return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
163
+ var MIN_STAGES = 5;
164
+ var MIN_MILESTONES = 3;
165
+ var MAX_TRACE_VALUE_LENGTH = 140;
166
+ var MAX_TRACE_LENGTH = 720;
167
+ var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
168
+ function truncateTraceValue(value, limit) {
169
+ const compact = value.replace(/\s+/g, " ").trim();
170
+ if (compact.length <= limit) return compact;
171
+ return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
159
172
  }
160
- function sampleObservation(observe, stage) {
173
+ function formatReactPlaythroughFailureTrace(trace) {
174
+ const stages = trace.stages.map(
175
+ (stage) => `${stage.kind}:${stage.name}=${truncateTraceValue(stage.state, MAX_TRACE_VALUE_LENGTH)}`
176
+ );
177
+ if (trace.current) {
178
+ stages.push(
179
+ `current:${trace.current.name}=${truncateTraceValue(trace.current.before, 70)}\u2192${truncateTraceValue(trace.current.last, 70)}`
180
+ );
181
+ }
182
+ const step = trace.step ? `stepUntil=${trace.step.completed ?? "failed"}/${trace.step.bound}` : void 0;
183
+ const formatted = `${stages.length > 0 ? stages.join(" -> ") : "stages=none"}${step ? `; ${step}` : ""}`;
184
+ return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
185
+ }
186
+ var MAX_FORMATTED_STATE_LENGTH = 500;
187
+ function formatState(fingerprint) {
188
+ if (fingerprint.length <= MAX_FORMATTED_STATE_LENGTH) return fingerprint;
189
+ return `${fingerprint.slice(0, MAX_FORMATTED_STATE_LENGTH)}\u2026 (${fingerprint.length} chars)`;
190
+ }
191
+ function sampleObservedState(observe, stage) {
161
192
  let value;
162
193
  try {
163
194
  value = observe();
@@ -167,222 +198,272 @@ function sampleObservation(observe, stage) {
167
198
  try {
168
199
  const fingerprint = JSON.stringify(value);
169
200
  if (fingerprint === void 0) throw new Error("unsupported value");
170
- return { fingerprint, formatted: formatObservation(fingerprint) };
201
+ return { fingerprint, formatted: formatState(fingerprint) };
171
202
  } catch {
172
203
  throw new Error(
173
204
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
174
205
  );
175
206
  }
176
207
  }
177
- function formatObservationTimeline(entered, afterPrimary, outcome) {
178
- return [
179
- `entered=${entered.formatted}`,
180
- `after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
181
- `outcome=${outcome.formatted}`
182
- ].join(", ");
208
+ function sampleDomState(view) {
209
+ const fingerprint = view.container.innerHTML.replace(/\s+/g, " ").trim();
210
+ return { fingerprint, formatted: formatState(JSON.stringify(fingerprint)) };
211
+ }
212
+ function createEvidence() {
213
+ return { domInputEvents: 0, stages: [], verified: false };
214
+ }
215
+ function createMetadata(waiverReason) {
216
+ return { version: 4, waiverReason, evidence: createEvidence() };
217
+ }
218
+ function stageLabel(kind, name) {
219
+ return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
183
220
  }
184
221
  function describeMissingEvidence(evidence) {
185
- if (!evidence || evidence.entryInputs === 0) return "an entry input";
186
- if (evidence.primaryInputs === 0) return "a primary gameplay input";
187
- if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
188
- if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
189
- if (evidence.assertionsAfterOutcome === 0)
190
- return "an outcome assertion after stepUntil";
191
- if (evidence.checkpoints.length < MIN_CHECKPOINTS)
192
- return `at least ${MIN_CHECKPOINTS} checkpoints`;
193
- if (!evidence.checkpoints.some(
194
- (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
195
- )) {
196
- return "progress/terminal checkpoint";
222
+ if (!evidence || evidence.stages.length === 0) return "an enter stage";
223
+ if (evidence.stages[0]?.kind !== "entered") return "the entered stage";
224
+ const milestones = evidence.stages.filter(
225
+ (stage) => stage.kind === "milestone"
226
+ );
227
+ if (milestones.length < MIN_MILESTONES) {
228
+ return `at least ${MIN_MILESTONES} gameplay milestones`;
229
+ }
230
+ const finalStage = evidence.stages.at(-1);
231
+ if (finalStage?.kind !== "progress" && finalStage?.kind !== "terminal") {
232
+ return "a progress or terminal finish stage";
233
+ }
234
+ if (evidence.stages.length < MIN_STAGES) {
235
+ return `at least ${MIN_STAGES} evidenced stages`;
197
236
  }
198
237
  return "a complete playthrough verification marker";
199
238
  }
200
- function createMetadata(waiverReason) {
201
- return {
202
- version: 3,
203
- waiverReason,
204
- evidence: {
205
- domInputEvents: 0,
206
- entryInputs: 0,
207
- primaryInputs: 0,
208
- boundedRuns: 0,
209
- assertionsAfterOutcome: 0,
210
- checkpoints: [],
211
- verified: false
212
- }
213
- };
214
- }
215
239
  function definePlaythrough(element, run, playthroughOptions, waiverReason) {
216
240
  const reason = normalizePlaythroughWaiverReason(waiverReason);
217
241
  const metadata = createMetadata(reason);
218
- (0, import_vitest.test)("production game completes a bounded playthrough", {
219
- skip: Boolean(reason),
220
- meta: { reactPlaythrough: metadata }
221
- }, async ({ expect }) => {
222
- const evidence = metadata.evidence;
223
- let assertionsAtOutcome;
224
- let enteredRecorded = false;
225
- let domTextAtEntered;
226
- let enteredObservation;
227
- let afterPrimaryObservation;
228
- const recordInput = () => {
229
- evidence.domInputEvents += 1;
230
- };
231
- for (const event of INPUT_EVENTS) {
232
- document.addEventListener(event, recordInput, true);
233
- }
234
- try {
235
- const view = (0, import_react2.render)(element);
236
- if (view.container.childNodes.length === 0) {
237
- throw new Error(
238
- "playthroughTest must render the production game entry."
239
- );
242
+ (0, import_vitest.test)(
243
+ "production game completes a bounded playthrough",
244
+ {
245
+ concurrent: false,
246
+ skip: Boolean(reason),
247
+ meta: { reactPlaythrough: metadata }
248
+ },
249
+ async ({ annotate, expect, onTestFailed, signal }) => {
250
+ metadata.evidence = createEvidence();
251
+ metadata.trace = void 0;
252
+ const evidence = metadata.evidence;
253
+ let entered = false;
254
+ let finished = false;
255
+ let activeStage;
256
+ let lastSample;
257
+ let stepTrace;
258
+ let failureTraceFactory;
259
+ let acceptingStageInput = false;
260
+ let inputCaptureAttached = false;
261
+ const recordInput = () => {
262
+ if (acceptingStageInput) evidence.domInputEvents += 1;
263
+ };
264
+ const stopInputCapture = () => {
265
+ acceptingStageInput = false;
266
+ if (!inputCaptureAttached) return;
267
+ inputCaptureAttached = false;
268
+ for (const event of INPUT_EVENTS) {
269
+ document.removeEventListener(event, recordInput, true);
270
+ }
271
+ };
272
+ const captureFailureTrace = () => {
273
+ try {
274
+ return failureTraceFactory?.() ?? "stages=none";
275
+ } catch {
276
+ return "stages=none";
277
+ }
278
+ };
279
+ onTestFailed(() => {
280
+ metadata.trace ??= captureFailureTrace();
281
+ });
282
+ for (const event of INPUT_EVENTS) {
283
+ document.addEventListener(event, recordInput, true);
240
284
  }
241
- const user = import_user_event.default.setup();
242
- await run({
243
- view,
244
- user,
245
- expect,
246
- async performInput(kind, input) {
247
- if (kind === "entry" && enteredRecorded) {
285
+ inputCaptureAttached = true;
286
+ signal.addEventListener("abort", stopInputCapture, { once: true });
287
+ try {
288
+ const view = (0, import_react2.render)(element);
289
+ if (view.container.childNodes.length === 0) {
290
+ throw new Error(
291
+ "playthroughTest must render the production game entry."
292
+ );
293
+ }
294
+ const user = import_user_event.default.setup();
295
+ const sampleState = (label) => playthroughOptions?.observe ? sampleObservedState(playthroughOptions.observe, label) : sampleDomState(view);
296
+ lastSample = sampleState("initial render");
297
+ failureTraceFactory = () => formatReactPlaythroughFailureTrace({
298
+ stages: evidence.stages.map((stage) => ({
299
+ name: stage.name,
300
+ kind: stage.kind,
301
+ state: stage.after
302
+ })),
303
+ current: activeStage ? {
304
+ name: stageLabel(activeStage.kind, activeStage.name),
305
+ before: activeStage.before.formatted,
306
+ last: (() => {
307
+ try {
308
+ return sampleState("failure").formatted;
309
+ } catch (error) {
310
+ return `<state unavailable: ${String(error)}>`;
311
+ }
312
+ })()
313
+ } : void 0,
314
+ step: stepTrace
315
+ });
316
+ const executeStage = async (name, kind, stage) => {
317
+ const normalizedName = name.trim();
318
+ if (normalizedName.length === 0) {
319
+ throw new Error("playthrough stage names must be non-empty strings.");
320
+ }
321
+ if (evidence.stages.some(
322
+ (completed) => completed.name === normalizedName
323
+ )) {
248
324
  throw new Error(
249
- 'performInput("entry") must run before checkpoint("entered"). Group multiple setup actions in the same callback.'
325
+ `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
250
326
  );
251
327
  }
252
- if (kind === "primary" && !enteredRecorded) {
328
+ if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
253
329
  throw new Error(
254
- 'Before performInput("primary"), run an entry input and checkpoint("entered").'
330
+ `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
255
331
  );
256
332
  }
257
- const inputsBefore = evidence.domInputEvents;
258
- await input();
259
- if (evidence.domInputEvents === inputsBefore) {
333
+ const before = lastSample ?? sampleState(`before ${normalizedName}`);
334
+ activeStage = { name: normalizedName, kind, before };
335
+ stepTrace = { bound: stage.maxSteps ?? 120 };
336
+ if (stage.until()) {
260
337
  throw new Error(
261
- `performInput("${kind}") did not dispatch a supported production DOM input. Use the provided user or dispatch a real keyboard, pointer, or touch event to the production target.`
338
+ `${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.`
262
339
  );
263
340
  }
264
- if (kind === "entry") evidence.entryInputs += 1;
265
- else {
266
- evidence.primaryInputs += 1;
267
- if (playthroughOptions?.observe) {
268
- afterPrimaryObservation = sampleObservation(
269
- playthroughOptions.observe,
270
- "after-primary"
271
- );
272
- }
273
- }
274
- },
275
- checkpoint(kind) {
276
- if (kind === "entered") {
277
- if (enteredRecorded) {
278
- throw new Error(
279
- 'checkpoint("entered") may only be recorded once, before the primary input.'
280
- );
341
+ const inputsBefore = evidence.domInputEvents;
342
+ if (stage.act) {
343
+ acceptingStageInput = true;
344
+ try {
345
+ await stage.act();
346
+ } finally {
347
+ acceptingStageInput = false;
281
348
  }
282
- if (evidence.entryInputs === 0) {
349
+ if (evidence.domInputEvents === inputsBefore) {
283
350
  throw new Error(
284
- 'checkpoint("entered") must follow performInput("entry", ...).'
285
- );
286
- }
287
- enteredRecorded = true;
288
- if (playthroughOptions?.observe) {
289
- enteredObservation = sampleObservation(
290
- playthroughOptions.observe,
291
- "entered"
351
+ `${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.`
292
352
  );
293
- } else {
294
- domTextAtEntered = document.body.textContent ?? "";
295
353
  }
296
- evidence.checkpoints.push(kind);
297
- return;
298
354
  }
299
- if (evidence.primaryInputs === 0) {
355
+ let advancedSteps = 0;
356
+ const stepBound = stage.maxSteps ?? 120;
357
+ stepTrace = { bound: stepBound };
358
+ const steps = await runBoundedUntil(stage.until, {
359
+ maxSteps: stage.maxSteps,
360
+ signal,
361
+ diagnostics: stage.diagnostics ?? playthroughOptions?.observe,
362
+ step: stage.step ? async (step) => {
363
+ advancedSteps += 1;
364
+ await stage.step?.(step);
365
+ } : void 0
366
+ });
367
+ stepTrace = { bound: stepBound, completed: steps };
368
+ if (!stage.act && advancedSteps === 0) {
300
369
  throw new Error(
301
- `checkpoint("${kind}") must follow performInput("primary", ...).`
370
+ `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
302
371
  );
303
372
  }
304
- if (evidence.boundedRuns === 0) {
373
+ const assertionsBefore = expect.getState().assertionCalls;
374
+ await stage.assert({ expect, user, view });
375
+ const assertions = expect.getState().assertionCalls - assertionsBefore;
376
+ if (assertions === 0) {
305
377
  throw new Error(
306
- `checkpoint("${kind}") must be recorded after stepUntil returns.`
378
+ `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
307
379
  );
308
380
  }
309
- if (assertionsAtOutcome === void 0 || expect.getState().assertionCalls <= assertionsAtOutcome) {
381
+ const after = sampleState(`after ${normalizedName}`);
382
+ if (after.fingerprint === before.fingerprint) {
383
+ const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
310
384
  throw new Error(
311
- `Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}").`
385
+ `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
312
386
  );
313
387
  }
314
- evidence.checkpoints.push(kind);
315
- },
316
- async stepUntil(condition, stepOptions = {}) {
317
- const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
318
- ...stepOptions,
319
- diagnostics: playthroughOptions.observe
320
- };
321
- const steps = await runBoundedUntil(condition, boundedOptions);
322
- if (evidence.primaryInputs === 0) {
323
- throw new Error(
324
- 'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
325
- );
326
- }
327
- if (playthroughOptions?.observe) {
328
- if (!enteredObservation) {
329
- throw new Error(
330
- 'observe requires checkpoint("entered") before primary gameplay input.'
331
- );
388
+ evidence.stages.push({
389
+ name: normalizedName,
390
+ kind,
391
+ domInputEvents: evidence.domInputEvents - inputsBefore,
392
+ advancedSteps,
393
+ assertions,
394
+ stateChanged: true,
395
+ before: before.formatted,
396
+ after: after.formatted
397
+ });
398
+ lastSample = after;
399
+ activeStage = void 0;
400
+ };
401
+ await run({
402
+ view,
403
+ user,
404
+ async enter(stage) {
405
+ if (entered) {
406
+ throw new Error("enter may only be called once.");
332
407
  }
333
- const outcomeObservation = sampleObservation(
334
- playthroughOptions.observe,
335
- "outcome"
336
- );
337
- if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
338
- throw new Error(
339
- `The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
340
- );
408
+ if (evidence.stages.length > 0) {
409
+ throw new Error("enter must be the first playthrough stage.");
341
410
  }
342
- } else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
343
- throw new Error(
344
- 'stepUntil found the outcome at step 0, and the DOM has not changed since checkpoint("entered"). The flow therefore provides no evidence that the primary gameplay input produced a result. For Canvas or Controller state outside the DOM, declare one playthrough observe callback.'
345
- );
411
+ await executeStage("entered", "entered", stage);
412
+ entered = true;
413
+ },
414
+ async milestone(name, stage) {
415
+ if (!entered) {
416
+ throw new Error("milestone must follow enter.");
417
+ }
418
+ if (finished) {
419
+ throw new Error("milestone cannot run after finish.");
420
+ }
421
+ await executeStage(name, "milestone", stage);
422
+ },
423
+ async finish(name, stage) {
424
+ if (!entered) throw new Error("finish must follow enter.");
425
+ if (finished) {
426
+ throw new Error("finish may only be called once.");
427
+ }
428
+ await executeStage(name, stage.kind, stage);
429
+ finished = true;
346
430
  }
347
- evidence.boundedRuns += 1;
348
- assertionsAtOutcome = expect.getState().assertionCalls;
349
- return steps;
350
- }
351
- });
352
- const assertionCalls = expect.getState().assertionCalls;
353
- evidence.assertionsAfterOutcome = assertionsAtOutcome === void 0 ? 0 : assertionCalls - assertionsAtOutcome;
354
- if (evidence.entryInputs === 0) {
355
- throw new Error(
356
- 'playthroughTest must perform an entry input with performInput("entry", ...).'
357
- );
358
- }
359
- if (evidence.primaryInputs === 0) {
360
- throw new Error(
361
- 'playthroughTest must perform a core game action with performInput("primary", ...).'
431
+ });
432
+ const milestones = evidence.stages.filter(
433
+ (stage) => stage.kind === "milestone"
362
434
  );
363
- }
364
- if (evidence.boundedRuns === 0) {
365
- throw new Error("playthroughTest must complete one bounded stepUntil.");
366
- }
367
- if (evidence.assertionsAfterOutcome === 0) {
368
- throw new Error(
369
- "Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
370
- );
371
- }
372
- if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
373
- (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
374
- )) {
375
- throw new Error(
376
- `playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
377
- );
378
- }
379
- evidence.verified = true;
380
- } finally {
381
- for (const event of INPUT_EVENTS) {
382
- document.removeEventListener(event, recordInput, true);
435
+ if (!entered) throw new Error("playthroughTest must call enter once.");
436
+ if (milestones.length < MIN_MILESTONES) {
437
+ throw new Error(
438
+ `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
439
+ );
440
+ }
441
+ if (!finished) {
442
+ throw new Error(
443
+ 'playthroughTest must call finish with kind "progress" or "terminal".'
444
+ );
445
+ }
446
+ if (evidence.stages.length < MIN_STAGES) {
447
+ throw new Error(
448
+ `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
449
+ );
450
+ }
451
+ evidence.verified = true;
452
+ } catch (error) {
453
+ const trace = captureFailureTrace();
454
+ metadata.trace = trace;
455
+ try {
456
+ await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
457
+ } catch {
458
+ }
459
+ throw error;
460
+ } finally {
461
+ metadata.trace ??= failureTraceFactory?.();
462
+ signal.removeEventListener("abort", stopInputCapture);
463
+ stopInputCapture();
383
464
  }
384
465
  }
385
- });
466
+ );
386
467
  }
387
468
  var playthroughTest = Object.assign(
388
469
  (element, optionsOrRun, maybeRun) => {
@@ -390,19 +471,29 @@ var playthroughTest = Object.assign(
390
471
  definePlaythrough(element, optionsOrRun);
391
472
  return;
392
473
  }
393
- if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
474
+ if (!maybeRun) {
475
+ throw new TypeError("playthroughTest requires a run callback.");
476
+ }
394
477
  definePlaythrough(element, maybeRun, optionsOrRun);
395
478
  },
396
479
  {
397
- skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
480
+ skip: (reason, element) => definePlaythrough(element, async () => {
481
+ }, void 0, reason)
398
482
  }
399
483
  );
400
484
  function auditReactPlaythroughRun(tests) {
401
485
  const declared = tests.filter((candidate) => candidate.metadata);
402
486
  const valid = declared.filter(({ state, metadata }) => {
403
487
  const evidence = metadata?.evidence;
404
- return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.checkpoints.length >= MIN_CHECKPOINTS && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
405
- (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
488
+ if (!evidence || state !== "passed" || evidence.verified !== true) {
489
+ return false;
490
+ }
491
+ const milestones = evidence.stages.filter(
492
+ (stage) => stage.kind === "milestone"
493
+ );
494
+ const finalStage = evidence.stages.at(-1);
495
+ return evidence.domInputEvents > 0 && evidence.stages.length >= MIN_STAGES && evidence.stages[0]?.kind === "entered" && milestones.length >= MIN_MILESTONES && (finalStage?.kind === "progress" || finalStage?.kind === "terminal") && evidence.stages.every(
496
+ (stage) => stage.assertions > 0 && (stage.domInputEvents > 0 || stage.advancedSteps > 0) && stage.stateChanged === true
406
497
  );
407
498
  });
408
499
  const waivers = declared.filter(
@@ -411,13 +502,11 @@ function auditReactPlaythroughRun(tests) {
411
502
  const issues = [];
412
503
  if (declared.length === 0) {
413
504
  issues.push(
414
- 'No production gameplay verification was declared. Use playthroughTest to render <App />, then run performInput("entry"), checkpoint("entered"), performInput("primary"), and a bounded stepUntil. Assert the authoritative outcome with the expect provided by playthroughTest, then record checkpoint("progress") or checkpoint("terminal").'
505
+ "No production gameplay verification was declared. Use playthroughTest to render <App />, then compose one real-input enter stage, at least three named milestones, and finish. Later stages may drive production input or deterministic advancement; every stage must reach and assert a bounded new result."
415
506
  );
416
507
  } else {
417
508
  for (const candidate of declared) {
418
- const isValid = valid.includes(candidate);
419
- const isWaived = waivers.includes(candidate);
420
- if (isValid || isWaived) continue;
509
+ if (valid.includes(candidate) || waivers.includes(candidate)) continue;
421
510
  if (candidate.state === "skipped") {
422
511
  issues.push(
423
512
  `Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
@@ -427,9 +516,8 @@ function auditReactPlaythroughRun(tests) {
427
516
  `Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
428
517
  );
429
518
  } else {
430
- const missing = describeMissingEvidence(candidate.metadata?.evidence);
431
519
  issues.push(
432
- `Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
520
+ `Playthrough ${JSON.stringify(candidate.name)} is missing ${describeMissingEvidence(candidate.metadata?.evidence)}.`
433
521
  );
434
522
  }
435
523
  }