miaoda-game-devkit 0.2.9 → 0.2.11

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.
Files changed (40) hide show
  1. package/README.md +27 -1
  2. package/dist/cli/lint.js +32 -1
  3. package/dist/game-clock-suUidZdT.d.mts +11 -0
  4. package/dist/game-clock-suUidZdT.d.ts +11 -0
  5. package/dist/{gameplay-audit-CCJo4Qhk.d.mts → gameplay-audit-DmbBA0M_.d.mts} +7 -1
  6. package/dist/{gameplay-audit-CCJo4Qhk.d.ts → gameplay-audit-DmbBA0M_.d.ts} +7 -1
  7. package/dist/index.d.mts +2 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +165 -4
  10. package/dist/index.mjs +165 -4
  11. package/dist/lint/game-telemetry.contract.mjs +165 -4
  12. package/dist/lint/gameplay-contract.contract.mjs +165 -4
  13. package/dist/lint/phaser-headless.contract.mjs +281 -4
  14. package/dist/lint/vitest-config.contract.mjs +234 -11
  15. package/dist/phaser-facade.mjs +156 -0
  16. package/dist/react/index.d.mts +13 -0
  17. package/dist/react/index.d.ts +13 -0
  18. package/dist/react/index.js +57 -0
  19. package/dist/react/index.mjs +29 -0
  20. package/dist/react/testing.d.mts +21 -0
  21. package/dist/react/testing.d.ts +21 -0
  22. package/dist/react/testing.js +82 -0
  23. package/dist/react/testing.mjs +57 -0
  24. package/dist/react/vitest-config.d.mts +14 -0
  25. package/dist/react/vitest-config.d.ts +14 -0
  26. package/dist/react/vitest-config.js +57 -0
  27. package/dist/react/vitest-config.mjs +32 -0
  28. package/dist/react/vitest-setup.d.mts +2 -0
  29. package/dist/react/vitest-setup.d.ts +2 -0
  30. package/dist/react/vitest-setup.js +35 -0
  31. package/dist/react/vitest-setup.mjs +33 -0
  32. package/dist/vite.d.mts +22 -0
  33. package/dist/vite.d.ts +22 -0
  34. package/dist/vite.js +93 -0
  35. package/dist/vite.mjs +67 -0
  36. package/dist/vitest-config.d.mts +2 -2
  37. package/dist/vitest-config.d.ts +2 -2
  38. package/dist/vitest-config.js +53 -3
  39. package/dist/vitest-config.mjs +53 -3
  40. package/package.json +72 -2
package/README.md CHANGED
@@ -1,9 +1,13 @@
1
1
  # miaoda-game-devkit
2
2
 
3
- 妙搭 Phaser 4 游戏模板的共享开发基础设施。
3
+ 妙搭 Phaser 4 与 React 游戏模板的共享开发基础设施。
4
4
 
5
5
  该开发工具包提供:
6
6
 
7
+ - React 游戏的生产/手动时钟、Strict Mode 安全 Session ownership、JSDOM
8
+ Vitest 配置和自动收集运行错误的 Testing Library setup,分别从 `miaoda-game-devkit/react`、
9
+ `miaoda-game-devkit/react/testing` 和 `miaoda-game-devkit/react/vitest-config` 导入;
10
+
7
11
  - 支持 DOM 鼠标、键盘和触摸输入的确定性 Phaser 无头测试宿主;
8
12
  - 不依赖渲染器的运行时、交互区域和文字健康检查;
9
13
  - 由模板平台维护的通用 Vitest 契约测试;
@@ -11,6 +15,8 @@
11
15
  Oxlint 和 Phaser 通用契约检查。
12
16
  - 统一的 `defineGameVitestConfig` 配置工厂,固定 Phaser 无头测试依赖的基础配置,
13
17
  同时允许每个游戏声明 Scene 审计范围,并配置额外 alias、setup 文件和超时。
18
+ - 与 Vite `defineConfig` 同签名的 `defineGameViteConfig` 配置工厂,统一维护生产 Scene
19
+ 的运行时基础能力,同时完整保留游戏自己的 Vite 配置。
14
20
 
15
21
  互动游戏的长流程优先使用游戏自己暴露的 `globalThis.gameTelemetry`。稳定 façade 通常调用
16
22
  `installGameTelemetry(this.game, telemetry)` 绑定 Game 生命周期;只有刻意局部的 façade 才绑定
@@ -24,6 +30,12 @@ Scene 命令、restart、业务 checkpoint 和销毁。玩法测试可以调用
24
30
  `host.assertGameplayEvidence()` 把这些客观事实设为门禁;它不推断业务语义,
25
31
  authoritative state 仍必须由测试显式断言。
26
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
+
27
39
  玩法测试应优先使用 `gameplayTest`。它是玩法 contract 的唯一权威声明,在执行阶段绑定
28
40
  HEADLESS host,并在 `onTestFinished` 校验 Ledger。`vitest.config.ts` 默认只列生产 Scene,
29
41
  不再复制 contract id、测试文件和证据要求。Reporter 从测试 metadata 汇总交互项目的最低
@@ -54,6 +66,20 @@ monorepo 中的模板可以放置只包含 `extends: ["miaoda-game-devkit/biome"
54
66
  别名等项目差异。TypeScript 严格模式、bundler 模块解析和无输出检查等平台基线由开发
55
67
  工具包集中维护,`miaoda-game-lint` 会直接检查模板的这个配置。
56
68
 
69
+ Phaser4 模板的 `vite.config.ts` 应从 `miaoda-game-devkit/vite` 导入 `defineGameViteConfig`,调用方式
70
+ 与 Vite 的 `defineConfig` 相同,支持配置对象、Promise、同步配置函数和异步配置函数:
71
+
72
+ ```ts
73
+ export default defineGameViteConfig({
74
+ plugins: [miaodaDevPlugin()],
75
+ });
76
+ ```
77
+
78
+ 该入口只增强 `src/scenes` 中对 Phaser 的导入。游戏 Scene 仍按标准方式继承
79
+ `Phaser.Scene`;devkit 在 Scene 创建前提供 RexUI,并在创建后验证业务确实完成了 RexUI
80
+ 根布局。RexUI 的加载、Phaser 4.0 兼容和 Scene Plugin 生命周期属于 devkit 责任,游戏
81
+ 配置不重复注册。
82
+
57
83
  具体游戏的玩法测试仍由模板使用方放在 `tests/`。运行时测试从本开发工具包
58
84
  导入 `createHeadlessGame`;有限目标游戏通过合法 Telemetry controls 走到正常成功终局,
59
85
  无尽或沙盒游戏证明有意义的持续进展。短输入测试从入口操作走到一个主要操作,用真实 DOM
package/dist/cli/lint.js CHANGED
@@ -49,10 +49,41 @@ async function runContracts() {
49
49
  ["run", "--config", (0, import_node_path.join)(devkitRoot, "dist/lint/contracts.config.mjs")]
50
50
  );
51
51
  }
52
+ function checkGameViteConfig() {
53
+ const viteConfigPath = (0, import_node_path.join)(projectRoot, "vite.config.ts");
54
+ if (!(0, import_node_fs.existsSync)(viteConfigPath)) {
55
+ console.error(
56
+ "[vite-config] \u7F3A\u5C11\u6A21\u677F\u5FC5\u9700\u7684 vite.config.ts\u3002\u8BF7\u6062\u590D\u6A21\u677F\u539F\u6587\u4EF6\u540E\u91CD\u65B0\u8FD0\u884C pnpm lint\u3002"
57
+ );
58
+ return { name: "vite-config", ok: false };
59
+ }
60
+ const source = (0, import_node_fs.readFileSync)(viteConfigPath, "utf8");
61
+ const hasDevkitImport = /from\s+["']miaoda-game-devkit\/vite["']/.test(source);
62
+ const hasConfigCall = /defineGameViteConfig\s*\(/.test(source);
63
+ if (!hasDevkitImport || !hasConfigCall) {
64
+ console.error(
65
+ "[vite-config] vite.config.ts \u672A\u4F7F\u7528\u6A21\u677F\u89C4\u5B9A\u7684\u914D\u7F6E\u5165\u53E3\u3002\n\u8BF7\u4ECE miaoda-game-devkit/vite \u5BFC\u5165 defineGameViteConfig\uFF0C\u5E76\u7528 defineGameViteConfig({ ... }) \u5305\u4F4F\u5F53\u524D\u7684\u914D\u7F6E\u5BF9\u8C61\u3002\n\u53EA\u66FF\u6362\u914D\u7F6E\u51FD\u6570\uFF0C\u4FDD\u7559\u73B0\u6709 plugins\u3001resolve \u7B49\u5BF9\u8C61\u5185\u5BB9\u4E0D\u53D8\u3002"
66
+ );
67
+ return { name: "vite-config", ok: false };
68
+ }
69
+ return { name: "vite-config", ok: true };
70
+ }
71
+ function projectUsesPhaser4() {
72
+ const manifestPath = (0, import_node_path.join)(projectRoot, "package.json");
73
+ if (!(0, import_node_fs.existsSync)(manifestPath)) return false;
74
+ try {
75
+ const manifest = JSON.parse((0, import_node_fs.readFileSync)(manifestPath, "utf8"));
76
+ const version = manifest.dependencies?.phaser ?? manifest.devDependencies?.phaser;
77
+ return typeof version === "string" && /(?:^|[^0-9])4(?:\.|$)/.test(version);
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
52
82
  async function runAllChecks() {
53
83
  if (contractsOnly) {
54
84
  return [await runContracts()];
55
85
  }
86
+ const viteConfig = projectUsesPhaser4() ? [checkGameViteConfig()] : [];
56
87
  const [tsgo, biome] = await Promise.all([
57
88
  run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
58
89
  run("biome", "@biomejs/biome", "biome", [
@@ -76,7 +107,7 @@ async function runAllChecks() {
76
107
  "src"
77
108
  ]);
78
109
  const contracts = await runContracts();
79
- return [tsgo, biome, tailwind, oxlint, contracts];
110
+ return [...viteConfig, tsgo, biome, tailwind, oxlint, contracts];
80
111
  }
81
112
  async function main() {
82
113
  const results = await runAllChecks();
@@ -0,0 +1,11 @@
1
+ type GameFrameCallback = (timestamp: number) => void;
2
+ interface GameClock {
3
+ now(): number;
4
+ requestFrame(callback: GameFrameCallback): number;
5
+ cancelFrame(id: number): void;
6
+ setTimeout(callback: () => void, delayMs: number): number;
7
+ clearTimeout(id: number): void;
8
+ }
9
+ declare const browserGameClock: GameClock;
10
+
11
+ export { type GameClock as G, type GameFrameCallback as a, browserGameClock as b };
@@ -0,0 +1,11 @@
1
+ type GameFrameCallback = (timestamp: number) => void;
2
+ interface GameClock {
3
+ now(): number;
4
+ requestFrame(callback: GameFrameCallback): number;
5
+ cancelFrame(id: number): void;
6
+ setTimeout(callback: () => void, delayMs: number): number;
7
+ clearTimeout(id: number): void;
8
+ }
9
+ declare const browserGameClock: GameClock;
10
+
11
+ export { type GameClock as G, type GameFrameCallback as a, browserGameClock as b };
@@ -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 GameplayContractMetadata as G, type HeadlessGameHost as H, type HeadlessGameplayEvidenceRequirements as a, type GameplayAuditContract as b, type GameplayAuditOptions 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 GameplayContractMetadata as G, type HeadlessGameHost as H, type HeadlessGameplayEvidenceRequirements as a, type GameplayAuditContract as b, type GameplayAuditOptions 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 { G as GameplayContractMetadata, H as HeadlessGameHost, a as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-CCJo4Qhk.mjs';
2
- export { b as GameplayAuditContract, c 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-CCJo4Qhk.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 { G as GameplayContractMetadata, H as HeadlessGameHost, a as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-CCJo4Qhk.js';
2
- export { b as GameplayAuditContract, c 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-CCJo4Qhk.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
  };