miaoda-game-devkit 0.2.12 → 0.2.13

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