miaoda-game-devkit 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,12 @@ Scene 命令、restart、业务 checkpoint 和销毁。玩法测试可以调用
30
30
  `host.assertGameplayEvidence()` 把这些客观事实设为门禁;它不推断业务语义,
31
31
  authoritative state 仍必须由测试显式断言。
32
32
 
33
+ HEADLESS host 会把 Phaser 自身的 warning/error、Loader 文件失败、漏掉 `load.start()` 的队列、
34
+ 未注册 Scene 命令和可见对象的 `__MISSING` 纹理升级为测试失败。只有经过确认的引擎
35
+ warning 才能在创建 host 时通过 `ignoreEngineWarnings: [/pattern/]` 显式豁免;资源 URL
36
+ 仍须通过浏览器验证,因为 JSDOM 图片加载只支持测试用的 `data:image/` URL。刻意暂存
37
+ 资源供稍后加载的 Scene 可以通过 `allowIdleLoaderQueues: ["SceneKey"]` 精确声明。
38
+
33
39
  玩法测试应优先使用 `gameplayTest`。它是玩法 contract 的唯一权威声明,在执行阶段绑定
34
40
  HEADLESS host,并在 `onTestFinished` 校验 Ledger。`vitest.config.ts` 默认只列生产 Scene,
35
41
  不再复制 contract id、测试文件和证据要求。Reporter 从测试 metadata 汇总交互项目的最低
@@ -1,5 +1,7 @@
1
1
  import * as Phaser from 'phaser';
2
2
 
3
+ type EngineWarningMatcher = string | RegExp;
4
+
3
5
  interface HeadlessGameHost<TScene extends Phaser.Scene> {
4
6
  /** 当前隔离的 Phaser 游戏实例。 */
5
7
  game: Phaser.Game;
@@ -158,6 +160,10 @@ type HeadlessGameOptions = Omit<Phaser.Types.Core.GameConfig, "type" | "scene" |
158
160
  bootTimeoutMs?: number;
159
161
  /** 初始 Scene 启动前必须注册的其他 Scene 类型。 */
160
162
  additionalScenes?: Phaser.Types.Scenes.SceneType[];
163
+ /** 显式允许的 Phaser 引擎 warning;其余引擎 warning 会使测试失败。 */
164
+ ignoreEngineWarnings?: readonly EngineWarningMatcher[];
165
+ /** 明确允许 Loader 暂存文件但尚未 start 的 Scene key。 */
166
+ allowIdleLoaderQueues?: readonly string[];
161
167
  };
162
168
  /** 创建隔离的 Phaser 游戏,并在入口 Scene 完成 create 后返回。 */
163
169
  declare function createHeadlessGame<TScene extends Phaser.Scene>(scene: TScene, options?: HeadlessGameOptions): Promise<HeadlessGameHost<TScene>>;
@@ -205,4 +211,4 @@ interface GameplayContractMetadata {
205
211
  verified?: boolean;
206
212
  }
207
213
 
208
- export { type GameplayAuditOptions as G, type HeadlessGameHost as H, type GameplayContractMetadata as a, type HeadlessGameplayEvidenceRequirements as b, type GameplayAuditContract as c, type HeadlessCanvasBounds as d, type HeadlessGameOptions as e, type HeadlessGameplayEvidence as f, type HeadlessInputDriver as g, type HeadlessInputPoint as h, type HeadlessKeyboardEventOptions as i, type HeadlessKeyboardInput as j, type HeadlessMouseInput as k, type HeadlessTouchInput as l, createHeadlessGame as m };
214
+ export { type EngineWarningMatcher as E, type GameplayAuditOptions as G, type HeadlessGameHost as H, type GameplayContractMetadata as a, type HeadlessGameplayEvidenceRequirements as b, type GameplayAuditContract as c, type HeadlessCanvasBounds as d, type HeadlessGameOptions as e, type HeadlessGameplayEvidence as f, type HeadlessInputDriver as g, type HeadlessInputPoint as h, type HeadlessKeyboardEventOptions as i, type HeadlessKeyboardInput as j, type HeadlessMouseInput as k, type HeadlessTouchInput as l, createHeadlessGame as m };
@@ -1,5 +1,7 @@
1
1
  import * as Phaser from 'phaser';
2
2
 
3
+ type EngineWarningMatcher = string | RegExp;
4
+
3
5
  interface HeadlessGameHost<TScene extends Phaser.Scene> {
4
6
  /** 当前隔离的 Phaser 游戏实例。 */
5
7
  game: Phaser.Game;
@@ -158,6 +160,10 @@ type HeadlessGameOptions = Omit<Phaser.Types.Core.GameConfig, "type" | "scene" |
158
160
  bootTimeoutMs?: number;
159
161
  /** 初始 Scene 启动前必须注册的其他 Scene 类型。 */
160
162
  additionalScenes?: Phaser.Types.Scenes.SceneType[];
163
+ /** 显式允许的 Phaser 引擎 warning;其余引擎 warning 会使测试失败。 */
164
+ ignoreEngineWarnings?: readonly EngineWarningMatcher[];
165
+ /** 明确允许 Loader 暂存文件但尚未 start 的 Scene key。 */
166
+ allowIdleLoaderQueues?: readonly string[];
161
167
  };
162
168
  /** 创建隔离的 Phaser 游戏,并在入口 Scene 完成 create 后返回。 */
163
169
  declare function createHeadlessGame<TScene extends Phaser.Scene>(scene: TScene, options?: HeadlessGameOptions): Promise<HeadlessGameHost<TScene>>;
@@ -205,4 +211,4 @@ interface GameplayContractMetadata {
205
211
  verified?: boolean;
206
212
  }
207
213
 
208
- export { type GameplayAuditOptions as G, type HeadlessGameHost as H, type GameplayContractMetadata as a, type HeadlessGameplayEvidenceRequirements as b, type GameplayAuditContract as c, type HeadlessCanvasBounds as d, type HeadlessGameOptions as e, type HeadlessGameplayEvidence as f, type HeadlessInputDriver as g, type HeadlessInputPoint as h, type HeadlessKeyboardEventOptions as i, type HeadlessKeyboardInput as j, type HeadlessMouseInput as k, type HeadlessTouchInput as l, createHeadlessGame as m };
214
+ export { type EngineWarningMatcher as E, type GameplayAuditOptions as G, type HeadlessGameHost as H, type GameplayContractMetadata as a, type HeadlessGameplayEvidenceRequirements as b, type GameplayAuditContract as c, type HeadlessCanvasBounds as d, type HeadlessGameOptions as e, type HeadlessGameplayEvidence as f, type HeadlessInputDriver as g, type HeadlessInputPoint as h, type HeadlessKeyboardEventOptions as i, type HeadlessKeyboardInput as j, type HeadlessMouseInput as k, type HeadlessTouchInput as l, createHeadlessGame as m };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as GameplayContractMetadata, H as HeadlessGameHost, b as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-BwAobjIX.mjs';
2
- export { c as GameplayAuditContract, G as GameplayAuditOptions, d as HeadlessCanvasBounds, e as HeadlessGameOptions, f as HeadlessGameplayEvidence, g as HeadlessInputDriver, h as HeadlessInputPoint, i as HeadlessKeyboardEventOptions, j as HeadlessKeyboardInput, k as HeadlessMouseInput, l as HeadlessTouchInput, m as createHeadlessGame } from './gameplay-audit-BwAobjIX.mjs';
1
+ import { a as GameplayContractMetadata, H as HeadlessGameHost, b as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-DmbBA0M_.mjs';
2
+ export { E as EngineWarningMatcher, c as GameplayAuditContract, G as GameplayAuditOptions, d as HeadlessCanvasBounds, e as HeadlessGameOptions, f as HeadlessGameplayEvidence, g as HeadlessInputDriver, h as HeadlessInputPoint, i as HeadlessKeyboardEventOptions, j as HeadlessKeyboardInput, k as HeadlessMouseInput, l as HeadlessTouchInput, m as createHeadlessGame } from './gameplay-audit-DmbBA0M_.mjs';
3
3
  import * as Phaser from 'phaser';
4
4
  import { TestContext, TestOptions } from 'vitest';
5
5
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as GameplayContractMetadata, H as HeadlessGameHost, b as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-BwAobjIX.js';
2
- export { c as GameplayAuditContract, G as GameplayAuditOptions, d as HeadlessCanvasBounds, e as HeadlessGameOptions, f as HeadlessGameplayEvidence, g as HeadlessInputDriver, h as HeadlessInputPoint, i as HeadlessKeyboardEventOptions, j as HeadlessKeyboardInput, k as HeadlessMouseInput, l as HeadlessTouchInput, m as createHeadlessGame } from './gameplay-audit-BwAobjIX.js';
1
+ import { a as GameplayContractMetadata, H as HeadlessGameHost, b as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-DmbBA0M_.js';
2
+ export { E as EngineWarningMatcher, c as GameplayAuditContract, G as GameplayAuditOptions, d as HeadlessCanvasBounds, e as HeadlessGameOptions, f as HeadlessGameplayEvidence, g as HeadlessInputDriver, h as HeadlessInputPoint, i as HeadlessKeyboardEventOptions, j as HeadlessKeyboardInput, k as HeadlessMouseInput, l as HeadlessTouchInput, m as createHeadlessGame } from './gameplay-audit-DmbBA0M_.js';
3
3
  import * as Phaser from 'phaser';
4
4
  import { TestContext, TestOptions } from 'vitest';
5
5
 
package/dist/index.js CHANGED
@@ -46,6 +46,75 @@ module.exports = __toCommonJS(src_exports);
46
46
  // src/phaser-headless-host.ts
47
47
  var Phaser3 = __toESM(require("phaser"));
48
48
 
49
+ // src/phaser-engine-warnings.ts
50
+ var originalWarn;
51
+ var originalError;
52
+ var listener;
53
+ function formatWarningArgument(value) {
54
+ if (typeof value === "string") return value;
55
+ if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
56
+ return String(value);
57
+ }
58
+ if (value instanceof Error) return value.message;
59
+ const constructorName = typeof value === "object" ? value.constructor?.name : void 0;
60
+ return constructorName ? `[${constructorName}]` : String(value);
61
+ }
62
+ function comesFromPhaser(stack) {
63
+ return /(?:^|[/\\])phaser(?:[/\\]|\.(?:js|mjs|cjs)(?::\d+)?)/m.test(stack);
64
+ }
65
+ function captureEngineDiagnostic(level, original, args) {
66
+ const stack = new Error(`Phaser engine ${level}`).stack ?? "";
67
+ if (!comesFromPhaser(stack)) {
68
+ original(...args);
69
+ return;
70
+ }
71
+ listener?.({
72
+ level,
73
+ message: args.map(formatWarningArgument).join(" "),
74
+ stack
75
+ });
76
+ }
77
+ function installWarningMultiplexer(currentListener) {
78
+ if (listener) {
79
+ throw new Error(
80
+ "Only one Phaser HEADLESS host may be active in the same Vitest worker. Destroy the current host before creating another one."
81
+ );
82
+ }
83
+ listener = currentListener;
84
+ const passthroughWarn = console.warn;
85
+ const passthroughError = console.error;
86
+ originalWarn = passthroughWarn;
87
+ originalError = passthroughError;
88
+ console.warn = (...args) => {
89
+ captureEngineDiagnostic("warn", passthroughWarn, args);
90
+ };
91
+ console.error = (...args) => {
92
+ captureEngineDiagnostic("error", passthroughError, args);
93
+ };
94
+ }
95
+ function uninstallWarningMultiplexer() {
96
+ if (!listener) return;
97
+ if (originalWarn) console.warn = originalWarn;
98
+ if (originalError) console.error = originalError;
99
+ listener = void 0;
100
+ originalWarn = void 0;
101
+ originalError = void 0;
102
+ }
103
+ function observeEngineDiagnostics(currentListener) {
104
+ installWarningMultiplexer(currentListener);
105
+ let observing = true;
106
+ return () => {
107
+ if (!observing) return;
108
+ observing = false;
109
+ uninstallWarningMultiplexer();
110
+ };
111
+ }
112
+ function matchesEngineWarning(message, matcher) {
113
+ if (typeof matcher === "string") return message.includes(matcher);
114
+ matcher.lastIndex = 0;
115
+ return matcher.test(message);
116
+ }
117
+
49
118
  // src/phaser-text-assertions.ts
50
119
  var Phaser = __toESM(require("phaser"));
51
120
  function describeText(label) {
@@ -55,12 +124,15 @@ function describeText(label) {
55
124
  }
56
125
  function isEffectivelyRenderable(label) {
57
126
  let current = label;
127
+ const visited = /* @__PURE__ */ new Set();
58
128
  while (current) {
129
+ if (visited.has(current)) return false;
130
+ visited.add(current);
59
131
  const state = current;
60
132
  if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
61
133
  return false;
62
134
  }
63
- current = state.parentContainer ?? null;
135
+ current = state.parentContainer ?? (state.displayList instanceof Phaser.GameObjects.Layer ? state.displayList : null);
64
136
  }
65
137
  return true;
66
138
  }
@@ -236,12 +308,15 @@ function describeObject(object) {
236
308
  }
237
309
  function isEffectivelyVisible(object) {
238
310
  let current = object;
311
+ const visited = /* @__PURE__ */ new Set();
239
312
  while (current) {
313
+ if (visited.has(current)) return false;
314
+ visited.add(current);
240
315
  const state = current;
241
316
  if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
242
317
  return false;
243
318
  }
244
- current = state.parentContainer ?? null;
319
+ current = state.parentContainer ?? (state.displayList instanceof Phaser2.GameObjects.Layer ? state.displayList : null);
245
320
  }
246
321
  return true;
247
322
  }
@@ -282,6 +357,12 @@ function collectGameObjectHealthProblems(scene) {
282
357
  if (!checksObjectState(object)) continue;
283
358
  const subject = describeObject(object);
284
359
  const target = object;
360
+ const texture = target.texture;
361
+ if (isEffectivelyVisible(object) && texture && typeof texture === "object" && Reflect.get(texture, "key") === "__MISSING") {
362
+ problems.push(
363
+ `${subject} uses Phaser's __MISSING texture; verify the loaded texture key and asset path`
364
+ );
365
+ }
285
366
  if (typeof target.setPosition === "function") {
286
367
  for (const property of transformProperties) {
287
368
  requireFiniteProperty(problems, subject, object, property);
@@ -419,7 +500,13 @@ function assertSceneInteractiveHealth(scene) {
419
500
 
420
501
  // src/phaser-headless-host.ts
421
502
  async function createHeadlessGame(scene, options = {}) {
422
- const { bootTimeoutMs = 2e3, additionalScenes = [], ...config } = options;
503
+ const {
504
+ bootTimeoutMs = 2e3,
505
+ additionalScenes = [],
506
+ ignoreEngineWarnings = [],
507
+ allowIdleLoaderQueues = [],
508
+ ...config
509
+ } = options;
423
510
  let game;
424
511
  let settled = false;
425
512
  let runtimeError;
@@ -428,6 +515,16 @@ async function createHeadlessGame(scene, options = {}) {
428
515
  };
429
516
  let removeRuntimeListeners = () => {
430
517
  };
518
+ const engineDiagnostics = [];
519
+ const loaderFailures = [];
520
+ const stopObservingEngineDiagnostics = observeEngineDiagnostics((diagnostic) => {
521
+ if (diagnostic.level === "warn" && ignoreEngineWarnings.some(
522
+ (matcher) => matchesEngineWarning(diagnostic.message, matcher)
523
+ )) {
524
+ return;
525
+ }
526
+ engineDiagnostics.push(diagnostic);
527
+ });
431
528
  const transitions = [];
432
529
  const registeredScenes = [];
433
530
  const visitedScenes = [];
@@ -513,6 +610,25 @@ async function createHeadlessGame(scene, options = {}) {
513
610
  const guardScene = (currentScene) => {
514
611
  if (guardedScenes.has(currentScene)) return;
515
612
  guardedScenes.add(currentScene);
613
+ const onFileLoadError = (file) => {
614
+ const source = file.src || file.url || "unknown source";
615
+ loaderFailures.push({
616
+ scene: currentScene.sys.settings.key,
617
+ type: String(file.type),
618
+ key: String(file.key),
619
+ source: String(source)
620
+ });
621
+ };
622
+ currentScene.load.on(
623
+ Phaser3.Loader.Events.FILE_LOAD_ERROR,
624
+ onFileLoadError
625
+ );
626
+ restoreGuards.push(() => {
627
+ currentScene.load.off(
628
+ Phaser3.Loader.Events.FILE_LOAD_ERROR,
629
+ onFileLoadError
630
+ );
631
+ });
516
632
  const scenePlugin = currentScene.scene;
517
633
  for (const method of [
518
634
  "start",
@@ -525,7 +641,12 @@ async function createHeadlessGame(scene, options = {}) {
525
641
  if (typeof original !== "function") continue;
526
642
  scenePlugin[method] = (key, ...args) => {
527
643
  const target = key === void 0 ? currentScene : typeof key === "string" ? currentScene.scene.get(key) : key;
528
- if (target) guardScene(target);
644
+ if (!target) {
645
+ throw new Error(
646
+ `[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.`
647
+ );
648
+ }
649
+ guardScene(target);
529
650
  const from = currentScene.sys.settings.key;
530
651
  const to = key === void 0 ? from : typeof key === "string" ? key : key.sys.settings.key;
531
652
  if (from && to) transitions.push({ from, to, method });
@@ -653,6 +774,7 @@ async function createHeadlessGame(scene, options = {}) {
653
774
  game.headlessStep(game.loop.lastTime, 0);
654
775
  }
655
776
  restoreHostGuards();
777
+ stopObservingEngineDiagnostics();
656
778
  throw error;
657
779
  }
658
780
  const readyGame = game;
@@ -660,11 +782,39 @@ async function createHeadlessGame(scene, options = {}) {
660
782
  if (hasRuntimeError) {
661
783
  throw runtimeError instanceof Error ? runtimeError : new Error(`Unhandled Phaser runtime error: ${String(runtimeError)}`);
662
784
  }
785
+ if (loaderFailures.length > 0) {
786
+ const imageTypes = /* @__PURE__ */ new Set(["image", "normalMap", "spritesheet"]);
787
+ const hasHeadlessImageFailure = loaderFailures.some(
788
+ (failure) => imageTypes.has(failure.type)
789
+ );
790
+ throw new Error(
791
+ [
792
+ "Phaser loader failed to load one or more files:",
793
+ ...loaderFailures.map(
794
+ (failure) => `- ${failure.scene}: failed to load ${failure.type} ${JSON.stringify(failure.key)} from ${JSON.stringify(failure.source)}`
795
+ ),
796
+ 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."
797
+ ].join("\n")
798
+ );
799
+ }
800
+ if (engineDiagnostics.length > 0) {
801
+ throw new Error(
802
+ [
803
+ "Phaser emitted an engine diagnostic:",
804
+ ...engineDiagnostics.flatMap((diagnostic) => [
805
+ `- ${diagnostic.level}: ${diagnostic.message}`,
806
+ ...diagnostic.stack.split("\n").slice(2, 8).map((frame) => ` ${frame.trim()}`)
807
+ ]),
808
+ "Fix the invalid Phaser operation. Only known warnings (not errors) may be allowed with ignoreEngineWarnings."
809
+ ].join("\n")
810
+ );
811
+ }
663
812
  };
664
813
  const canvas = readyGame.canvas;
665
814
  if (!canvas) {
666
815
  removeRuntimeListeners();
667
816
  restoreHostGuards();
817
+ stopObservingEngineDiagnostics();
668
818
  readyGame.destroy(true);
669
819
  readyGame.headlessStep(readyGame.loop.lastTime, 0);
670
820
  throw new Error("Phaser HEADLESS did not create an input canvas");
@@ -673,6 +823,7 @@ async function createHeadlessGame(scene, options = {}) {
673
823
  if (!inputWindow) {
674
824
  removeRuntimeListeners();
675
825
  restoreHostGuards();
826
+ stopObservingEngineDiagnostics();
676
827
  readyGame.destroy(true);
677
828
  readyGame.headlessStep(readyGame.loop.lastTime, 0);
678
829
  throw new Error(
@@ -728,6 +879,14 @@ async function createHeadlessGame(scene, options = {}) {
728
879
  if (!currentScene.sys.isActive() && !currentScene.sys.isPaused())
729
880
  continue;
730
881
  appendUnique(visitedScenes, currentScene.sys.settings.key);
882
+ if (!allowIdleLoaderQueues.includes(currentScene.sys.settings.key) && !currentScene.load.isLoading() && currentScene.load.list.size > 0) {
883
+ const queuedFiles = [...currentScene.load.list].map(
884
+ (file) => `${file.type}:${String(file.key)}`
885
+ );
886
+ throw new Error(
887
+ `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().`
888
+ );
889
+ }
731
890
  assertSceneTextHealth(currentScene);
732
891
  assertSceneInteractiveHealth(currentScene);
733
892
  assertSceneRuntimeHealth(currentScene);
@@ -953,6 +1112,7 @@ async function createHeadlessGame(scene, options = {}) {
953
1112
  } catch (error) {
954
1113
  removeRuntimeListeners();
955
1114
  restoreHostGuards();
1115
+ stopObservingEngineDiagnostics();
956
1116
  readyGame.destroy(true);
957
1117
  readyGame.headlessStep(readyGame.loop.lastTime, 0);
958
1118
  throw error;
@@ -1084,6 +1244,7 @@ async function createHeadlessGame(scene, options = {}) {
1084
1244
  } finally {
1085
1245
  removeRuntimeListeners();
1086
1246
  restoreHostGuards();
1247
+ stopObservingEngineDiagnostics();
1087
1248
  }
1088
1249
  }
1089
1250
  };
package/dist/index.mjs CHANGED
@@ -1,6 +1,75 @@
1
1
  // src/phaser-headless-host.ts
2
2
  import * as Phaser3 from "phaser";
3
3
 
4
+ // src/phaser-engine-warnings.ts
5
+ var originalWarn;
6
+ var originalError;
7
+ var listener;
8
+ function formatWarningArgument(value) {
9
+ if (typeof value === "string") return value;
10
+ if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
11
+ return String(value);
12
+ }
13
+ if (value instanceof Error) return value.message;
14
+ const constructorName = typeof value === "object" ? value.constructor?.name : void 0;
15
+ return constructorName ? `[${constructorName}]` : String(value);
16
+ }
17
+ function comesFromPhaser(stack) {
18
+ return /(?:^|[/\\])phaser(?:[/\\]|\.(?:js|mjs|cjs)(?::\d+)?)/m.test(stack);
19
+ }
20
+ function captureEngineDiagnostic(level, original, args) {
21
+ const stack = new Error(`Phaser engine ${level}`).stack ?? "";
22
+ if (!comesFromPhaser(stack)) {
23
+ original(...args);
24
+ return;
25
+ }
26
+ listener?.({
27
+ level,
28
+ message: args.map(formatWarningArgument).join(" "),
29
+ stack
30
+ });
31
+ }
32
+ function installWarningMultiplexer(currentListener) {
33
+ if (listener) {
34
+ throw new Error(
35
+ "Only one Phaser HEADLESS host may be active in the same Vitest worker. Destroy the current host before creating another one."
36
+ );
37
+ }
38
+ listener = currentListener;
39
+ const passthroughWarn = console.warn;
40
+ const passthroughError = console.error;
41
+ originalWarn = passthroughWarn;
42
+ originalError = passthroughError;
43
+ console.warn = (...args) => {
44
+ captureEngineDiagnostic("warn", passthroughWarn, args);
45
+ };
46
+ console.error = (...args) => {
47
+ captureEngineDiagnostic("error", passthroughError, args);
48
+ };
49
+ }
50
+ function uninstallWarningMultiplexer() {
51
+ if (!listener) return;
52
+ if (originalWarn) console.warn = originalWarn;
53
+ if (originalError) console.error = originalError;
54
+ listener = void 0;
55
+ originalWarn = void 0;
56
+ originalError = void 0;
57
+ }
58
+ function observeEngineDiagnostics(currentListener) {
59
+ installWarningMultiplexer(currentListener);
60
+ let observing = true;
61
+ return () => {
62
+ if (!observing) return;
63
+ observing = false;
64
+ uninstallWarningMultiplexer();
65
+ };
66
+ }
67
+ function matchesEngineWarning(message, matcher) {
68
+ if (typeof matcher === "string") return message.includes(matcher);
69
+ matcher.lastIndex = 0;
70
+ return matcher.test(message);
71
+ }
72
+
4
73
  // src/phaser-text-assertions.ts
5
74
  import * as Phaser from "phaser";
6
75
  function describeText(label) {
@@ -10,12 +79,15 @@ function describeText(label) {
10
79
  }
11
80
  function isEffectivelyRenderable(label) {
12
81
  let current = label;
82
+ const visited = /* @__PURE__ */ new Set();
13
83
  while (current) {
84
+ if (visited.has(current)) return false;
85
+ visited.add(current);
14
86
  const state = current;
15
87
  if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
16
88
  return false;
17
89
  }
18
- current = state.parentContainer ?? null;
90
+ current = state.parentContainer ?? (state.displayList instanceof Phaser.GameObjects.Layer ? state.displayList : null);
19
91
  }
20
92
  return true;
21
93
  }
@@ -191,12 +263,15 @@ function describeObject(object) {
191
263
  }
192
264
  function isEffectivelyVisible(object) {
193
265
  let current = object;
266
+ const visited = /* @__PURE__ */ new Set();
194
267
  while (current) {
268
+ if (visited.has(current)) return false;
269
+ visited.add(current);
195
270
  const state = current;
196
271
  if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
197
272
  return false;
198
273
  }
199
- current = state.parentContainer ?? null;
274
+ current = state.parentContainer ?? (state.displayList instanceof Phaser2.GameObjects.Layer ? state.displayList : null);
200
275
  }
201
276
  return true;
202
277
  }
@@ -237,6 +312,12 @@ function collectGameObjectHealthProblems(scene) {
237
312
  if (!checksObjectState(object)) continue;
238
313
  const subject = describeObject(object);
239
314
  const target = object;
315
+ const texture = target.texture;
316
+ if (isEffectivelyVisible(object) && texture && typeof texture === "object" && Reflect.get(texture, "key") === "__MISSING") {
317
+ problems.push(
318
+ `${subject} uses Phaser's __MISSING texture; verify the loaded texture key and asset path`
319
+ );
320
+ }
240
321
  if (typeof target.setPosition === "function") {
241
322
  for (const property of transformProperties) {
242
323
  requireFiniteProperty(problems, subject, object, property);
@@ -374,7 +455,13 @@ function assertSceneInteractiveHealth(scene) {
374
455
 
375
456
  // src/phaser-headless-host.ts
376
457
  async function createHeadlessGame(scene, options = {}) {
377
- const { bootTimeoutMs = 2e3, additionalScenes = [], ...config } = options;
458
+ const {
459
+ bootTimeoutMs = 2e3,
460
+ additionalScenes = [],
461
+ ignoreEngineWarnings = [],
462
+ allowIdleLoaderQueues = [],
463
+ ...config
464
+ } = options;
378
465
  let game;
379
466
  let settled = false;
380
467
  let runtimeError;
@@ -383,6 +470,16 @@ async function createHeadlessGame(scene, options = {}) {
383
470
  };
384
471
  let removeRuntimeListeners = () => {
385
472
  };
473
+ const engineDiagnostics = [];
474
+ const loaderFailures = [];
475
+ const stopObservingEngineDiagnostics = observeEngineDiagnostics((diagnostic) => {
476
+ if (diagnostic.level === "warn" && ignoreEngineWarnings.some(
477
+ (matcher) => matchesEngineWarning(diagnostic.message, matcher)
478
+ )) {
479
+ return;
480
+ }
481
+ engineDiagnostics.push(diagnostic);
482
+ });
386
483
  const transitions = [];
387
484
  const registeredScenes = [];
388
485
  const visitedScenes = [];
@@ -468,6 +565,25 @@ async function createHeadlessGame(scene, options = {}) {
468
565
  const guardScene = (currentScene) => {
469
566
  if (guardedScenes.has(currentScene)) return;
470
567
  guardedScenes.add(currentScene);
568
+ const onFileLoadError = (file) => {
569
+ const source = file.src || file.url || "unknown source";
570
+ loaderFailures.push({
571
+ scene: currentScene.sys.settings.key,
572
+ type: String(file.type),
573
+ key: String(file.key),
574
+ source: String(source)
575
+ });
576
+ };
577
+ currentScene.load.on(
578
+ Phaser3.Loader.Events.FILE_LOAD_ERROR,
579
+ onFileLoadError
580
+ );
581
+ restoreGuards.push(() => {
582
+ currentScene.load.off(
583
+ Phaser3.Loader.Events.FILE_LOAD_ERROR,
584
+ onFileLoadError
585
+ );
586
+ });
471
587
  const scenePlugin = currentScene.scene;
472
588
  for (const method of [
473
589
  "start",
@@ -480,7 +596,12 @@ async function createHeadlessGame(scene, options = {}) {
480
596
  if (typeof original !== "function") continue;
481
597
  scenePlugin[method] = (key, ...args) => {
482
598
  const target = key === void 0 ? currentScene : typeof key === "string" ? currentScene.scene.get(key) : key;
483
- if (target) guardScene(target);
599
+ if (!target) {
600
+ throw new Error(
601
+ `[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.`
602
+ );
603
+ }
604
+ guardScene(target);
484
605
  const from = currentScene.sys.settings.key;
485
606
  const to = key === void 0 ? from : typeof key === "string" ? key : key.sys.settings.key;
486
607
  if (from && to) transitions.push({ from, to, method });
@@ -608,6 +729,7 @@ async function createHeadlessGame(scene, options = {}) {
608
729
  game.headlessStep(game.loop.lastTime, 0);
609
730
  }
610
731
  restoreHostGuards();
732
+ stopObservingEngineDiagnostics();
611
733
  throw error;
612
734
  }
613
735
  const readyGame = game;
@@ -615,11 +737,39 @@ async function createHeadlessGame(scene, options = {}) {
615
737
  if (hasRuntimeError) {
616
738
  throw runtimeError instanceof Error ? runtimeError : new Error(`Unhandled Phaser runtime error: ${String(runtimeError)}`);
617
739
  }
740
+ if (loaderFailures.length > 0) {
741
+ const imageTypes = /* @__PURE__ */ new Set(["image", "normalMap", "spritesheet"]);
742
+ const hasHeadlessImageFailure = loaderFailures.some(
743
+ (failure) => imageTypes.has(failure.type)
744
+ );
745
+ throw new Error(
746
+ [
747
+ "Phaser loader failed to load one or more files:",
748
+ ...loaderFailures.map(
749
+ (failure) => `- ${failure.scene}: failed to load ${failure.type} ${JSON.stringify(failure.key)} from ${JSON.stringify(failure.source)}`
750
+ ),
751
+ 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."
752
+ ].join("\n")
753
+ );
754
+ }
755
+ if (engineDiagnostics.length > 0) {
756
+ throw new Error(
757
+ [
758
+ "Phaser emitted an engine diagnostic:",
759
+ ...engineDiagnostics.flatMap((diagnostic) => [
760
+ `- ${diagnostic.level}: ${diagnostic.message}`,
761
+ ...diagnostic.stack.split("\n").slice(2, 8).map((frame) => ` ${frame.trim()}`)
762
+ ]),
763
+ "Fix the invalid Phaser operation. Only known warnings (not errors) may be allowed with ignoreEngineWarnings."
764
+ ].join("\n")
765
+ );
766
+ }
618
767
  };
619
768
  const canvas = readyGame.canvas;
620
769
  if (!canvas) {
621
770
  removeRuntimeListeners();
622
771
  restoreHostGuards();
772
+ stopObservingEngineDiagnostics();
623
773
  readyGame.destroy(true);
624
774
  readyGame.headlessStep(readyGame.loop.lastTime, 0);
625
775
  throw new Error("Phaser HEADLESS did not create an input canvas");
@@ -628,6 +778,7 @@ async function createHeadlessGame(scene, options = {}) {
628
778
  if (!inputWindow) {
629
779
  removeRuntimeListeners();
630
780
  restoreHostGuards();
781
+ stopObservingEngineDiagnostics();
631
782
  readyGame.destroy(true);
632
783
  readyGame.headlessStep(readyGame.loop.lastTime, 0);
633
784
  throw new Error(
@@ -683,6 +834,14 @@ async function createHeadlessGame(scene, options = {}) {
683
834
  if (!currentScene.sys.isActive() && !currentScene.sys.isPaused())
684
835
  continue;
685
836
  appendUnique(visitedScenes, currentScene.sys.settings.key);
837
+ if (!allowIdleLoaderQueues.includes(currentScene.sys.settings.key) && !currentScene.load.isLoading() && currentScene.load.list.size > 0) {
838
+ const queuedFiles = [...currentScene.load.list].map(
839
+ (file) => `${file.type}:${String(file.key)}`
840
+ );
841
+ throw new Error(
842
+ `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().`
843
+ );
844
+ }
686
845
  assertSceneTextHealth(currentScene);
687
846
  assertSceneInteractiveHealth(currentScene);
688
847
  assertSceneRuntimeHealth(currentScene);
@@ -908,6 +1067,7 @@ async function createHeadlessGame(scene, options = {}) {
908
1067
  } catch (error) {
909
1068
  removeRuntimeListeners();
910
1069
  restoreHostGuards();
1070
+ stopObservingEngineDiagnostics();
911
1071
  readyGame.destroy(true);
912
1072
  readyGame.headlessStep(readyGame.loop.lastTime, 0);
913
1073
  throw error;
@@ -1039,6 +1199,7 @@ async function createHeadlessGame(scene, options = {}) {
1039
1199
  } finally {
1040
1200
  removeRuntimeListeners();
1041
1201
  restoreHostGuards();
1202
+ stopObservingEngineDiagnostics();
1042
1203
  }
1043
1204
  }
1044
1205
  };