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.
@@ -0,0 +1,1575 @@
1
+ // src/lint/phaser-headless.test.ts
2
+ import * as Phaser4 from "phaser";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ // src/phaser-headless-host.ts
6
+ import * as Phaser3 from "phaser";
7
+
8
+ // src/phaser-text-assertions.ts
9
+ import * as Phaser from "phaser";
10
+ function describeText(label) {
11
+ const name = label.name ? ` name=${JSON.stringify(label.name)}` : "";
12
+ const value = label.text.length > 80 ? `${label.text.slice(0, 77)}...` : label.text;
13
+ return `Text${name} value=${JSON.stringify(value)}`;
14
+ }
15
+ function isEffectivelyRenderable(label) {
16
+ let current = label;
17
+ while (current) {
18
+ const state = current;
19
+ if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
20
+ return false;
21
+ }
22
+ current = state.parentContainer ?? null;
23
+ }
24
+ return true;
25
+ }
26
+ function requireFinite(problems, label, property, value) {
27
+ if (!Number.isFinite(value)) {
28
+ problems.push(
29
+ `${describeText(label)} has non-finite ${property}: ${String(value)}`
30
+ );
31
+ }
32
+ }
33
+ function collectSceneTexts(scene) {
34
+ const labels = [];
35
+ const visited = /* @__PURE__ */ new Set();
36
+ const visit = (child) => {
37
+ if (visited.has(child)) return;
38
+ visited.add(child);
39
+ if (child instanceof Phaser.GameObjects.Text) {
40
+ labels.push(child);
41
+ return;
42
+ }
43
+ if (child instanceof Phaser.GameObjects.Container) {
44
+ child.list.forEach(visit);
45
+ return;
46
+ }
47
+ if (child instanceof Phaser.GameObjects.Layer) {
48
+ child.getChildren().forEach(visit);
49
+ }
50
+ };
51
+ scene.children.getChildren().forEach(visit);
52
+ return labels;
53
+ }
54
+ function collectSceneBitmapTexts(scene) {
55
+ const labels = [];
56
+ const visited = /* @__PURE__ */ new Set();
57
+ const visit = (child) => {
58
+ if (visited.has(child)) return;
59
+ visited.add(child);
60
+ if (child instanceof Phaser.GameObjects.BitmapText) {
61
+ labels.push(child);
62
+ return;
63
+ }
64
+ if (child instanceof Phaser.GameObjects.Container) {
65
+ child.list.forEach(visit);
66
+ return;
67
+ }
68
+ if (child instanceof Phaser.GameObjects.Layer) {
69
+ child.getChildren().forEach(visit);
70
+ }
71
+ };
72
+ scene.children.getChildren().forEach(visit);
73
+ return labels;
74
+ }
75
+ function collectMissingBitmapGlyphs(text, chars) {
76
+ const missing = [];
77
+ const seen = /* @__PURE__ */ new Set();
78
+ for (let index = 0; index < text.length; index += 1) {
79
+ const character = text[index];
80
+ if (/\s/u.test(character)) continue;
81
+ const code = text.charCodeAt(index);
82
+ if (seen.has(code) || chars[code] !== void 0) continue;
83
+ seen.add(code);
84
+ missing.push(
85
+ `${JSON.stringify(character)} (U+${code.toString(16).toUpperCase().padStart(4, "0")})`
86
+ );
87
+ }
88
+ return missing;
89
+ }
90
+ function collectTextHealthProblems(scene) {
91
+ const problems = [];
92
+ const labels = collectSceneTexts(scene);
93
+ for (const label of labels) {
94
+ if (label.text.length === 0) continue;
95
+ requireFinite(problems, label, "x", label.x);
96
+ requireFinite(problems, label, "y", label.y);
97
+ requireFinite(problems, label, "width", label.width);
98
+ requireFinite(problems, label, "height", label.height);
99
+ requireFinite(problems, label, "displayWidth", label.displayWidth);
100
+ requireFinite(problems, label, "displayHeight", label.displayHeight);
101
+ requireFinite(problems, label, "scaleX", label.scaleX);
102
+ requireFinite(problems, label, "scaleY", label.scaleY);
103
+ requireFinite(problems, label, "originX", label.originX);
104
+ requireFinite(problems, label, "originY", label.originY);
105
+ requireFinite(problems, label, "rotation", label.rotation);
106
+ requireFinite(problems, label, "fixedWidth", label.style.fixedWidth);
107
+ requireFinite(problems, label, "fixedHeight", label.style.fixedHeight);
108
+ requireFinite(problems, label, "resolution", label.style.resolution);
109
+ const requiresPositiveSize = /\S/u.test(label.text) && isEffectivelyRenderable(label);
110
+ if (requiresPositiveSize && Number.isFinite(label.width) && label.width <= 0) {
111
+ problems.push(
112
+ `${describeText(label)} has non-positive width: ${label.width}`
113
+ );
114
+ }
115
+ if (requiresPositiveSize && Number.isFinite(label.height) && label.height <= 0) {
116
+ problems.push(
117
+ `${describeText(label)} has non-positive height: ${label.height}`
118
+ );
119
+ }
120
+ if (Number.isFinite(label.style.fixedWidth) && label.style.fixedWidth < 0) {
121
+ problems.push(
122
+ `${describeText(label)} has negative fixedWidth: ${label.style.fixedWidth}`
123
+ );
124
+ }
125
+ if (Number.isFinite(label.style.fixedHeight) && label.style.fixedHeight < 0) {
126
+ problems.push(
127
+ `${describeText(label)} has negative fixedHeight: ${label.style.fixedHeight}`
128
+ );
129
+ }
130
+ if (Number.isFinite(label.style.resolution) && label.style.resolution <= 0) {
131
+ problems.push(
132
+ `${describeText(label)} has non-positive resolution: ${label.style.resolution}`
133
+ );
134
+ }
135
+ const bounds = label.getBounds();
136
+ for (const [property, value] of Object.entries({
137
+ boundsX: bounds.x,
138
+ boundsY: bounds.y,
139
+ boundsWidth: bounds.width,
140
+ boundsHeight: bounds.height
141
+ })) {
142
+ requireFinite(problems, label, property, value);
143
+ }
144
+ }
145
+ return problems;
146
+ }
147
+ function collectBitmapTextHealthProblems(scene) {
148
+ const problems = [];
149
+ for (const label of collectSceneBitmapTexts(scene)) {
150
+ const text = Array.isArray(label.text) ? label.text.join("\n") : label.text;
151
+ if (!/\S/u.test(text)) continue;
152
+ const missing = collectMissingBitmapGlyphs(text, label.fontData.chars);
153
+ if (missing.length > 0) {
154
+ problems.push(
155
+ `BitmapText${label.name ? ` name=${JSON.stringify(label.name)}` : ""} value=${JSON.stringify(text)} is missing glyphs: ${missing.join(", ")}`
156
+ );
157
+ }
158
+ }
159
+ return problems;
160
+ }
161
+ function assertSceneTextHealth(scene) {
162
+ const problems = [
163
+ ...collectTextHealthProblems(scene),
164
+ ...collectBitmapTextHealthProblems(scene)
165
+ ];
166
+ if (problems.length === 0) return;
167
+ throw new Error(
168
+ [
169
+ `Phaser text health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
170
+ ...problems.map((problem) => `- ${problem}`),
171
+ "Fix optional TextStyle values by omitting them or using Phaser defaults (for example fixedWidth: value ?? 0)."
172
+ ].join("\n")
173
+ );
174
+ }
175
+
176
+ // src/phaser-runtime-assertions.ts
177
+ import * as Phaser2 from "phaser";
178
+ function collectObjects(scene) {
179
+ const objects = [];
180
+ const visited = /* @__PURE__ */ new Set();
181
+ const visit = (object) => {
182
+ if (visited.has(object)) return;
183
+ visited.add(object);
184
+ objects.push(object);
185
+ if (object instanceof Phaser2.GameObjects.Container)
186
+ object.list.forEach(visit);
187
+ if (object instanceof Phaser2.GameObjects.Layer)
188
+ object.getChildren().forEach(visit);
189
+ };
190
+ scene.children.getChildren().forEach(visit);
191
+ return objects;
192
+ }
193
+ function describeObject(object) {
194
+ return `${object.type}${object.name ? ` name=${JSON.stringify(object.name)}` : ""}`;
195
+ }
196
+ function isEffectivelyVisible(object) {
197
+ let current = object;
198
+ while (current) {
199
+ const state = current;
200
+ if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
201
+ return false;
202
+ }
203
+ current = state.parentContainer ?? null;
204
+ }
205
+ return true;
206
+ }
207
+ function requireFiniteProperty(problems, subject, target, property) {
208
+ if (!(property in target)) return;
209
+ const value = Reflect.get(target, property);
210
+ if (typeof value !== "number" || !Number.isFinite(value)) {
211
+ problems.push(`${subject} has non-finite ${property}: ${String(value)}`);
212
+ }
213
+ }
214
+ function requireFiniteVector(problems, subject, target, property) {
215
+ if (!(property in target)) return;
216
+ const vector = Reflect.get(target, property);
217
+ if (!vector || typeof vector !== "object") return;
218
+ for (const axis of ["x", "y"]) {
219
+ if (!(axis in vector)) continue;
220
+ const value = Reflect.get(vector, axis);
221
+ if (typeof value !== "number" || !Number.isFinite(value)) {
222
+ problems.push(
223
+ `${subject} has non-finite ${property}.${axis}: ${String(value)}`
224
+ );
225
+ }
226
+ }
227
+ }
228
+ function checksObjectState(object) {
229
+ return object.active !== false || isEffectivelyVisible(object) || Boolean(object.input?.enabled);
230
+ }
231
+ function collectGameObjectHealthProblems(scene) {
232
+ const problems = [];
233
+ const transformProperties = ["x", "y", "scaleX", "scaleY", "rotation"];
234
+ const optionalComponents = [
235
+ ["setAlpha", ["alpha", "_alphaTL", "_alphaTR", "_alphaBL", "_alphaBR"]],
236
+ ["setDepth", ["depth"]],
237
+ ["setOrigin", ["originX", "originY", "displayOriginX", "displayOriginY"]],
238
+ ["setScrollFactor", ["scrollFactorX", "scrollFactorY"]]
239
+ ];
240
+ for (const object of collectObjects(scene)) {
241
+ if (!checksObjectState(object)) continue;
242
+ const subject = describeObject(object);
243
+ const target = object;
244
+ if (typeof target.setPosition === "function") {
245
+ for (const property of transformProperties) {
246
+ requireFiniteProperty(problems, subject, object, property);
247
+ }
248
+ }
249
+ for (const [method, properties] of optionalComponents) {
250
+ if (typeof target[method] !== "function") continue;
251
+ for (const property of properties) {
252
+ requireFiniteProperty(problems, subject, object, property);
253
+ }
254
+ }
255
+ }
256
+ return problems;
257
+ }
258
+ function collectCameraHealthProblems(scene) {
259
+ const problems = [];
260
+ const properties = [
261
+ "x",
262
+ "y",
263
+ "width",
264
+ "height",
265
+ "scrollX",
266
+ "scrollY",
267
+ "rotation",
268
+ "zoomX",
269
+ "zoomY"
270
+ ];
271
+ for (const camera of scene.cameras?.cameras ?? []) {
272
+ if (camera.visible === false) continue;
273
+ const subject = `Camera ${JSON.stringify(camera.name || String(camera.id))}`;
274
+ for (const property of properties) {
275
+ requireFiniteProperty(problems, subject, camera, property);
276
+ }
277
+ if (camera.width !== 0 && camera.height !== 0 && (camera.zoomX === 0 || camera.zoomY === 0)) {
278
+ problems.push(`${subject} has zero zoom while its viewport is active`);
279
+ }
280
+ }
281
+ return problems;
282
+ }
283
+ function getArcadeBodyCollection(value) {
284
+ if (value instanceof Set) return [...value];
285
+ if (!value || typeof value !== "object") return void 0;
286
+ const entries = Reflect.get(value, "entries");
287
+ return Array.isArray(entries) ? entries : void 0;
288
+ }
289
+ function getArcadeWorldBodies(world) {
290
+ if (!world || typeof world !== "object") return void 0;
291
+ const bodies = getArcadeBodyCollection(Reflect.get(world, "bodies"));
292
+ const staticBodies = getArcadeBodyCollection(
293
+ Reflect.get(world, "staticBodies")
294
+ );
295
+ return bodies && staticBodies ? [...bodies, ...staticBodies] : void 0;
296
+ }
297
+ function collectArcadeBodyHealthProblems(scene) {
298
+ const problems = [];
299
+ const bodies = getArcadeWorldBodies(scene.physics?.world);
300
+ if (!bodies) return problems;
301
+ for (const body of bodies) {
302
+ if (body.enable === false) continue;
303
+ const gameObject = body.gameObject;
304
+ const subject = gameObject ? `Arcade Body for ${describeObject(gameObject)}` : "Arcade Body";
305
+ for (const property of [
306
+ "position",
307
+ "velocity",
308
+ "acceleration",
309
+ "gravity",
310
+ "offset",
311
+ "center"
312
+ ]) {
313
+ requireFiniteVector(problems, subject, body, property);
314
+ }
315
+ for (const property of [
316
+ "width",
317
+ "height",
318
+ "halfWidth",
319
+ "halfHeight",
320
+ "rotation"
321
+ ]) {
322
+ requireFiniteProperty(problems, subject, body, property);
323
+ }
324
+ }
325
+ return problems;
326
+ }
327
+ function usesCustomHitArea(object) {
328
+ return Boolean(
329
+ object.input?.customHitArea
330
+ );
331
+ }
332
+ function collectInteractiveHealthProblems(scene) {
333
+ const problems = [];
334
+ for (const object of collectObjects(scene)) {
335
+ if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled)
336
+ continue;
337
+ if (usesCustomHitArea(object)) continue;
338
+ const hitArea = object.input.hitArea;
339
+ if (!hitArea) {
340
+ problems.push(`${describeObject(object)} has no default hit area`);
341
+ continue;
342
+ }
343
+ const values = [hitArea.x, hitArea.y, hitArea.width, hitArea.height];
344
+ if (!values.every(Number.isFinite) || hitArea.width <= 0 || hitArea.height <= 0) {
345
+ problems.push(
346
+ `${describeObject(object)} has invalid default hit area (${hitArea.x}, ${hitArea.y}, ${hitArea.width}, ${hitArea.height})`
347
+ );
348
+ }
349
+ }
350
+ return problems;
351
+ }
352
+ function assertSceneRuntimeHealth(scene) {
353
+ const problems = [
354
+ ...collectGameObjectHealthProblems(scene),
355
+ ...collectCameraHealthProblems(scene),
356
+ ...collectArcadeBodyHealthProblems(scene)
357
+ ];
358
+ if (problems.length === 0) return;
359
+ throw new Error(
360
+ [
361
+ `Phaser runtime health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
362
+ ...problems.map((problem) => `- ${problem}`),
363
+ "Keep values consumed by Phaser finite; zero size and zero scale remain allowed."
364
+ ].join("\n")
365
+ );
366
+ }
367
+ function assertSceneInteractiveHealth(scene) {
368
+ const problems = collectInteractiveHealthProblems(scene);
369
+ if (problems.length === 0) return;
370
+ throw new Error(
371
+ [
372
+ `Phaser interactive health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
373
+ ...problems.map((problem) => `- ${problem}`),
374
+ "Fix the default hit area or disable input before making the object interactive."
375
+ ].join("\n")
376
+ );
377
+ }
378
+
379
+ // src/phaser-headless-host.ts
380
+ async function createHeadlessGame(scene, options = {}) {
381
+ const { bootTimeoutMs = 2e3, additionalScenes = [], ...config } = options;
382
+ let game;
383
+ let settled = false;
384
+ let runtimeError;
385
+ let hasRuntimeError = false;
386
+ let restoreHostGuards = () => {
387
+ };
388
+ let removeRuntimeListeners = () => {
389
+ };
390
+ const transitions = [];
391
+ const registeredScenes = [];
392
+ const visitedScenes = [];
393
+ const restartedScenes = [];
394
+ const checkpoints = [];
395
+ const appendUnique = (values, value) => {
396
+ if (value && !values.includes(value)) values.push(value);
397
+ };
398
+ const assertStepCount = (value, name) => {
399
+ if (!Number.isSafeInteger(value) || value < 0) {
400
+ throw new Error(
401
+ `${name} must be a non-negative safe integer; received ${value}`
402
+ );
403
+ }
404
+ };
405
+ const evidence = {
406
+ frames: 0,
407
+ physicsSteps: 0,
408
+ mouseEvents: 0,
409
+ keyboardEvents: 0,
410
+ touchEvents: 0,
411
+ clicks: 0,
412
+ registeredScenes,
413
+ visitedScenes,
414
+ restartedScenes,
415
+ checkpoints,
416
+ destroyed: false,
417
+ transitions
418
+ };
419
+ const created = new Promise((resolve, reject) => {
420
+ const guardedScenes = /* @__PURE__ */ new WeakSet();
421
+ const restoreGuards = [];
422
+ let removeReadinessCheck = () => {
423
+ };
424
+ const finish = (error) => {
425
+ if (settled) {
426
+ return;
427
+ }
428
+ settled = true;
429
+ window.clearTimeout(timeout);
430
+ removeReadinessCheck();
431
+ if (error === void 0) {
432
+ game?.loop.stop();
433
+ resolve();
434
+ } else {
435
+ removeRuntimeListeners();
436
+ reject(error);
437
+ }
438
+ };
439
+ const reportError = (error) => {
440
+ const normalizedError = error instanceof Error ? error : new Error(`Unhandled Phaser runtime error: ${String(error)}`);
441
+ if (!settled) {
442
+ finish(normalizedError);
443
+ } else if (!hasRuntimeError) {
444
+ hasRuntimeError = true;
445
+ runtimeError = normalizedError;
446
+ }
447
+ };
448
+ const onWindowError = (event) => {
449
+ if (event.defaultPrevented) return;
450
+ event.preventDefault();
451
+ const error = event.error ?? new Error(event.message);
452
+ queueMicrotask(() => {
453
+ reportError(error);
454
+ });
455
+ };
456
+ const onUnhandledRejection = (event) => {
457
+ queueMicrotask(() => {
458
+ if (!event.defaultPrevented) reportError(event.reason);
459
+ });
460
+ };
461
+ const timeout = window.setTimeout(() => {
462
+ finish(
463
+ new Error(`Phaser HEADLESS boot timed out after ${bootTimeoutMs}ms`)
464
+ );
465
+ }, bootTimeoutMs);
466
+ window.addEventListener("error", onWindowError, true);
467
+ window.addEventListener("unhandledrejection", onUnhandledRejection);
468
+ removeRuntimeListeners = () => {
469
+ window.removeEventListener("error", onWindowError, true);
470
+ window.removeEventListener("unhandledrejection", onUnhandledRejection);
471
+ };
472
+ const guardScene = (currentScene) => {
473
+ if (guardedScenes.has(currentScene)) return;
474
+ guardedScenes.add(currentScene);
475
+ const scenePlugin = currentScene.scene;
476
+ for (const method of [
477
+ "start",
478
+ "launch",
479
+ "switch",
480
+ "sleep",
481
+ "wake"
482
+ ]) {
483
+ const original = scenePlugin[method];
484
+ if (typeof original !== "function") continue;
485
+ scenePlugin[method] = (key, ...args) => {
486
+ const target = key === void 0 ? currentScene : typeof key === "string" ? currentScene.scene.get(key) : key;
487
+ if (target) guardScene(target);
488
+ const from = currentScene.sys.settings.key;
489
+ const to = key === void 0 ? from : typeof key === "string" ? key : key.sys.settings.key;
490
+ if (from && to) transitions.push({ from, to, method });
491
+ const callArgs = key === void 0 && args.length === 0 ? [] : [key, ...args];
492
+ return Reflect.apply(original, currentScene.scene, callArgs);
493
+ };
494
+ restoreGuards.push(() => {
495
+ scenePlugin[method] = original;
496
+ });
497
+ }
498
+ const originalRestart = scenePlugin.restart;
499
+ if (typeof originalRestart === "function") {
500
+ scenePlugin.restart = (...args) => {
501
+ appendUnique(restartedScenes, currentScene.sys.settings.key);
502
+ return Reflect.apply(originalRestart, currentScene.scene, args);
503
+ };
504
+ restoreGuards.push(() => {
505
+ scenePlugin.restart = originalRestart;
506
+ });
507
+ }
508
+ for (const hook of ["init", "preload", "create", "update"]) {
509
+ const original = Reflect.get(currentScene, hook);
510
+ if (typeof original !== "function") continue;
511
+ const ownDescriptor = Object.getOwnPropertyDescriptor(
512
+ currentScene,
513
+ hook
514
+ );
515
+ Reflect.set(
516
+ currentScene,
517
+ hook,
518
+ function guardedLifecycleHook(...args) {
519
+ try {
520
+ const result = Reflect.apply(original, this, args);
521
+ if (result && typeof result === "object" && "then" in result) {
522
+ const completion = Promise.resolve(result);
523
+ if (currentScene === scene && hook === "create" && !settled) {
524
+ completion.then(
525
+ () => window.setTimeout(() => finish(), 0),
526
+ reportError
527
+ );
528
+ } else {
529
+ completion.catch(reportError);
530
+ }
531
+ } else if (currentScene === scene && hook === "create" && !settled) {
532
+ window.setTimeout(() => finish(), 0);
533
+ }
534
+ return result;
535
+ } catch (error) {
536
+ reportError(error);
537
+ return void 0;
538
+ }
539
+ }
540
+ );
541
+ restoreGuards.push(() => {
542
+ if (ownDescriptor) {
543
+ Object.defineProperty(currentScene, hook, ownDescriptor);
544
+ } else {
545
+ Reflect.deleteProperty(currentScene, hook);
546
+ }
547
+ });
548
+ }
549
+ };
550
+ restoreHostGuards = () => {
551
+ while (restoreGuards.length > 0) restoreGuards.pop()?.();
552
+ };
553
+ const fps = {
554
+ ...config.fps,
555
+ target: config.fps?.target ?? 60,
556
+ forceSetTimeOut: true
557
+ };
558
+ const audio = {
559
+ ...config.audio,
560
+ noAudio: true
561
+ };
562
+ try {
563
+ game = new Phaser3.Game({
564
+ width: 320,
565
+ height: 180,
566
+ banner: false,
567
+ autoFocus: false,
568
+ seed: ["phaser-headless-test"],
569
+ ...config,
570
+ type: Phaser3.HEADLESS,
571
+ fps,
572
+ audio,
573
+ callbacks: {
574
+ preBoot(bootedGame) {
575
+ const manager = bootedGame.scene;
576
+ const originalStart = manager.start;
577
+ manager.start = ((key, data) => {
578
+ const currentScene = typeof key === "string" ? manager.getScene(key) : key;
579
+ if (currentScene) guardScene(currentScene);
580
+ return Reflect.apply(originalStart, manager, [key, data]);
581
+ });
582
+ restoreGuards.push(() => {
583
+ manager.start = originalStart;
584
+ });
585
+ },
586
+ postBoot(bootedGame) {
587
+ const checkReadiness = () => {
588
+ if (typeof Reflect.get(scene, "create") !== "function" && scene.sys.isActive()) {
589
+ finish();
590
+ }
591
+ };
592
+ bootedGame.events.on(Phaser3.Core.Events.POST_STEP, checkReadiness);
593
+ removeReadinessCheck = () => {
594
+ bootedGame.events.off(
595
+ Phaser3.Core.Events.POST_STEP,
596
+ checkReadiness
597
+ );
598
+ };
599
+ }
600
+ },
601
+ scene: additionalScenes.length > 0 ? [scene, ...additionalScenes] : scene
602
+ });
603
+ } catch (error) {
604
+ reportError(error);
605
+ }
606
+ });
607
+ try {
608
+ await created;
609
+ } catch (error) {
610
+ if (game) {
611
+ game.destroy(true);
612
+ game.headlessStep(game.loop.lastTime, 0);
613
+ }
614
+ restoreHostGuards();
615
+ throw error;
616
+ }
617
+ const readyGame = game;
618
+ const throwRuntimeError = () => {
619
+ if (hasRuntimeError) {
620
+ throw runtimeError instanceof Error ? runtimeError : new Error(`Unhandled Phaser runtime error: ${String(runtimeError)}`);
621
+ }
622
+ };
623
+ const canvas = readyGame.canvas;
624
+ if (!canvas) {
625
+ removeRuntimeListeners();
626
+ restoreHostGuards();
627
+ readyGame.destroy(true);
628
+ readyGame.headlessStep(readyGame.loop.lastTime, 0);
629
+ throw new Error("Phaser HEADLESS did not create an input canvas");
630
+ }
631
+ const inputWindow = canvas.ownerDocument.defaultView;
632
+ if (!inputWindow) {
633
+ removeRuntimeListeners();
634
+ restoreHostGuards();
635
+ readyGame.destroy(true);
636
+ readyGame.headlessStep(readyGame.loop.lastTime, 0);
637
+ throw new Error(
638
+ "Phaser HEADLESS input canvas is not attached to a DOM Window"
639
+ );
640
+ }
641
+ let canvasBounds = {
642
+ left: 0,
643
+ top: 0,
644
+ width: canvas.width,
645
+ height: canvas.height
646
+ };
647
+ const setCanvasBounds = (bounds) => {
648
+ const values = [bounds.left, bounds.top, bounds.width, bounds.height];
649
+ if (!values.every(Number.isFinite) || bounds.width <= 0 || bounds.height <= 0) {
650
+ throw new Error(
651
+ `HEADLESS canvas bounds must be finite with positive dimensions; received ${JSON.stringify(bounds)}`
652
+ );
653
+ }
654
+ canvasBounds = { ...bounds };
655
+ canvas.getBoundingClientRect = () => ({
656
+ x: canvasBounds.left,
657
+ y: canvasBounds.top,
658
+ left: canvasBounds.left,
659
+ top: canvasBounds.top,
660
+ right: canvasBounds.left + canvasBounds.width,
661
+ bottom: canvasBounds.top + canvasBounds.height,
662
+ width: canvasBounds.width,
663
+ height: canvasBounds.height,
664
+ toJSON: () => ({ ...canvasBounds })
665
+ });
666
+ readyGame.scale.updateBounds();
667
+ readyGame.scale.displayScale.set(
668
+ readyGame.scale.baseSize.width / canvasBounds.width,
669
+ readyGame.scale.baseSize.height / canvasBounds.height
670
+ );
671
+ };
672
+ setCanvasBounds(canvasBounds);
673
+ const gameToClient = (x, y) => {
674
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
675
+ throw new Error(
676
+ `HEADLESS input coordinates must be finite; received (${x}, ${y})`
677
+ );
678
+ }
679
+ return {
680
+ clientX: canvasBounds.left + x / readyGame.scale.displayScale.x,
681
+ clientY: canvasBounds.top + y / readyGame.scale.displayScale.y
682
+ };
683
+ };
684
+ const assertHealth = () => {
685
+ for (const currentScene of readyGame.scene.getScenes(false)) {
686
+ appendUnique(registeredScenes, currentScene.sys.settings.key);
687
+ if (!currentScene.sys.isActive() && !currentScene.sys.isPaused())
688
+ continue;
689
+ appendUnique(visitedScenes, currentScene.sys.settings.key);
690
+ assertSceneTextHealth(currentScene);
691
+ assertSceneInteractiveHealth(currentScene);
692
+ assertSceneRuntimeHealth(currentScene);
693
+ }
694
+ };
695
+ const settleRuntime = async () => {
696
+ await new Promise((resolve) => inputWindow.setTimeout(resolve, 0));
697
+ throwRuntimeError();
698
+ assertHealth();
699
+ };
700
+ const getEventTarget = (target, kind) => {
701
+ if (!target || typeof Reflect.get(target, "dispatchEvent") !== "function") {
702
+ throw new Error(
703
+ `Phaser HEADLESS ${kind} input target cannot dispatch DOM events`
704
+ );
705
+ }
706
+ return target;
707
+ };
708
+ const dispatchMouse = async (type, x, y, buttons) => {
709
+ const { clientX, clientY } = gameToClient(x, y);
710
+ const mouseTarget = getEventTarget(readyGame.input.mouse?.target, "mouse");
711
+ mouseTarget.dispatchEvent(
712
+ new inputWindow.MouseEvent(type, {
713
+ bubbles: true,
714
+ cancelable: true,
715
+ button: 0,
716
+ buttons,
717
+ clientX,
718
+ clientY
719
+ })
720
+ );
721
+ evidence.mouseEvents += 1;
722
+ await settleRuntime();
723
+ };
724
+ const keyIdentity = (keyCode) => {
725
+ if (keyCode >= 65 && keyCode <= 90) {
726
+ const letter = String.fromCharCode(keyCode);
727
+ return { key: letter.toLowerCase(), code: `Key${letter}` };
728
+ }
729
+ if (keyCode >= 48 && keyCode <= 57) {
730
+ const digit = String.fromCharCode(keyCode);
731
+ return { key: digit, code: `Digit${digit}` };
732
+ }
733
+ const identities = /* @__PURE__ */ new Map([
734
+ [
735
+ Phaser3.Input.Keyboard.KeyCodes.LEFT,
736
+ { key: "ArrowLeft", code: "ArrowLeft" }
737
+ ],
738
+ [
739
+ Phaser3.Input.Keyboard.KeyCodes.RIGHT,
740
+ { key: "ArrowRight", code: "ArrowRight" }
741
+ ],
742
+ [Phaser3.Input.Keyboard.KeyCodes.UP, { key: "ArrowUp", code: "ArrowUp" }],
743
+ [
744
+ Phaser3.Input.Keyboard.KeyCodes.DOWN,
745
+ { key: "ArrowDown", code: "ArrowDown" }
746
+ ],
747
+ [Phaser3.Input.Keyboard.KeyCodes.SPACE, { key: " ", code: "Space" }],
748
+ [Phaser3.Input.Keyboard.KeyCodes.ENTER, { key: "Enter", code: "Enter" }],
749
+ [Phaser3.Input.Keyboard.KeyCodes.ESC, { key: "Escape", code: "Escape" }]
750
+ ]);
751
+ return identities.get(keyCode) ?? { key: "", code: "" };
752
+ };
753
+ const dispatchKeyboard = async (type, keyCode, options2 = {}) => {
754
+ if (!Number.isInteger(keyCode) || keyCode < 0) {
755
+ throw new Error(
756
+ `HEADLESS keyboard keyCode must be a non-negative integer; received ${keyCode}`
757
+ );
758
+ }
759
+ const identity = keyIdentity(keyCode);
760
+ const event = new inputWindow.KeyboardEvent(type, {
761
+ bubbles: true,
762
+ cancelable: true,
763
+ key: options2.key ?? identity.key,
764
+ code: options2.code ?? identity.code,
765
+ repeat: options2.repeat ?? false,
766
+ altKey: options2.altKey ?? false,
767
+ ctrlKey: options2.ctrlKey ?? false,
768
+ metaKey: options2.metaKey ?? false,
769
+ shiftKey: options2.shiftKey ?? false
770
+ });
771
+ Object.defineProperties(event, {
772
+ // Phaser KeyboardManager 仍使用这些兼容旧浏览器的数字字段。
773
+ keyCode: { value: keyCode },
774
+ which: { value: keyCode }
775
+ });
776
+ const keyboardTarget = getEventTarget(
777
+ readyGame.input.keyboard?.target,
778
+ "keyboard"
779
+ );
780
+ keyboardTarget.dispatchEvent(event);
781
+ evidence.keyboardEvents += 1;
782
+ await settleRuntime();
783
+ };
784
+ const activeTouches = /* @__PURE__ */ new Map();
785
+ const createTouch = (x, y, identifier, target) => {
786
+ if (!Number.isInteger(identifier) || identifier < 0) {
787
+ throw new Error(
788
+ `HEADLESS touch identifier must be a non-negative integer; received ${identifier}`
789
+ );
790
+ }
791
+ const { clientX, clientY } = gameToClient(x, y);
792
+ return {
793
+ identifier,
794
+ target,
795
+ clientX,
796
+ clientY,
797
+ pageX: clientX,
798
+ pageY: clientY,
799
+ screenX: clientX,
800
+ screenY: clientY,
801
+ radiusX: 1,
802
+ radiusY: 1,
803
+ rotationAngle: 0,
804
+ force: 1
805
+ };
806
+ };
807
+ const dispatchTouch = async (type, x, y, identifier) => {
808
+ if (type === "touchstart" && activeTouches.has(identifier)) {
809
+ throw new Error(
810
+ `HEADLESS touch identifier ${identifier} is already active`
811
+ );
812
+ }
813
+ if (type !== "touchstart" && !activeTouches.has(identifier)) {
814
+ throw new Error(`HEADLESS touch identifier ${identifier} is not active`);
815
+ }
816
+ const touchTarget = getEventTarget(readyGame.input.touch?.target, "touch");
817
+ const changedTouch = createTouch(x, y, identifier, touchTarget);
818
+ if (type === "touchstart" || type === "touchmove") {
819
+ activeTouches.set(identifier, changedTouch);
820
+ } else {
821
+ activeTouches.delete(identifier);
822
+ }
823
+ const touches = [...activeTouches.values()];
824
+ const event = new inputWindow.Event(type, {
825
+ bubbles: true,
826
+ cancelable: true
827
+ });
828
+ Object.defineProperties(event, {
829
+ changedTouches: { value: [changedTouch] },
830
+ targetTouches: { value: touches },
831
+ touches: { value: touches }
832
+ });
833
+ const document = inputWindow.document;
834
+ const originalElementFromPoint = document.elementFromPoint;
835
+ const originalElementFromPointDescriptor = Object.getOwnPropertyDescriptor(
836
+ document,
837
+ "elementFromPoint"
838
+ );
839
+ Object.defineProperty(document, "elementFromPoint", {
840
+ configurable: true,
841
+ value(clientX, clientY) {
842
+ const insideCanvas = clientX >= canvasBounds.left && clientX <= canvasBounds.left + canvasBounds.width && clientY >= canvasBounds.top && clientY <= canvasBounds.top + canvasBounds.height;
843
+ return insideCanvas ? canvas : originalElementFromPoint?.call(document, clientX, clientY) ?? null;
844
+ }
845
+ });
846
+ try {
847
+ touchTarget.dispatchEvent(event);
848
+ evidence.touchEvents += 1;
849
+ } finally {
850
+ if (originalElementFromPointDescriptor) {
851
+ Object.defineProperty(
852
+ document,
853
+ "elementFromPoint",
854
+ originalElementFromPointDescriptor
855
+ );
856
+ } else {
857
+ Reflect.deleteProperty(document, "elementFromPoint");
858
+ }
859
+ }
860
+ await settleRuntime();
861
+ };
862
+ const input = {
863
+ canvas,
864
+ setCanvasBounds,
865
+ gameToClient,
866
+ settle: settleRuntime,
867
+ mouse: {
868
+ move: (x, y, buttons = 0) => dispatchMouse("mousemove", x, y, buttons),
869
+ down: (x, y) => dispatchMouse("mousedown", x, y, 1),
870
+ up: (x, y) => dispatchMouse("mouseup", x, y, 0),
871
+ async click(x, y) {
872
+ evidence.clicks += 1;
873
+ await dispatchMouse("mousemove", x, y, 0);
874
+ await dispatchMouse("mousedown", x, y, 1);
875
+ await dispatchMouse("mouseup", x, y, 0);
876
+ }
877
+ },
878
+ keyboard: {
879
+ down: (keyCode, options2) => dispatchKeyboard("keydown", keyCode, options2),
880
+ up: (keyCode, options2) => dispatchKeyboard("keyup", keyCode, options2),
881
+ async press(keyCode, options2) {
882
+ await dispatchKeyboard("keydown", keyCode, options2);
883
+ await dispatchKeyboard("keyup", keyCode, options2);
884
+ }
885
+ },
886
+ touch: {
887
+ start: (x, y, identifier = 1) => dispatchTouch("touchstart", x, y, identifier),
888
+ move: (x, y, identifier = 1) => dispatchTouch("touchmove", x, y, identifier),
889
+ end: (x, y, identifier = 1) => dispatchTouch("touchend", x, y, identifier),
890
+ cancel: (x, y, identifier = 1) => dispatchTouch("touchcancel", x, y, identifier),
891
+ async tap(x, y, identifier = 1) {
892
+ await dispatchTouch("touchstart", x, y, identifier);
893
+ await dispatchTouch("touchend", x, y, identifier);
894
+ }
895
+ }
896
+ };
897
+ const frameDurationMs = 1e3 / (config.fps?.target ?? 60);
898
+ const stepFrame = () => {
899
+ readyGame.loop.step(readyGame.loop.lastTime + frameDurationMs);
900
+ evidence.frames += 1;
901
+ };
902
+ const stepFrames = (count = 1) => {
903
+ assertStepCount(count, "stepFrames count");
904
+ throwRuntimeError();
905
+ for (let frame = 0; frame < count; frame += 1) stepFrame();
906
+ throwRuntimeError();
907
+ assertHealth();
908
+ };
909
+ try {
910
+ throwRuntimeError();
911
+ assertHealth();
912
+ } catch (error) {
913
+ removeRuntimeListeners();
914
+ restoreHostGuards();
915
+ readyGame.destroy(true);
916
+ readyGame.headlessStep(readyGame.loop.lastTime, 0);
917
+ throw error;
918
+ }
919
+ let destroyed = false;
920
+ return {
921
+ game: readyGame,
922
+ scene,
923
+ settle: settleRuntime,
924
+ input,
925
+ evidence,
926
+ assertGameplayEvidence(requirements = {}) {
927
+ const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
928
+ if (requirements.requireInput && inputEvents === 0) {
929
+ throw new Error(
930
+ "Gameplay evidence is missing real HEADLESS input events."
931
+ );
932
+ }
933
+ if (requirements.requireFrameAdvance && evidence.frames === 0) {
934
+ throw new Error(
935
+ "Gameplay evidence is missing complete Phaser frame advancement."
936
+ );
937
+ }
938
+ if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
939
+ throw new Error(
940
+ "Gameplay evidence is missing Arcade Physics advancement."
941
+ );
942
+ }
943
+ const requiredTransitions = requirements.requireTransition === void 0 ? [] : typeof requirements.requireTransition === "string" ? [requirements.requireTransition] : requirements.requireTransition;
944
+ for (const target of requiredTransitions) {
945
+ if (!evidence.transitions.some((transition) => transition.to === target)) {
946
+ throw new Error(
947
+ `Gameplay evidence is missing a transition to ${target}.`
948
+ );
949
+ }
950
+ }
951
+ const requiredScenes = requirements.requireScene === void 0 ? [] : typeof requirements.requireScene === "string" ? [requirements.requireScene] : requirements.requireScene;
952
+ for (const sceneKey of requiredScenes) {
953
+ if (!evidence.visitedScenes.includes(sceneKey)) {
954
+ throw new Error(
955
+ `Gameplay evidence is missing a visit to Scene ${sceneKey}.`
956
+ );
957
+ }
958
+ }
959
+ const requiredRestarts = requirements.requireRestart === void 0 ? [] : typeof requirements.requireRestart === "string" ? [requirements.requireRestart] : requirements.requireRestart;
960
+ for (const sceneKey of requiredRestarts) {
961
+ if (!evidence.restartedScenes.includes(sceneKey)) {
962
+ throw new Error(
963
+ `Gameplay evidence is missing a restart of Scene ${sceneKey}.`
964
+ );
965
+ }
966
+ }
967
+ const requiredCheckpoints = requirements.requireCheckpoint === void 0 ? [] : typeof requirements.requireCheckpoint === "string" ? [requirements.requireCheckpoint] : requirements.requireCheckpoint;
968
+ for (const checkpoint of requiredCheckpoints) {
969
+ if (!evidence.checkpoints.includes(checkpoint)) {
970
+ throw new Error(
971
+ `Gameplay evidence is missing checkpoint ${checkpoint}.`
972
+ );
973
+ }
974
+ }
975
+ if (requirements.requireDestroy && !evidence.destroyed) {
976
+ throw new Error(
977
+ "Gameplay evidence is missing HEADLESS host destruction."
978
+ );
979
+ }
980
+ },
981
+ stepFrames,
982
+ async stepFramesAsync(count = 1) {
983
+ stepFrames(count);
984
+ await settleRuntime();
985
+ },
986
+ stepUntil(condition, maxFrames = 120) {
987
+ assertStepCount(maxFrames, "stepUntil maxFrames");
988
+ throwRuntimeError();
989
+ for (let frame = 0; frame <= maxFrames; frame += 1) {
990
+ if (condition()) {
991
+ assertHealth();
992
+ return frame;
993
+ }
994
+ if (frame < maxFrames) {
995
+ stepFrame();
996
+ throwRuntimeError();
997
+ }
998
+ }
999
+ assertHealth();
1000
+ throw new Error(
1001
+ `Condition was not met within ${maxFrames} complete Phaser frames`
1002
+ );
1003
+ },
1004
+ stepPhysics(steps = 1) {
1005
+ assertStepCount(steps, "stepPhysics steps");
1006
+ throwRuntimeError();
1007
+ const world = scene.physics?.world;
1008
+ if (!world || !getArcadeWorldBodies(world) || typeof world.singleStep !== "function") {
1009
+ throw new Error(
1010
+ "stepPhysics() requires an Arcade Physics world; use stepFrames() for other configurations."
1011
+ );
1012
+ }
1013
+ for (let step = 0; step < steps; step += 1) {
1014
+ scene.physics.world.singleStep();
1015
+ evidence.physicsSteps += 1;
1016
+ }
1017
+ throwRuntimeError();
1018
+ assertHealth();
1019
+ },
1020
+ checkpoint(id) {
1021
+ const normalizedId = id.trim();
1022
+ if (!normalizedId || normalizedId.length > 120) {
1023
+ throw new Error(
1024
+ "Gameplay checkpoint id must contain 1 to 120 non-whitespace characters."
1025
+ );
1026
+ }
1027
+ appendUnique(checkpoints, normalizedId);
1028
+ },
1029
+ assertTextHealth() {
1030
+ assertSceneTextHealth(scene);
1031
+ },
1032
+ destroy() {
1033
+ if (destroyed) {
1034
+ return;
1035
+ }
1036
+ destroyed = true;
1037
+ try {
1038
+ readyGame.destroy(true);
1039
+ readyGame.headlessStep(readyGame.loop.lastTime, 0);
1040
+ throwRuntimeError();
1041
+ evidence.destroyed = true;
1042
+ } finally {
1043
+ removeRuntimeListeners();
1044
+ restoreHostGuards();
1045
+ }
1046
+ }
1047
+ };
1048
+ }
1049
+
1050
+ // src/lint/phaser-headless.test.ts
1051
+ var SmokeScene = class extends Phaser4.Scene {
1052
+ movingObject;
1053
+ randomValue = 0;
1054
+ updates = 0;
1055
+ textObject;
1056
+ constructor() {
1057
+ super("SmokeScene");
1058
+ }
1059
+ create() {
1060
+ this.randomValue = Phaser4.Math.RND.frac();
1061
+ this.movingObject = this.add.zone(40, 50, 16, 16);
1062
+ this.textObject = this.add.text(8, 8, "READY", { fontSize: "16px" });
1063
+ if (this.physics) {
1064
+ this.physics.add.existing(this.movingObject);
1065
+ const body = this.movingObject.body;
1066
+ body.setVelocityX(60);
1067
+ }
1068
+ }
1069
+ update() {
1070
+ this.updates += 1;
1071
+ }
1072
+ };
1073
+ var LayeredInputScene = class extends Phaser4.Scene {
1074
+ firstHits = 0;
1075
+ secondHits = 0;
1076
+ constructor() {
1077
+ super("LayeredInputScene");
1078
+ }
1079
+ create() {
1080
+ this.add.zone(80, 60, 40, 40).setInteractive().on("pointerup", () => {
1081
+ this.firstHits += 1;
1082
+ });
1083
+ this.add.zone(80, 60, 40, 40).setInteractive().on("pointerup", () => {
1084
+ this.secondHits += 1;
1085
+ });
1086
+ }
1087
+ };
1088
+ var KeyboardInputScene = class extends Phaser4.Scene {
1089
+ playerX = 0;
1090
+ rightKey;
1091
+ constructor() {
1092
+ super("KeyboardInputScene");
1093
+ }
1094
+ create() {
1095
+ this.rightKey = this.input.keyboard.addKey(
1096
+ Phaser4.Input.Keyboard.KeyCodes.RIGHT
1097
+ );
1098
+ }
1099
+ update() {
1100
+ if (this.rightKey.isDown) this.playerX += 1;
1101
+ }
1102
+ };
1103
+ var TouchInputScene = class extends Phaser4.Scene {
1104
+ moves = 0;
1105
+ taps = 0;
1106
+ wasTouch = false;
1107
+ constructor() {
1108
+ super("TouchInputScene");
1109
+ }
1110
+ create() {
1111
+ this.add.zone(80, 60, 40, 40).setInteractive().on("pointerup", (pointer) => {
1112
+ this.taps += 1;
1113
+ this.wasTouch = pointer.wasTouch;
1114
+ });
1115
+ this.input.on("pointermove", (pointer) => {
1116
+ if (pointer.wasTouch) this.moves += 1;
1117
+ });
1118
+ }
1119
+ };
1120
+ describe("Phaser HEADLESS host", () => {
1121
+ let host;
1122
+ afterEach(() => {
1123
+ host?.destroy();
1124
+ host = void 0;
1125
+ });
1126
+ it("boots a scene without a renderer and advances Arcade Physics", async () => {
1127
+ host = await createHeadlessGame(new SmokeScene(), {
1128
+ physics: {
1129
+ default: "arcade",
1130
+ arcade: { gravity: { x: 0, y: 0 }, fixedStep: true }
1131
+ }
1132
+ });
1133
+ expect(host.game.renderer).toBeNull();
1134
+ expect(host.scene.sys.isActive()).toBe(true);
1135
+ const initialX = host.scene.movingObject.x;
1136
+ host.stepPhysics();
1137
+ expect(host.scene.movingObject.x).toBeGreaterThan(initialX);
1138
+ expect(host.scene.physics.world.bodies.size).toBe(1);
1139
+ expect(host.scene.textObject.width).toBeGreaterThan(0);
1140
+ const initialUpdates = host.scene.updates;
1141
+ host.stepFrames(3);
1142
+ expect(host.scene.updates).toBe(initialUpdates + 3);
1143
+ });
1144
+ it("rejects invalid advancement counts instead of entering an unbounded loop", async () => {
1145
+ const boundedHost = await createHeadlessGame(new LayeredInputScene());
1146
+ expect(() => boundedHost.stepFrames(Number.POSITIVE_INFINITY)).toThrow(
1147
+ "stepFrames count must be a non-negative safe integer"
1148
+ );
1149
+ expect(() => boundedHost.stepUntil(() => false, 1.5)).toThrow(
1150
+ "stepUntil maxFrames must be a non-negative safe integer"
1151
+ );
1152
+ expect(() => boundedHost.stepPhysics(-1)).toThrow(
1153
+ "stepPhysics steps must be a non-negative safe integer"
1154
+ );
1155
+ boundedHost.destroy();
1156
+ });
1157
+ it("drives scaled DOM mouse input through hit testing and top-only dispatch", async () => {
1158
+ const inputHost = await createHeadlessGame(new LayeredInputScene(), {
1159
+ width: 160,
1160
+ height: 120
1161
+ });
1162
+ inputHost.stepFrames();
1163
+ inputHost.input.setCanvasBounds({
1164
+ left: 20,
1165
+ top: 30,
1166
+ width: 80,
1167
+ height: 60
1168
+ });
1169
+ expect(inputHost.input.gameToClient(80, 60)).toEqual({
1170
+ clientX: 60,
1171
+ clientY: 60
1172
+ });
1173
+ await inputHost.input.mouse.click(80, 60);
1174
+ expect({
1175
+ pointerX: inputHost.scene.input.activePointer.x,
1176
+ pointerY: inputHost.scene.input.activePointer.y,
1177
+ totalHits: inputHost.scene.firstHits + inputHost.scene.secondHits
1178
+ }).toEqual({
1179
+ pointerX: 80,
1180
+ pointerY: 60,
1181
+ totalHits: 1
1182
+ });
1183
+ await inputHost.input.mouse.click(10, 10);
1184
+ expect(inputHost.scene.firstHits + inputHost.scene.secondHits).toBe(1);
1185
+ inputHost.assertGameplayEvidence({
1186
+ requireInput: true,
1187
+ requireFrameAdvance: true
1188
+ });
1189
+ expect(inputHost.evidence.clicks).toBe(2);
1190
+ inputHost.destroy();
1191
+ });
1192
+ it("drives held DOM keyboard input through Phaser's configured target", async () => {
1193
+ const keyboardHost = await createHeadlessGame(new KeyboardInputScene());
1194
+ await keyboardHost.input.keyboard.down(
1195
+ Phaser4.Input.Keyboard.KeyCodes.RIGHT
1196
+ );
1197
+ keyboardHost.stepFrames(3);
1198
+ expect(keyboardHost.scene.playerX).toBe(3);
1199
+ await keyboardHost.input.keyboard.up(Phaser4.Input.Keyboard.KeyCodes.RIGHT);
1200
+ keyboardHost.stepFrames(2);
1201
+ expect(keyboardHost.scene.playerX).toBe(3);
1202
+ expect(keyboardHost.evidence.keyboardEvents).toBe(2);
1203
+ expect(
1204
+ () => keyboardHost.assertGameplayEvidence({ requireInput: true })
1205
+ ).not.toThrow();
1206
+ keyboardHost.destroy();
1207
+ });
1208
+ it("drives DOM touch input through pointer conversion and hit testing", async () => {
1209
+ const touchHost = await createHeadlessGame(new TouchInputScene(), {
1210
+ width: 160,
1211
+ height: 120
1212
+ });
1213
+ touchHost.stepFrames();
1214
+ touchHost.input.setCanvasBounds({
1215
+ left: 20,
1216
+ top: 30,
1217
+ width: 80,
1218
+ height: 60
1219
+ });
1220
+ await touchHost.input.touch.start(40, 40);
1221
+ await touchHost.input.touch.move(80, 60);
1222
+ await touchHost.input.touch.end(80, 60);
1223
+ expect({
1224
+ moves: touchHost.scene.moves,
1225
+ pointerX: touchHost.scene.input.activePointer.x,
1226
+ pointerY: touchHost.scene.input.activePointer.y,
1227
+ taps: touchHost.scene.taps,
1228
+ wasTouch: touchHost.scene.wasTouch
1229
+ }).toEqual({
1230
+ moves: 1,
1231
+ pointerX: 80,
1232
+ pointerY: 60,
1233
+ taps: 1,
1234
+ wasTouch: true
1235
+ });
1236
+ expect(touchHost.evidence.touchEvents).toBe(3);
1237
+ expect(
1238
+ () => touchHost.assertGameplayEvidence({ requireInput: true })
1239
+ ).not.toThrow();
1240
+ touchHost.destroy();
1241
+ });
1242
+ it("boots when a production input family is disabled", async () => {
1243
+ const mouseOnlyHost = await createHeadlessGame(new SmokeScene(), {
1244
+ input: { keyboard: false, touch: false }
1245
+ });
1246
+ await expect(
1247
+ mouseOnlyHost.input.keyboard.press(Phaser4.Input.Keyboard.KeyCodes.SPACE)
1248
+ ).rejects.toThrow("keyboard input target cannot dispatch DOM events");
1249
+ await expect(mouseOnlyHost.input.touch.tap(20, 20)).rejects.toThrow(
1250
+ "touch input target cannot dispatch DOM events"
1251
+ );
1252
+ mouseOnlyHost.destroy();
1253
+ });
1254
+ it("registers transition targets before boot and settles queued transitions", async () => {
1255
+ class TargetScene extends Phaser4.Scene {
1256
+ receivedValue = 0;
1257
+ constructor() {
1258
+ super("TargetScene");
1259
+ }
1260
+ init(data) {
1261
+ this.receivedValue = data.value;
1262
+ }
1263
+ }
1264
+ class SourceScene extends Phaser4.Scene {
1265
+ constructor() {
1266
+ super("SourceScene");
1267
+ }
1268
+ create() {
1269
+ this.add.zone(40, 40, 30, 30).setInteractive().on("pointerup", () => {
1270
+ this.scene.start("TargetScene", { value: 7 });
1271
+ });
1272
+ }
1273
+ }
1274
+ const transitionHost = await createHeadlessGame(new SourceScene(), {
1275
+ width: 100,
1276
+ height: 100,
1277
+ additionalScenes: [TargetScene]
1278
+ });
1279
+ transitionHost.stepFrames();
1280
+ expect(transitionHost.game.scene.isActive("TargetScene")).toBe(false);
1281
+ await transitionHost.input.mouse.click(40, 40);
1282
+ transitionHost.stepUntil(
1283
+ () => transitionHost.game.scene.isActive("TargetScene")
1284
+ );
1285
+ const target = transitionHost.game.scene.getScene(
1286
+ "TargetScene"
1287
+ );
1288
+ expect(target.receivedValue).toBe(7);
1289
+ expect(transitionHost.game.scene.isActive("SourceScene")).toBe(false);
1290
+ transitionHost.assertGameplayEvidence({
1291
+ requireInput: true,
1292
+ requireFrameAdvance: true,
1293
+ requireTransition: "TargetScene"
1294
+ });
1295
+ transitionHost.destroy();
1296
+ });
1297
+ it("preserves Scene sleep and wake commands that omit their optional key", async () => {
1298
+ class OptionalCommandScene extends Phaser4.Scene {
1299
+ constructor() {
1300
+ super("OptionalCommandScene");
1301
+ }
1302
+ }
1303
+ const optionalHost = await createHeadlessGame(new OptionalCommandScene());
1304
+ optionalHost.scene.scene.sleep();
1305
+ optionalHost.stepFrames();
1306
+ expect(optionalHost.scene.sys.isSleeping()).toBe(true);
1307
+ optionalHost.scene.scene.wake();
1308
+ optionalHost.stepFrames();
1309
+ expect(optionalHost.scene.sys.isActive()).toBe(true);
1310
+ expect(optionalHost.evidence.transitions).toEqual([
1311
+ {
1312
+ from: "OptionalCommandScene",
1313
+ to: "OptionalCommandScene",
1314
+ method: "sleep"
1315
+ },
1316
+ {
1317
+ from: "OptionalCommandScene",
1318
+ to: "OptionalCommandScene",
1319
+ method: "wake"
1320
+ }
1321
+ ]);
1322
+ optionalHost.destroy();
1323
+ });
1324
+ it("records Scene visits, restart, checkpoints, and destruction", async () => {
1325
+ const evidenceHost = await createHeadlessGame(new SmokeScene());
1326
+ evidenceHost.checkpoint("progress");
1327
+ evidenceHost.scene.scene.restart();
1328
+ evidenceHost.stepFrames();
1329
+ evidenceHost.destroy();
1330
+ expect(evidenceHost.evidence.registeredScenes).toContain("SmokeScene");
1331
+ expect(evidenceHost.evidence.visitedScenes).toContain("SmokeScene");
1332
+ expect(evidenceHost.evidence.restartedScenes).toContain("SmokeScene");
1333
+ expect(evidenceHost.evidence.checkpoints).toEqual(["progress"]);
1334
+ expect(evidenceHost.evidence.destroyed).toBe(true);
1335
+ expect(
1336
+ () => evidenceHost.assertGameplayEvidence({
1337
+ requireScene: "SmokeScene",
1338
+ requireRestart: "SmokeScene",
1339
+ requireCheckpoint: "progress",
1340
+ requireDestroy: true
1341
+ })
1342
+ ).not.toThrow();
1343
+ });
1344
+ it("surfaces exceptions thrown by DOM-driven input callbacks", async () => {
1345
+ class BrokenInputScene extends Phaser4.Scene {
1346
+ create() {
1347
+ this.add.zone(40, 40, 30, 30).setInteractive().on("pointerup", () => {
1348
+ throw new Error("expected DOM input failure");
1349
+ });
1350
+ }
1351
+ }
1352
+ const brokenHost = await createHeadlessGame(new BrokenInputScene(), {
1353
+ width: 100,
1354
+ height: 100
1355
+ });
1356
+ brokenHost.stepFrames();
1357
+ await expect(brokenHost.input.mouse.click(40, 40)).rejects.toThrow(
1358
+ "expected DOM input failure"
1359
+ );
1360
+ expect(() => brokenHost.destroy()).toThrow("expected DOM input failure");
1361
+ });
1362
+ it("automatically rejects invalid Phaser Text dimensions with repair guidance", async () => {
1363
+ class InvalidTextScene extends Phaser4.Scene {
1364
+ create() {
1365
+ const label = this.add.text(8, 8, "BROKEN", {
1366
+ fixedWidth: void 0
1367
+ });
1368
+ this.add.container(0, 0, label);
1369
+ }
1370
+ }
1371
+ await expect(createHeadlessGame(new InvalidTextScene())).rejects.toThrow(
1372
+ /non-finite width.*fixedWidth: value \?\? 0/s
1373
+ );
1374
+ });
1375
+ it("rechecks Text after manually advancing the game", async () => {
1376
+ host = await createHeadlessGame(new SmokeScene());
1377
+ host.scene.textObject.setStyle({
1378
+ fixedWidth: void 0
1379
+ });
1380
+ expect(() => host?.stepFrames()).toThrow(
1381
+ /non-finite width.*fixedWidth: value \?\? 0/s
1382
+ );
1383
+ });
1384
+ it("rejects an enabled interactive object with unusable hit-test bounds", async () => {
1385
+ class InvalidInteractiveScene extends Phaser4.Scene {
1386
+ create() {
1387
+ const zone = this.add.zone(80, 80, 20, 20).setInteractive();
1388
+ (zone.input?.hitArea).width = 0;
1389
+ }
1390
+ }
1391
+ await expect(
1392
+ createHeadlessGame(new InvalidInteractiveScene())
1393
+ ).rejects.toThrow(
1394
+ /interactive health check failed.*invalid default hit area/s
1395
+ );
1396
+ });
1397
+ it("allows dormant objects and zero-scale transitions", async () => {
1398
+ class DormantObjectScene extends Phaser4.Scene {
1399
+ create() {
1400
+ this.add.zone(Number.NaN, 20, 10, 10).setActive(false).setVisible(false);
1401
+ this.add.zone(20, 20, 10, 10).setInteractive().setScale(0);
1402
+ this.add.text(20, 20, "hidden", { fontSize: "0px" }).setVisible(false);
1403
+ const child = this.add.zone(20, 20, 10, 10).setInteractive();
1404
+ (child.input?.hitArea).width = 0;
1405
+ this.add.container(0, 0, child).setVisible(false);
1406
+ }
1407
+ }
1408
+ const dormantHost = await createHeadlessGame(new DormantObjectScene());
1409
+ expect(() => dormantHost.destroy()).not.toThrow();
1410
+ });
1411
+ it("rejects non-finite live GameObject transforms", async () => {
1412
+ class InvalidTransformScene extends Phaser4.Scene {
1413
+ create() {
1414
+ this.add.zone(Number.NaN, 20, 10, 10);
1415
+ }
1416
+ }
1417
+ await expect(
1418
+ createHeadlessGame(new InvalidTransformScene())
1419
+ ).rejects.toThrow(/runtime health check failed.*non-finite x: NaN/s);
1420
+ });
1421
+ it("rejects zero Camera zoom but allows negative zoom", async () => {
1422
+ class InvalidCameraScene extends Phaser4.Scene {
1423
+ create() {
1424
+ this.cameras.main.zoomX = 0;
1425
+ }
1426
+ }
1427
+ await expect(createHeadlessGame(new InvalidCameraScene())).rejects.toThrow(
1428
+ /runtime health check failed.*zero zoom/s
1429
+ );
1430
+ class MirroredCameraScene extends Phaser4.Scene {
1431
+ create() {
1432
+ this.cameras.main.setZoom(-1, 1);
1433
+ }
1434
+ }
1435
+ const mirroredHost = await createHeadlessGame(new MirroredCameraScene());
1436
+ expect(() => mirroredHost.destroy()).not.toThrow();
1437
+ });
1438
+ it("rejects non-finite enabled Arcade Body state and ignores disabled Bodies", async () => {
1439
+ class InvalidBodyScene extends Phaser4.Scene {
1440
+ create() {
1441
+ const object = this.add.zone(20, 20, 10, 10);
1442
+ this.physics.add.existing(object);
1443
+ object.body.velocity.x = Number.NaN;
1444
+ }
1445
+ }
1446
+ await expect(
1447
+ createHeadlessGame(new InvalidBodyScene(), {
1448
+ physics: { default: "arcade" }
1449
+ })
1450
+ ).rejects.toThrow(/runtime health check failed.*velocity.x: NaN/s);
1451
+ class DisabledBodyScene extends Phaser4.Scene {
1452
+ create() {
1453
+ const object = this.add.zone(20, 20, 10, 10);
1454
+ this.physics.add.existing(object);
1455
+ const body = object.body;
1456
+ body.enable = false;
1457
+ body.velocity.x = Number.NaN;
1458
+ }
1459
+ }
1460
+ const disabledHost = await createHeadlessGame(new DisabledBodyScene(), {
1461
+ physics: { default: "arcade" }
1462
+ });
1463
+ expect(() => disabledHost.destroy()).not.toThrow();
1464
+ });
1465
+ it("captures rejected async lifecycle hooks", async () => {
1466
+ class AsyncFailureScene extends Phaser4.Scene {
1467
+ async create() {
1468
+ await Promise.resolve();
1469
+ throw new Error("expected async create failure");
1470
+ }
1471
+ }
1472
+ await expect(createHeadlessGame(new AsyncFailureScene())).rejects.toThrow(
1473
+ "expected async create failure"
1474
+ );
1475
+ });
1476
+ it("waits for preload to finish before resolving", async () => {
1477
+ class PreloadScene extends Phaser4.Scene {
1478
+ created = false;
1479
+ preload() {
1480
+ this.load.image(
1481
+ "pixel",
1482
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+Avz9WQAAAABJRU5ErkJggg=="
1483
+ );
1484
+ }
1485
+ create() {
1486
+ this.created = true;
1487
+ }
1488
+ }
1489
+ const preloadHost = await createHeadlessGame(new PreloadScene());
1490
+ expect(preloadHost.scene.created).toBe(true);
1491
+ expect(preloadHost.scene.textures.exists("pixel")).toBe(true);
1492
+ preloadHost.destroy();
1493
+ });
1494
+ it("captures lifecycle Promise rejections without an Error reason", async () => {
1495
+ class EmptyRejectionScene extends Phaser4.Scene {
1496
+ create() {
1497
+ return Promise.reject(void 0);
1498
+ }
1499
+ }
1500
+ await expect(createHeadlessGame(new EmptyRejectionScene())).rejects.toThrow(
1501
+ "Unhandled Phaser runtime error: undefined"
1502
+ );
1503
+ });
1504
+ it("does not apply Arcade checks to Matter Physics", async () => {
1505
+ class MatterScene extends Phaser4.Scene {
1506
+ create() {
1507
+ this.matter.add.rectangle(40, 40, 12, 12);
1508
+ }
1509
+ }
1510
+ const matterHost = await createHeadlessGame(new MatterScene(), {
1511
+ physics: { default: "matter" }
1512
+ });
1513
+ expect(() => matterHost.stepFrames()).not.toThrow();
1514
+ expect(() => matterHost.stepPhysics()).toThrow(
1515
+ "stepPhysics() requires an Arcade Physics world"
1516
+ );
1517
+ matterHost.destroy();
1518
+ });
1519
+ it("does not require a physics system", async () => {
1520
+ class NoPhysicsScene extends Phaser4.Scene {
1521
+ create() {
1522
+ this.add.text(8, 8, "NO PHYSICS");
1523
+ }
1524
+ }
1525
+ const noPhysicsHost = await createHeadlessGame(new NoPhysicsScene());
1526
+ expect(noPhysicsHost.scene.physics).toBeUndefined();
1527
+ expect(() => noPhysicsHost.stepFrames()).not.toThrow();
1528
+ expect(() => noPhysicsHost.stepPhysics()).toThrow(
1529
+ "stepPhysics() requires an Arcade Physics world"
1530
+ );
1531
+ noPhysicsHost.destroy();
1532
+ });
1533
+ it("can create a fresh game after destroying the previous instance", async () => {
1534
+ const firstHost = await createHeadlessGame(new SmokeScene());
1535
+ firstHost.destroy();
1536
+ expect(() => firstHost.destroy()).not.toThrow();
1537
+ host = await createHeadlessGame(new SmokeScene());
1538
+ expect(host.scene.sys.isActive()).toBe(true);
1539
+ });
1540
+ it("accepts game config overrides and reproduces a fixed seed", async () => {
1541
+ host = await createHeadlessGame(new SmokeScene(), {
1542
+ width: 640,
1543
+ height: 360,
1544
+ seed: ["repeatable"],
1545
+ physics: {
1546
+ default: "arcade",
1547
+ arcade: { gravity: { x: 0, y: 120 } }
1548
+ }
1549
+ });
1550
+ const firstRandomValue = host.scene.randomValue;
1551
+ expect(host.game.config.width).toBe(640);
1552
+ expect(host.game.config.height).toBe(360);
1553
+ expect(host.scene.physics.world.gravity.y).toBe(120);
1554
+ host.destroy();
1555
+ host = await createHeadlessGame(new SmokeScene(), { seed: ["repeatable"] });
1556
+ expect(host.scene.randomValue).toBe(firstRandomValue);
1557
+ });
1558
+ it("rejects promptly when scene creation fails", async () => {
1559
+ class BrokenScene extends Phaser4.Scene {
1560
+ create() {
1561
+ throw new Error("expected boot failure");
1562
+ }
1563
+ }
1564
+ await expect(
1565
+ createHeadlessGame(new BrokenScene(), { bootTimeoutMs: 250 })
1566
+ ).rejects.toThrow("expected boot failure");
1567
+ });
1568
+ it("times out instead of hanging when boot cannot complete in time", async () => {
1569
+ await expect(
1570
+ createHeadlessGame(new SmokeScene(), { bootTimeoutMs: 0 })
1571
+ ).rejects.toThrow("Phaser HEADLESS boot timed out after 0ms");
1572
+ host = await createHeadlessGame(new SmokeScene());
1573
+ expect(host.scene.sys.isActive()).toBe(true);
1574
+ });
1575
+ });