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 +6 -0
- package/dist/{gameplay-audit-BwAobjIX.d.mts → gameplay-audit-DmbBA0M_.d.mts} +7 -1
- package/dist/{gameplay-audit-BwAobjIX.d.ts → gameplay-audit-DmbBA0M_.d.ts} +7 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +165 -4
- package/dist/index.mjs +165 -4
- package/dist/lint/game-telemetry.contract.mjs +165 -4
- package/dist/lint/gameplay-contract.contract.mjs +165 -4
- package/dist/lint/phaser-headless.contract.mjs +281 -4
- package/dist/lint/vitest-config.contract.mjs +141 -95
- package/dist/vite.js +13 -0
- package/dist/vite.mjs +13 -0
- package/dist/vitest-config.d.mts +2 -2
- package/dist/vitest-config.d.ts +2 -2
- package/dist/vitest-config.js +7 -1
- package/dist/vitest-config.mjs +7 -1
- package/package.json +1 -1
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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 {
|
|
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)
|
|
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 {
|
|
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)
|
|
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
|
};
|