miaoda-game-devkit 0.1.0 → 0.2.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.
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ __export(src_exports, {
35
35
  assertSceneTextHealth: () => assertSceneTextHealth,
36
36
  collectMissingBitmapGlyphs: () => collectMissingBitmapGlyphs,
37
37
  createHeadlessGame: () => createHeadlessGame,
38
+ gameplayTest: () => gameplayTest,
38
39
  registerGameplayContract: () => registerGameplayContract
39
40
  });
40
41
  module.exports = __toCommonJS(src_exports);
@@ -62,7 +63,9 @@ function isEffectivelyRenderable(label) {
62
63
  }
63
64
  function requireFinite(problems, label, property, value) {
64
65
  if (!Number.isFinite(value)) {
65
- problems.push(`${describeText(label)} has non-finite ${property}: ${String(value)}`);
66
+ problems.push(
67
+ `${describeText(label)} has non-finite ${property}: ${String(value)}`
68
+ );
66
69
  }
67
70
  }
68
71
  function collectSceneTexts(scene) {
@@ -116,7 +119,9 @@ function collectMissingBitmapGlyphs(text, chars) {
116
119
  const code = text.charCodeAt(index);
117
120
  if (seen.has(code) || chars[code] !== void 0) continue;
118
121
  seen.add(code);
119
- missing.push(`${JSON.stringify(character)} (U+${code.toString(16).toUpperCase().padStart(4, "0")})`);
122
+ missing.push(
123
+ `${JSON.stringify(character)} (U+${code.toString(16).toUpperCase().padStart(4, "0")})`
124
+ );
120
125
  }
121
126
  return missing;
122
127
  }
@@ -141,19 +146,29 @@ function collectTextHealthProblems(scene) {
141
146
  requireFinite(problems, label, "resolution", label.style.resolution);
142
147
  const requiresPositiveSize = /\S/u.test(label.text) && isEffectivelyRenderable(label);
143
148
  if (requiresPositiveSize && Number.isFinite(label.width) && label.width <= 0) {
144
- problems.push(`${describeText(label)} has non-positive width: ${label.width}`);
149
+ problems.push(
150
+ `${describeText(label)} has non-positive width: ${label.width}`
151
+ );
145
152
  }
146
153
  if (requiresPositiveSize && Number.isFinite(label.height) && label.height <= 0) {
147
- problems.push(`${describeText(label)} has non-positive height: ${label.height}`);
154
+ problems.push(
155
+ `${describeText(label)} has non-positive height: ${label.height}`
156
+ );
148
157
  }
149
158
  if (Number.isFinite(label.style.fixedWidth) && label.style.fixedWidth < 0) {
150
- problems.push(`${describeText(label)} has negative fixedWidth: ${label.style.fixedWidth}`);
159
+ problems.push(
160
+ `${describeText(label)} has negative fixedWidth: ${label.style.fixedWidth}`
161
+ );
151
162
  }
152
163
  if (Number.isFinite(label.style.fixedHeight) && label.style.fixedHeight < 0) {
153
- problems.push(`${describeText(label)} has negative fixedHeight: ${label.style.fixedHeight}`);
164
+ problems.push(
165
+ `${describeText(label)} has negative fixedHeight: ${label.style.fixedHeight}`
166
+ );
154
167
  }
155
168
  if (Number.isFinite(label.style.resolution) && label.style.resolution <= 0) {
156
- problems.push(`${describeText(label)} has non-positive resolution: ${label.style.resolution}`);
169
+ problems.push(
170
+ `${describeText(label)} has non-positive resolution: ${label.style.resolution}`
171
+ );
157
172
  }
158
173
  const bounds = label.getBounds();
159
174
  for (const [property, value] of Object.entries({
@@ -187,11 +202,13 @@ function assertSceneTextHealth(scene) {
187
202
  ...collectBitmapTextHealthProblems(scene)
188
203
  ];
189
204
  if (problems.length === 0) return;
190
- throw new Error([
191
- `Phaser text health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
192
- ...problems.map((problem) => `- ${problem}`),
193
- "Fix optional TextStyle values by omitting them or using Phaser defaults (for example fixedWidth: value ?? 0)."
194
- ].join("\n"));
205
+ throw new Error(
206
+ [
207
+ `Phaser text health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
208
+ ...problems.map((problem) => `- ${problem}`),
209
+ "Fix optional TextStyle values by omitting them or using Phaser defaults (for example fixedWidth: value ?? 0)."
210
+ ].join("\n")
211
+ );
195
212
  }
196
213
 
197
214
  // src/phaser-runtime-assertions.ts
@@ -203,8 +220,10 @@ function collectObjects(scene) {
203
220
  if (visited.has(object)) return;
204
221
  visited.add(object);
205
222
  objects.push(object);
206
- if (object instanceof Phaser2.GameObjects.Container) object.list.forEach(visit);
207
- if (object instanceof Phaser2.GameObjects.Layer) object.getChildren().forEach(visit);
223
+ if (object instanceof Phaser2.GameObjects.Container)
224
+ object.list.forEach(visit);
225
+ if (object instanceof Phaser2.GameObjects.Layer)
226
+ object.getChildren().forEach(visit);
208
227
  };
209
228
  scene.children.getChildren().forEach(visit);
210
229
  return objects;
@@ -238,7 +257,9 @@ function requireFiniteVector(problems, subject, target, property) {
238
257
  if (!(axis in vector)) continue;
239
258
  const value = Reflect.get(vector, axis);
240
259
  if (typeof value !== "number" || !Number.isFinite(value)) {
241
- problems.push(`${subject} has non-finite ${property}.${axis}: ${String(value)}`);
260
+ problems.push(
261
+ `${subject} has non-finite ${property}.${axis}: ${String(value)}`
262
+ );
242
263
  }
243
264
  }
244
265
  }
@@ -306,7 +327,9 @@ function getArcadeBodyCollection(value) {
306
327
  function getArcadeWorldBodies(world) {
307
328
  if (!world || typeof world !== "object") return void 0;
308
329
  const bodies = getArcadeBodyCollection(Reflect.get(world, "bodies"));
309
- const staticBodies = getArcadeBodyCollection(Reflect.get(world, "staticBodies"));
330
+ const staticBodies = getArcadeBodyCollection(
331
+ Reflect.get(world, "staticBodies")
332
+ );
310
333
  return bodies && staticBodies ? [...bodies, ...staticBodies] : void 0;
311
334
  }
312
335
  function collectArcadeBodyHealthProblems(scene) {
@@ -317,22 +340,38 @@ function collectArcadeBodyHealthProblems(scene) {
317
340
  if (body.enable === false) continue;
318
341
  const gameObject = body.gameObject;
319
342
  const subject = gameObject ? `Arcade Body for ${describeObject(gameObject)}` : "Arcade Body";
320
- for (const property of ["position", "velocity", "acceleration", "gravity", "offset", "center"]) {
343
+ for (const property of [
344
+ "position",
345
+ "velocity",
346
+ "acceleration",
347
+ "gravity",
348
+ "offset",
349
+ "center"
350
+ ]) {
321
351
  requireFiniteVector(problems, subject, body, property);
322
352
  }
323
- for (const property of ["width", "height", "halfWidth", "halfHeight", "rotation"]) {
353
+ for (const property of [
354
+ "width",
355
+ "height",
356
+ "halfWidth",
357
+ "halfHeight",
358
+ "rotation"
359
+ ]) {
324
360
  requireFiniteProperty(problems, subject, body, property);
325
361
  }
326
362
  }
327
363
  return problems;
328
364
  }
329
365
  function usesCustomHitArea(object) {
330
- return Boolean(object.input?.customHitArea);
366
+ return Boolean(
367
+ object.input?.customHitArea
368
+ );
331
369
  }
332
370
  function collectInteractiveHealthProblems(scene) {
333
371
  const problems = [];
334
372
  for (const object of collectObjects(scene)) {
335
- if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled) continue;
373
+ if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled)
374
+ continue;
336
375
  if (usesCustomHitArea(object)) continue;
337
376
  const hitArea = object.input.hitArea;
338
377
  if (!hitArea) {
@@ -355,20 +394,24 @@ function assertSceneRuntimeHealth(scene) {
355
394
  ...collectArcadeBodyHealthProblems(scene)
356
395
  ];
357
396
  if (problems.length === 0) return;
358
- throw new Error([
359
- `Phaser runtime health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
360
- ...problems.map((problem) => `- ${problem}`),
361
- "Keep values consumed by Phaser finite; zero size and zero scale remain allowed."
362
- ].join("\n"));
397
+ throw new Error(
398
+ [
399
+ `Phaser runtime health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
400
+ ...problems.map((problem) => `- ${problem}`),
401
+ "Keep values consumed by Phaser finite; zero size and zero scale remain allowed."
402
+ ].join("\n")
403
+ );
363
404
  }
364
405
  function assertSceneInteractiveHealth(scene) {
365
406
  const problems = collectInteractiveHealthProblems(scene);
366
407
  if (problems.length === 0) return;
367
- throw new Error([
368
- `Phaser interactive health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
369
- ...problems.map((problem) => `- ${problem}`),
370
- "Fix the default hit area or disable input before making the object interactive."
371
- ].join("\n"));
408
+ throw new Error(
409
+ [
410
+ `Phaser interactive health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
411
+ ...problems.map((problem) => `- ${problem}`),
412
+ "Fix the default hit area or disable input before making the object interactive."
413
+ ].join("\n")
414
+ );
372
415
  }
373
416
 
374
417
  // src/phaser-headless-host.ts
@@ -383,11 +426,32 @@ async function createHeadlessGame(scene, options = {}) {
383
426
  let removeRuntimeListeners = () => {
384
427
  };
385
428
  const transitions = [];
429
+ const registeredScenes = [];
430
+ const visitedScenes = [];
431
+ const restartedScenes = [];
432
+ const checkpoints = [];
433
+ const appendUnique = (values, value) => {
434
+ if (value && !values.includes(value)) values.push(value);
435
+ };
436
+ const assertStepCount = (value, name) => {
437
+ if (!Number.isSafeInteger(value) || value < 0) {
438
+ throw new Error(
439
+ `${name} must be a non-negative safe integer; received ${value}`
440
+ );
441
+ }
442
+ };
386
443
  const evidence = {
387
444
  frames: 0,
388
445
  physicsSteps: 0,
389
446
  mouseEvents: 0,
447
+ keyboardEvents: 0,
448
+ touchEvents: 0,
390
449
  clicks: 0,
450
+ registeredScenes,
451
+ visitedScenes,
452
+ restartedScenes,
453
+ checkpoints,
454
+ destroyed: false,
391
455
  transitions
392
456
  };
393
457
  const created = new Promise((resolve, reject) => {
@@ -433,7 +497,9 @@ async function createHeadlessGame(scene, options = {}) {
433
497
  });
434
498
  };
435
499
  const timeout = window.setTimeout(() => {
436
- finish(new Error(`Phaser HEADLESS boot timed out after ${bootTimeoutMs}ms`));
500
+ finish(
501
+ new Error(`Phaser HEADLESS boot timed out after ${bootTimeoutMs}ms`)
502
+ );
437
503
  }, bootTimeoutMs);
438
504
  window.addEventListener("error", onWindowError, true);
439
505
  window.addEventListener("unhandledrejection", onUnhandledRejection);
@@ -445,7 +511,13 @@ async function createHeadlessGame(scene, options = {}) {
445
511
  if (guardedScenes.has(currentScene)) return;
446
512
  guardedScenes.add(currentScene);
447
513
  const scenePlugin = currentScene.scene;
448
- for (const method of ["start", "launch", "switch", "sleep", "wake"]) {
514
+ for (const method of [
515
+ "start",
516
+ "launch",
517
+ "switch",
518
+ "sleep",
519
+ "wake"
520
+ ]) {
449
521
  const original = scenePlugin[method];
450
522
  if (typeof original !== "function") continue;
451
523
  scenePlugin[method] = (key, ...args) => {
@@ -461,29 +533,49 @@ async function createHeadlessGame(scene, options = {}) {
461
533
  scenePlugin[method] = original;
462
534
  });
463
535
  }
536
+ const originalRestart = scenePlugin.restart;
537
+ if (typeof originalRestart === "function") {
538
+ scenePlugin.restart = (...args) => {
539
+ appendUnique(restartedScenes, currentScene.sys.settings.key);
540
+ return Reflect.apply(originalRestart, currentScene.scene, args);
541
+ };
542
+ restoreGuards.push(() => {
543
+ scenePlugin.restart = originalRestart;
544
+ });
545
+ }
464
546
  for (const hook of ["init", "preload", "create", "update"]) {
465
547
  const original = Reflect.get(currentScene, hook);
466
548
  if (typeof original !== "function") continue;
467
- const ownDescriptor = Object.getOwnPropertyDescriptor(currentScene, hook);
468
- Reflect.set(currentScene, hook, function guardedLifecycleHook(...args) {
469
- try {
470
- const result = Reflect.apply(original, this, args);
471
- if (result && typeof result === "object" && "then" in result) {
472
- const completion = Promise.resolve(result);
473
- if (currentScene === scene && hook === "create" && !settled) {
474
- completion.then(() => window.setTimeout(() => finish(), 0), reportError);
475
- } else {
476
- completion.catch(reportError);
549
+ const ownDescriptor = Object.getOwnPropertyDescriptor(
550
+ currentScene,
551
+ hook
552
+ );
553
+ Reflect.set(
554
+ currentScene,
555
+ hook,
556
+ function guardedLifecycleHook(...args) {
557
+ try {
558
+ const result = Reflect.apply(original, this, args);
559
+ if (result && typeof result === "object" && "then" in result) {
560
+ const completion = Promise.resolve(result);
561
+ if (currentScene === scene && hook === "create" && !settled) {
562
+ completion.then(
563
+ () => window.setTimeout(() => finish(), 0),
564
+ reportError
565
+ );
566
+ } else {
567
+ completion.catch(reportError);
568
+ }
569
+ } else if (currentScene === scene && hook === "create" && !settled) {
570
+ window.setTimeout(() => finish(), 0);
477
571
  }
478
- } else if (currentScene === scene && hook === "create" && !settled) {
479
- window.setTimeout(() => finish(), 0);
572
+ return result;
573
+ } catch (error) {
574
+ reportError(error);
575
+ return void 0;
480
576
  }
481
- return result;
482
- } catch (error) {
483
- reportError(error);
484
- return void 0;
485
577
  }
486
- });
578
+ );
487
579
  restoreGuards.push(() => {
488
580
  if (ownDescriptor) {
489
581
  Object.defineProperty(currentScene, hook, ownDescriptor);
@@ -512,13 +604,6 @@ async function createHeadlessGame(scene, options = {}) {
512
604
  banner: false,
513
605
  autoFocus: false,
514
606
  seed: ["phaser-headless-test"],
515
- physics: {
516
- default: "arcade",
517
- arcade: {
518
- gravity: { x: 0, y: 0 },
519
- fixedStep: true
520
- }
521
- },
522
607
  ...config,
523
608
  type: Phaser3.HEADLESS,
524
609
  fps,
@@ -544,7 +629,10 @@ async function createHeadlessGame(scene, options = {}) {
544
629
  };
545
630
  bootedGame.events.on(Phaser3.Core.Events.POST_STEP, checkReadiness);
546
631
  removeReadinessCheck = () => {
547
- bootedGame.events.off(Phaser3.Core.Events.POST_STEP, checkReadiness);
632
+ bootedGame.events.off(
633
+ Phaser3.Core.Events.POST_STEP,
634
+ checkReadiness
635
+ );
548
636
  };
549
637
  }
550
638
  },
@@ -584,7 +672,9 @@ async function createHeadlessGame(scene, options = {}) {
584
672
  restoreHostGuards();
585
673
  readyGame.destroy(true);
586
674
  readyGame.headlessStep(readyGame.loop.lastTime, 0);
587
- throw new Error("Phaser HEADLESS input canvas is not attached to a DOM Window");
675
+ throw new Error(
676
+ "Phaser HEADLESS input canvas is not attached to a DOM Window"
677
+ );
588
678
  }
589
679
  let canvasBounds = {
590
680
  left: 0,
@@ -620,7 +710,9 @@ async function createHeadlessGame(scene, options = {}) {
620
710
  setCanvasBounds(canvasBounds);
621
711
  const gameToClient = (x, y) => {
622
712
  if (!Number.isFinite(x) || !Number.isFinite(y)) {
623
- throw new Error(`HEADLESS input coordinates must be finite; received (${x}, ${y})`);
713
+ throw new Error(
714
+ `HEADLESS input coordinates must be finite; received (${x}, ${y})`
715
+ );
624
716
  }
625
717
  return {
626
718
  clientX: canvasBounds.left + x / readyGame.scale.displayScale.x,
@@ -629,7 +721,10 @@ async function createHeadlessGame(scene, options = {}) {
629
721
  };
630
722
  const assertHealth = () => {
631
723
  for (const currentScene of readyGame.scene.getScenes(false)) {
632
- if (!currentScene.sys.isActive() && !currentScene.sys.isPaused()) continue;
724
+ appendUnique(registeredScenes, currentScene.sys.settings.key);
725
+ if (!currentScene.sys.isActive() && !currentScene.sys.isPaused())
726
+ continue;
727
+ appendUnique(visitedScenes, currentScene.sys.settings.key);
633
728
  assertSceneTextHealth(currentScene);
634
729
  assertSceneInteractiveHealth(currentScene);
635
730
  assertSceneRuntimeHealth(currentScene);
@@ -640,17 +735,166 @@ async function createHeadlessGame(scene, options = {}) {
640
735
  throwRuntimeError();
641
736
  assertHealth();
642
737
  };
738
+ const getEventTarget = (target, kind) => {
739
+ if (!target || typeof Reflect.get(target, "dispatchEvent") !== "function") {
740
+ throw new Error(
741
+ `Phaser HEADLESS ${kind} input target cannot dispatch DOM events`
742
+ );
743
+ }
744
+ return target;
745
+ };
643
746
  const dispatchMouse = async (type, x, y, buttons) => {
644
747
  const { clientX, clientY } = gameToClient(x, y);
645
- canvas.dispatchEvent(new inputWindow.MouseEvent(type, {
748
+ const mouseTarget = getEventTarget(readyGame.input.mouse?.target, "mouse");
749
+ mouseTarget.dispatchEvent(
750
+ new inputWindow.MouseEvent(type, {
751
+ bubbles: true,
752
+ cancelable: true,
753
+ button: 0,
754
+ buttons,
755
+ clientX,
756
+ clientY
757
+ })
758
+ );
759
+ evidence.mouseEvents += 1;
760
+ await settleRuntime();
761
+ };
762
+ const keyIdentity = (keyCode) => {
763
+ if (keyCode >= 65 && keyCode <= 90) {
764
+ const letter = String.fromCharCode(keyCode);
765
+ return { key: letter.toLowerCase(), code: `Key${letter}` };
766
+ }
767
+ if (keyCode >= 48 && keyCode <= 57) {
768
+ const digit = String.fromCharCode(keyCode);
769
+ return { key: digit, code: `Digit${digit}` };
770
+ }
771
+ const identities = /* @__PURE__ */ new Map([
772
+ [
773
+ Phaser3.Input.Keyboard.KeyCodes.LEFT,
774
+ { key: "ArrowLeft", code: "ArrowLeft" }
775
+ ],
776
+ [
777
+ Phaser3.Input.Keyboard.KeyCodes.RIGHT,
778
+ { key: "ArrowRight", code: "ArrowRight" }
779
+ ],
780
+ [Phaser3.Input.Keyboard.KeyCodes.UP, { key: "ArrowUp", code: "ArrowUp" }],
781
+ [
782
+ Phaser3.Input.Keyboard.KeyCodes.DOWN,
783
+ { key: "ArrowDown", code: "ArrowDown" }
784
+ ],
785
+ [Phaser3.Input.Keyboard.KeyCodes.SPACE, { key: " ", code: "Space" }],
786
+ [Phaser3.Input.Keyboard.KeyCodes.ENTER, { key: "Enter", code: "Enter" }],
787
+ [Phaser3.Input.Keyboard.KeyCodes.ESC, { key: "Escape", code: "Escape" }]
788
+ ]);
789
+ return identities.get(keyCode) ?? { key: "", code: "" };
790
+ };
791
+ const dispatchKeyboard = async (type, keyCode, options2 = {}) => {
792
+ if (!Number.isInteger(keyCode) || keyCode < 0) {
793
+ throw new Error(
794
+ `HEADLESS keyboard keyCode must be a non-negative integer; received ${keyCode}`
795
+ );
796
+ }
797
+ const identity = keyIdentity(keyCode);
798
+ const event = new inputWindow.KeyboardEvent(type, {
646
799
  bubbles: true,
647
800
  cancelable: true,
648
- button: 0,
649
- buttons,
801
+ key: options2.key ?? identity.key,
802
+ code: options2.code ?? identity.code,
803
+ repeat: options2.repeat ?? false,
804
+ altKey: options2.altKey ?? false,
805
+ ctrlKey: options2.ctrlKey ?? false,
806
+ metaKey: options2.metaKey ?? false,
807
+ shiftKey: options2.shiftKey ?? false
808
+ });
809
+ Object.defineProperties(event, {
810
+ // Phaser KeyboardManager 仍使用这些兼容旧浏览器的数字字段。
811
+ keyCode: { value: keyCode },
812
+ which: { value: keyCode }
813
+ });
814
+ const keyboardTarget = getEventTarget(
815
+ readyGame.input.keyboard?.target,
816
+ "keyboard"
817
+ );
818
+ keyboardTarget.dispatchEvent(event);
819
+ evidence.keyboardEvents += 1;
820
+ await settleRuntime();
821
+ };
822
+ const activeTouches = /* @__PURE__ */ new Map();
823
+ const createTouch = (x, y, identifier, target) => {
824
+ if (!Number.isInteger(identifier) || identifier < 0) {
825
+ throw new Error(
826
+ `HEADLESS touch identifier must be a non-negative integer; received ${identifier}`
827
+ );
828
+ }
829
+ const { clientX, clientY } = gameToClient(x, y);
830
+ return {
831
+ identifier,
832
+ target,
650
833
  clientX,
651
- clientY
652
- }));
653
- evidence.mouseEvents += 1;
834
+ clientY,
835
+ pageX: clientX,
836
+ pageY: clientY,
837
+ screenX: clientX,
838
+ screenY: clientY,
839
+ radiusX: 1,
840
+ radiusY: 1,
841
+ rotationAngle: 0,
842
+ force: 1
843
+ };
844
+ };
845
+ const dispatchTouch = async (type, x, y, identifier) => {
846
+ if (type === "touchstart" && activeTouches.has(identifier)) {
847
+ throw new Error(
848
+ `HEADLESS touch identifier ${identifier} is already active`
849
+ );
850
+ }
851
+ if (type !== "touchstart" && !activeTouches.has(identifier)) {
852
+ throw new Error(`HEADLESS touch identifier ${identifier} is not active`);
853
+ }
854
+ const touchTarget = getEventTarget(readyGame.input.touch?.target, "touch");
855
+ const changedTouch = createTouch(x, y, identifier, touchTarget);
856
+ if (type === "touchstart" || type === "touchmove") {
857
+ activeTouches.set(identifier, changedTouch);
858
+ } else {
859
+ activeTouches.delete(identifier);
860
+ }
861
+ const touches = [...activeTouches.values()];
862
+ const event = new inputWindow.Event(type, {
863
+ bubbles: true,
864
+ cancelable: true
865
+ });
866
+ Object.defineProperties(event, {
867
+ changedTouches: { value: [changedTouch] },
868
+ targetTouches: { value: touches },
869
+ touches: { value: touches }
870
+ });
871
+ const document = inputWindow.document;
872
+ const originalElementFromPoint = document.elementFromPoint;
873
+ const originalElementFromPointDescriptor = Object.getOwnPropertyDescriptor(
874
+ document,
875
+ "elementFromPoint"
876
+ );
877
+ Object.defineProperty(document, "elementFromPoint", {
878
+ configurable: true,
879
+ value(clientX, clientY) {
880
+ const insideCanvas = clientX >= canvasBounds.left && clientX <= canvasBounds.left + canvasBounds.width && clientY >= canvasBounds.top && clientY <= canvasBounds.top + canvasBounds.height;
881
+ return insideCanvas ? canvas : originalElementFromPoint?.call(document, clientX, clientY) ?? null;
882
+ }
883
+ });
884
+ try {
885
+ touchTarget.dispatchEvent(event);
886
+ evidence.touchEvents += 1;
887
+ } finally {
888
+ if (originalElementFromPointDescriptor) {
889
+ Object.defineProperty(
890
+ document,
891
+ "elementFromPoint",
892
+ originalElementFromPointDescriptor
893
+ );
894
+ } else {
895
+ Reflect.deleteProperty(document, "elementFromPoint");
896
+ }
897
+ }
654
898
  await settleRuntime();
655
899
  };
656
900
  const input = {
@@ -668,6 +912,24 @@ async function createHeadlessGame(scene, options = {}) {
668
912
  await dispatchMouse("mousedown", x, y, 1);
669
913
  await dispatchMouse("mouseup", x, y, 0);
670
914
  }
915
+ },
916
+ keyboard: {
917
+ down: (keyCode, options2) => dispatchKeyboard("keydown", keyCode, options2),
918
+ up: (keyCode, options2) => dispatchKeyboard("keyup", keyCode, options2),
919
+ async press(keyCode, options2) {
920
+ await dispatchKeyboard("keydown", keyCode, options2);
921
+ await dispatchKeyboard("keyup", keyCode, options2);
922
+ }
923
+ },
924
+ touch: {
925
+ start: (x, y, identifier = 1) => dispatchTouch("touchstart", x, y, identifier),
926
+ move: (x, y, identifier = 1) => dispatchTouch("touchmove", x, y, identifier),
927
+ end: (x, y, identifier = 1) => dispatchTouch("touchend", x, y, identifier),
928
+ cancel: (x, y, identifier = 1) => dispatchTouch("touchcancel", x, y, identifier),
929
+ async tap(x, y, identifier = 1) {
930
+ await dispatchTouch("touchstart", x, y, identifier);
931
+ await dispatchTouch("touchend", x, y, identifier);
932
+ }
671
933
  }
672
934
  };
673
935
  const frameDurationMs = 1e3 / (config.fps?.target ?? 60);
@@ -676,6 +938,7 @@ async function createHeadlessGame(scene, options = {}) {
676
938
  evidence.frames += 1;
677
939
  };
678
940
  const stepFrames = (count = 1) => {
941
+ assertStepCount(count, "stepFrames count");
679
942
  throwRuntimeError();
680
943
  for (let frame = 0; frame < count; frame += 1) stepFrame();
681
944
  throwRuntimeError();
@@ -699,21 +962,59 @@ async function createHeadlessGame(scene, options = {}) {
699
962
  input,
700
963
  evidence,
701
964
  assertGameplayEvidence(requirements = {}) {
702
- if (requirements.requireInput && evidence.mouseEvents === 0) {
703
- throw new Error("Gameplay evidence is missing real HEADLESS input events.");
965
+ const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
966
+ if (requirements.requireInput && inputEvents === 0) {
967
+ throw new Error(
968
+ "Gameplay evidence is missing real HEADLESS input events."
969
+ );
704
970
  }
705
971
  if (requirements.requireFrameAdvance && evidence.frames === 0) {
706
- throw new Error("Gameplay evidence is missing complete Phaser frame advancement.");
972
+ throw new Error(
973
+ "Gameplay evidence is missing complete Phaser frame advancement."
974
+ );
707
975
  }
708
976
  if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
709
- throw new Error("Gameplay evidence is missing Arcade Physics advancement.");
977
+ throw new Error(
978
+ "Gameplay evidence is missing Arcade Physics advancement."
979
+ );
710
980
  }
711
981
  const requiredTransitions = requirements.requireTransition === void 0 ? [] : typeof requirements.requireTransition === "string" ? [requirements.requireTransition] : requirements.requireTransition;
712
982
  for (const target of requiredTransitions) {
713
983
  if (!evidence.transitions.some((transition) => transition.to === target)) {
714
- throw new Error(`Gameplay evidence is missing a transition to ${target}.`);
984
+ throw new Error(
985
+ `Gameplay evidence is missing a transition to ${target}.`
986
+ );
987
+ }
988
+ }
989
+ const requiredScenes = requirements.requireScene === void 0 ? [] : typeof requirements.requireScene === "string" ? [requirements.requireScene] : requirements.requireScene;
990
+ for (const sceneKey of requiredScenes) {
991
+ if (!evidence.visitedScenes.includes(sceneKey)) {
992
+ throw new Error(
993
+ `Gameplay evidence is missing a visit to Scene ${sceneKey}.`
994
+ );
995
+ }
996
+ }
997
+ const requiredRestarts = requirements.requireRestart === void 0 ? [] : typeof requirements.requireRestart === "string" ? [requirements.requireRestart] : requirements.requireRestart;
998
+ for (const sceneKey of requiredRestarts) {
999
+ if (!evidence.restartedScenes.includes(sceneKey)) {
1000
+ throw new Error(
1001
+ `Gameplay evidence is missing a restart of Scene ${sceneKey}.`
1002
+ );
715
1003
  }
716
1004
  }
1005
+ const requiredCheckpoints = requirements.requireCheckpoint === void 0 ? [] : typeof requirements.requireCheckpoint === "string" ? [requirements.requireCheckpoint] : requirements.requireCheckpoint;
1006
+ for (const checkpoint of requiredCheckpoints) {
1007
+ if (!evidence.checkpoints.includes(checkpoint)) {
1008
+ throw new Error(
1009
+ `Gameplay evidence is missing checkpoint ${checkpoint}.`
1010
+ );
1011
+ }
1012
+ }
1013
+ if (requirements.requireDestroy && !evidence.destroyed) {
1014
+ throw new Error(
1015
+ "Gameplay evidence is missing HEADLESS host destruction."
1016
+ );
1017
+ }
717
1018
  },
718
1019
  stepFrames,
719
1020
  async stepFramesAsync(count = 1) {
@@ -721,6 +1022,7 @@ async function createHeadlessGame(scene, options = {}) {
721
1022
  await settleRuntime();
722
1023
  },
723
1024
  stepUntil(condition, maxFrames = 120) {
1025
+ assertStepCount(maxFrames, "stepUntil maxFrames");
724
1026
  throwRuntimeError();
725
1027
  for (let frame = 0; frame <= maxFrames; frame += 1) {
726
1028
  if (condition()) {
@@ -733,13 +1035,18 @@ async function createHeadlessGame(scene, options = {}) {
733
1035
  }
734
1036
  }
735
1037
  assertHealth();
736
- throw new Error(`Condition was not met within ${maxFrames} complete Phaser frames`);
1038
+ throw new Error(
1039
+ `Condition was not met within ${maxFrames} complete Phaser frames`
1040
+ );
737
1041
  },
738
1042
  stepPhysics(steps = 1) {
1043
+ assertStepCount(steps, "stepPhysics steps");
739
1044
  throwRuntimeError();
740
1045
  const world = scene.physics?.world;
741
1046
  if (!world || !getArcadeWorldBodies(world) || typeof world.singleStep !== "function") {
742
- throw new Error("stepPhysics() requires an Arcade Physics world; use stepFrames() for other configurations.");
1047
+ throw new Error(
1048
+ "stepPhysics() requires an Arcade Physics world; use stepFrames() for other configurations."
1049
+ );
743
1050
  }
744
1051
  for (let step = 0; step < steps; step += 1) {
745
1052
  scene.physics.world.singleStep();
@@ -748,6 +1055,15 @@ async function createHeadlessGame(scene, options = {}) {
748
1055
  throwRuntimeError();
749
1056
  assertHealth();
750
1057
  },
1058
+ checkpoint(id) {
1059
+ const normalizedId = id.trim();
1060
+ if (!normalizedId || normalizedId.length > 120) {
1061
+ throw new Error(
1062
+ "Gameplay checkpoint id must contain 1 to 120 non-whitespace characters."
1063
+ );
1064
+ }
1065
+ appendUnique(checkpoints, normalizedId);
1066
+ },
751
1067
  assertTextHealth() {
752
1068
  assertSceneTextHealth(scene);
753
1069
  },
@@ -760,6 +1076,7 @@ async function createHeadlessGame(scene, options = {}) {
760
1076
  readyGame.destroy(true);
761
1077
  readyGame.headlessStep(readyGame.loop.lastTime, 0);
762
1078
  throwRuntimeError();
1079
+ evidence.destroyed = true;
763
1080
  } finally {
764
1081
  removeRuntimeListeners();
765
1082
  restoreHostGuards();
@@ -769,18 +1086,79 @@ async function createHeadlessGame(scene, options = {}) {
769
1086
  }
770
1087
 
771
1088
  // src/gameplay-contracts.ts
772
- function registerGameplayContract(context, options) {
773
- const task = context;
774
- const metadata = {
775
- id: options.id,
776
- requirements: {
777
- requireInput: options.requireInput,
778
- requireFrameAdvance: options.requireFrameAdvance,
779
- requirePhysicsStep: options.requirePhysicsStep,
780
- requireTransition: options.requireTransition
781
- }
1089
+ var import_vitest = require("vitest");
1090
+
1091
+ // src/gameplay-audit.ts
1092
+ function normalizeList(value) {
1093
+ if (value === void 0) return [];
1094
+ const values = typeof value === "string" ? [value] : value;
1095
+ return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
1096
+ }
1097
+ function normalizeSet(values) {
1098
+ return [
1099
+ ...new Set(values.map((value) => value.trim()).filter(Boolean))
1100
+ ].sort();
1101
+ }
1102
+ function normalizeGameplayRequirements(requirements, scenes) {
1103
+ const requiredScenes = normalizeSet([
1104
+ ...scenes,
1105
+ ...normalizeList(requirements.requireScene)
1106
+ ]);
1107
+ return {
1108
+ requireInput: requirements.requireInput || void 0,
1109
+ requireFrameAdvance: requirements.requireFrameAdvance || void 0,
1110
+ requirePhysicsStep: requirements.requirePhysicsStep || void 0,
1111
+ requireTransition: normalizeList(requirements.requireTransition),
1112
+ requireScene: requiredScenes,
1113
+ requireRestart: normalizeList(requirements.requireRestart),
1114
+ requireCheckpoint: normalizeList(requirements.requireCheckpoint),
1115
+ requireDestroy: true
1116
+ };
1117
+ }
1118
+ function createGameplayContractMetadata(contract) {
1119
+ const scenes = normalizeSet(contract.scenes);
1120
+ return {
1121
+ id: contract.id.trim(),
1122
+ scenes,
1123
+ requirements: normalizeGameplayRequirements(contract, scenes),
1124
+ verified: false
1125
+ };
1126
+ }
1127
+
1128
+ // src/gameplay-contracts.ts
1129
+ function snapshotEvidence(evidence) {
1130
+ return {
1131
+ frames: evidence.frames,
1132
+ physicsSteps: evidence.physicsSteps,
1133
+ mouseEvents: evidence.mouseEvents,
1134
+ keyboardEvents: evidence.keyboardEvents,
1135
+ touchEvents: evidence.touchEvents,
1136
+ clicks: evidence.clicks,
1137
+ registeredScenes: [...evidence.registeredScenes],
1138
+ visitedScenes: [...evidence.visitedScenes],
1139
+ restartedScenes: [...evidence.restartedScenes],
1140
+ checkpoints: [...evidence.checkpoints],
1141
+ destroyed: evidence.destroyed,
1142
+ transitions: evidence.transitions.map((transition) => ({ ...transition }))
782
1143
  };
783
- task.task.meta.gameplayContract = metadata;
1144
+ }
1145
+ function readStaticMetadata(context, options) {
1146
+ const metadata = context.task.meta.gameplayContract;
1147
+ if (!metadata || typeof metadata !== "object") return void 0;
1148
+ const candidate = metadata;
1149
+ if (candidate.id !== options.id) {
1150
+ throw new Error(
1151
+ `\u9759\u6001 gameplay contract "${candidate.id}" \u4E0E\u8FD0\u884C\u65F6 contract "${options.id}" \u4E0D\u4E00\u81F4\u3002`
1152
+ );
1153
+ }
1154
+ return candidate;
1155
+ }
1156
+ function registerGameplayContract(context, options) {
1157
+ const metadata = readStaticMetadata(context, options) ?? createGameplayContractMetadata({
1158
+ ...options,
1159
+ scenes: options.scenes ?? []
1160
+ });
1161
+ context.task.meta.gameplayContract = metadata;
784
1162
  let host;
785
1163
  let verified = false;
786
1164
  let testFailed = false;
@@ -790,16 +1168,13 @@ function registerGameplayContract(context, options) {
790
1168
  const verify = () => {
791
1169
  if (verified) return;
792
1170
  if (!host) {
793
- throw new Error(`Gameplay contract "${options.id}" did not attach a HEADLESS host.`);
1171
+ throw new Error(
1172
+ `Gameplay contract "${options.id}" \u6CA1\u6709\u7ED1\u5B9A HEADLESS host\u3002`
1173
+ );
794
1174
  }
795
1175
  host.assertGameplayEvidence(metadata.requirements);
796
- metadata.evidence = {
797
- frames: host.evidence.frames,
798
- physicsSteps: host.evidence.physicsSteps,
799
- mouseEvents: host.evidence.mouseEvents,
800
- clicks: host.evidence.clicks,
801
- transitions: host.evidence.transitions.map((transition) => ({ ...transition }))
802
- };
1176
+ metadata.evidence = snapshotEvidence(host.evidence);
1177
+ metadata.verified = true;
803
1178
  verified = true;
804
1179
  };
805
1180
  context.onTestFinished(() => {
@@ -809,13 +1184,25 @@ function registerGameplayContract(context, options) {
809
1184
  return {
810
1185
  attachHost(currentHost) {
811
1186
  if (host) {
812
- throw new Error(`Gameplay contract "${options.id}" attached more than one host.`);
1187
+ throw new Error(
1188
+ `Gameplay contract "${options.id}" \u91CD\u590D\u7ED1\u5B9A\u4E86 HEADLESS host\u3002`
1189
+ );
813
1190
  }
814
1191
  host = currentHost;
815
1192
  },
816
1193
  verify
817
1194
  };
818
1195
  }
1196
+ function gameplayTest(name, options, run) {
1197
+ const metadata = createGameplayContractMetadata(options);
1198
+ (0, import_vitest.test)(name, {
1199
+ ...options.testOptions,
1200
+ meta: { gameplayContract: metadata }
1201
+ }, async (context) => {
1202
+ const contract = registerGameplayContract(context, options);
1203
+ await run({ context, contract });
1204
+ });
1205
+ }
819
1206
  // Annotate the CommonJS export names for ESM import in node:
820
1207
  0 && (module.exports = {
821
1208
  assertSceneInteractiveHealth,
@@ -823,5 +1210,6 @@ function registerGameplayContract(context, options) {
823
1210
  assertSceneTextHealth,
824
1211
  collectMissingBitmapGlyphs,
825
1212
  createHeadlessGame,
1213
+ gameplayTest,
826
1214
  registerGameplayContract
827
1215
  });