miaoda-game-devkit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -12
- package/dist/gameplay-audit-CYqLL8gY.d.mts +193 -0
- package/dist/gameplay-audit-CYqLL8gY.d.ts +193 -0
- package/dist/index.d.mts +47 -83
- package/dist/index.d.ts +47 -83
- package/dist/index.js +481 -93
- package/dist/index.mjs +480 -93
- package/dist/lint/contracts.config.mjs +4 -2
- package/dist/lint/gameplay-audit.contract.mjs +426 -0
- package/dist/lint/gameplay-contract.contract.mjs +472 -94
- package/dist/lint/phaser-headless.contract.mjs +569 -92
- package/dist/lint/phaser-text-assertions.contract.mjs +3 -1
- package/dist/lint/setup.mjs +31 -34
- package/dist/lint/vitest-config.contract.mjs +368 -10
- package/dist/vitest-config.d.mts +6 -2
- package/dist/vitest-config.d.ts +6 -2
- package/dist/vitest-config.js +344 -5
- package/dist/vitest-config.mjs +342 -5
- package/package.json +2 -2
|
@@ -25,7 +25,9 @@ function isEffectivelyRenderable(label) {
|
|
|
25
25
|
}
|
|
26
26
|
function requireFinite(problems, label, property, value) {
|
|
27
27
|
if (!Number.isFinite(value)) {
|
|
28
|
-
problems.push(
|
|
28
|
+
problems.push(
|
|
29
|
+
`${describeText(label)} has non-finite ${property}: ${String(value)}`
|
|
30
|
+
);
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
function collectSceneTexts(scene) {
|
|
@@ -79,7 +81,9 @@ function collectMissingBitmapGlyphs(text, chars) {
|
|
|
79
81
|
const code = text.charCodeAt(index);
|
|
80
82
|
if (seen.has(code) || chars[code] !== void 0) continue;
|
|
81
83
|
seen.add(code);
|
|
82
|
-
missing.push(
|
|
84
|
+
missing.push(
|
|
85
|
+
`${JSON.stringify(character)} (U+${code.toString(16).toUpperCase().padStart(4, "0")})`
|
|
86
|
+
);
|
|
83
87
|
}
|
|
84
88
|
return missing;
|
|
85
89
|
}
|
|
@@ -104,19 +108,29 @@ function collectTextHealthProblems(scene) {
|
|
|
104
108
|
requireFinite(problems, label, "resolution", label.style.resolution);
|
|
105
109
|
const requiresPositiveSize = /\S/u.test(label.text) && isEffectivelyRenderable(label);
|
|
106
110
|
if (requiresPositiveSize && Number.isFinite(label.width) && label.width <= 0) {
|
|
107
|
-
problems.push(
|
|
111
|
+
problems.push(
|
|
112
|
+
`${describeText(label)} has non-positive width: ${label.width}`
|
|
113
|
+
);
|
|
108
114
|
}
|
|
109
115
|
if (requiresPositiveSize && Number.isFinite(label.height) && label.height <= 0) {
|
|
110
|
-
problems.push(
|
|
116
|
+
problems.push(
|
|
117
|
+
`${describeText(label)} has non-positive height: ${label.height}`
|
|
118
|
+
);
|
|
111
119
|
}
|
|
112
120
|
if (Number.isFinite(label.style.fixedWidth) && label.style.fixedWidth < 0) {
|
|
113
|
-
problems.push(
|
|
121
|
+
problems.push(
|
|
122
|
+
`${describeText(label)} has negative fixedWidth: ${label.style.fixedWidth}`
|
|
123
|
+
);
|
|
114
124
|
}
|
|
115
125
|
if (Number.isFinite(label.style.fixedHeight) && label.style.fixedHeight < 0) {
|
|
116
|
-
problems.push(
|
|
126
|
+
problems.push(
|
|
127
|
+
`${describeText(label)} has negative fixedHeight: ${label.style.fixedHeight}`
|
|
128
|
+
);
|
|
117
129
|
}
|
|
118
130
|
if (Number.isFinite(label.style.resolution) && label.style.resolution <= 0) {
|
|
119
|
-
problems.push(
|
|
131
|
+
problems.push(
|
|
132
|
+
`${describeText(label)} has non-positive resolution: ${label.style.resolution}`
|
|
133
|
+
);
|
|
120
134
|
}
|
|
121
135
|
const bounds = label.getBounds();
|
|
122
136
|
for (const [property, value] of Object.entries({
|
|
@@ -150,11 +164,13 @@ function assertSceneTextHealth(scene) {
|
|
|
150
164
|
...collectBitmapTextHealthProblems(scene)
|
|
151
165
|
];
|
|
152
166
|
if (problems.length === 0) return;
|
|
153
|
-
throw new Error(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
167
|
+
throw new Error(
|
|
168
|
+
[
|
|
169
|
+
`Phaser text health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
170
|
+
...problems.map((problem) => `- ${problem}`),
|
|
171
|
+
"Fix optional TextStyle values by omitting them or using Phaser defaults (for example fixedWidth: value ?? 0)."
|
|
172
|
+
].join("\n")
|
|
173
|
+
);
|
|
158
174
|
}
|
|
159
175
|
|
|
160
176
|
// src/phaser-runtime-assertions.ts
|
|
@@ -166,8 +182,10 @@ function collectObjects(scene) {
|
|
|
166
182
|
if (visited.has(object)) return;
|
|
167
183
|
visited.add(object);
|
|
168
184
|
objects.push(object);
|
|
169
|
-
if (object instanceof Phaser2.GameObjects.Container)
|
|
170
|
-
|
|
185
|
+
if (object instanceof Phaser2.GameObjects.Container)
|
|
186
|
+
object.list.forEach(visit);
|
|
187
|
+
if (object instanceof Phaser2.GameObjects.Layer)
|
|
188
|
+
object.getChildren().forEach(visit);
|
|
171
189
|
};
|
|
172
190
|
scene.children.getChildren().forEach(visit);
|
|
173
191
|
return objects;
|
|
@@ -201,7 +219,9 @@ function requireFiniteVector(problems, subject, target, property) {
|
|
|
201
219
|
if (!(axis in vector)) continue;
|
|
202
220
|
const value = Reflect.get(vector, axis);
|
|
203
221
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
204
|
-
problems.push(
|
|
222
|
+
problems.push(
|
|
223
|
+
`${subject} has non-finite ${property}.${axis}: ${String(value)}`
|
|
224
|
+
);
|
|
205
225
|
}
|
|
206
226
|
}
|
|
207
227
|
}
|
|
@@ -269,7 +289,9 @@ function getArcadeBodyCollection(value) {
|
|
|
269
289
|
function getArcadeWorldBodies(world) {
|
|
270
290
|
if (!world || typeof world !== "object") return void 0;
|
|
271
291
|
const bodies = getArcadeBodyCollection(Reflect.get(world, "bodies"));
|
|
272
|
-
const staticBodies = getArcadeBodyCollection(
|
|
292
|
+
const staticBodies = getArcadeBodyCollection(
|
|
293
|
+
Reflect.get(world, "staticBodies")
|
|
294
|
+
);
|
|
273
295
|
return bodies && staticBodies ? [...bodies, ...staticBodies] : void 0;
|
|
274
296
|
}
|
|
275
297
|
function collectArcadeBodyHealthProblems(scene) {
|
|
@@ -280,22 +302,38 @@ function collectArcadeBodyHealthProblems(scene) {
|
|
|
280
302
|
if (body.enable === false) continue;
|
|
281
303
|
const gameObject = body.gameObject;
|
|
282
304
|
const subject = gameObject ? `Arcade Body for ${describeObject(gameObject)}` : "Arcade Body";
|
|
283
|
-
for (const property of [
|
|
305
|
+
for (const property of [
|
|
306
|
+
"position",
|
|
307
|
+
"velocity",
|
|
308
|
+
"acceleration",
|
|
309
|
+
"gravity",
|
|
310
|
+
"offset",
|
|
311
|
+
"center"
|
|
312
|
+
]) {
|
|
284
313
|
requireFiniteVector(problems, subject, body, property);
|
|
285
314
|
}
|
|
286
|
-
for (const property of [
|
|
315
|
+
for (const property of [
|
|
316
|
+
"width",
|
|
317
|
+
"height",
|
|
318
|
+
"halfWidth",
|
|
319
|
+
"halfHeight",
|
|
320
|
+
"rotation"
|
|
321
|
+
]) {
|
|
287
322
|
requireFiniteProperty(problems, subject, body, property);
|
|
288
323
|
}
|
|
289
324
|
}
|
|
290
325
|
return problems;
|
|
291
326
|
}
|
|
292
327
|
function usesCustomHitArea(object) {
|
|
293
|
-
return Boolean(
|
|
328
|
+
return Boolean(
|
|
329
|
+
object.input?.customHitArea
|
|
330
|
+
);
|
|
294
331
|
}
|
|
295
332
|
function collectInteractiveHealthProblems(scene) {
|
|
296
333
|
const problems = [];
|
|
297
334
|
for (const object of collectObjects(scene)) {
|
|
298
|
-
if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled)
|
|
335
|
+
if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled)
|
|
336
|
+
continue;
|
|
299
337
|
if (usesCustomHitArea(object)) continue;
|
|
300
338
|
const hitArea = object.input.hitArea;
|
|
301
339
|
if (!hitArea) {
|
|
@@ -318,20 +356,24 @@ function assertSceneRuntimeHealth(scene) {
|
|
|
318
356
|
...collectArcadeBodyHealthProblems(scene)
|
|
319
357
|
];
|
|
320
358
|
if (problems.length === 0) return;
|
|
321
|
-
throw new Error(
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
359
|
+
throw new Error(
|
|
360
|
+
[
|
|
361
|
+
`Phaser runtime health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
362
|
+
...problems.map((problem) => `- ${problem}`),
|
|
363
|
+
"Keep values consumed by Phaser finite; zero size and zero scale remain allowed."
|
|
364
|
+
].join("\n")
|
|
365
|
+
);
|
|
326
366
|
}
|
|
327
367
|
function assertSceneInteractiveHealth(scene) {
|
|
328
368
|
const problems = collectInteractiveHealthProblems(scene);
|
|
329
369
|
if (problems.length === 0) return;
|
|
330
|
-
throw new Error(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
370
|
+
throw new Error(
|
|
371
|
+
[
|
|
372
|
+
`Phaser interactive health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
373
|
+
...problems.map((problem) => `- ${problem}`),
|
|
374
|
+
"Fix the default hit area or disable input before making the object interactive."
|
|
375
|
+
].join("\n")
|
|
376
|
+
);
|
|
335
377
|
}
|
|
336
378
|
|
|
337
379
|
// src/phaser-headless-host.ts
|
|
@@ -346,11 +388,32 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
346
388
|
let removeRuntimeListeners = () => {
|
|
347
389
|
};
|
|
348
390
|
const transitions = [];
|
|
391
|
+
const registeredScenes = [];
|
|
392
|
+
const visitedScenes = [];
|
|
393
|
+
const restartedScenes = [];
|
|
394
|
+
const checkpoints = [];
|
|
395
|
+
const appendUnique = (values, value) => {
|
|
396
|
+
if (value && !values.includes(value)) values.push(value);
|
|
397
|
+
};
|
|
398
|
+
const assertStepCount = (value, name) => {
|
|
399
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`${name} must be a non-negative safe integer; received ${value}`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
349
405
|
const evidence = {
|
|
350
406
|
frames: 0,
|
|
351
407
|
physicsSteps: 0,
|
|
352
408
|
mouseEvents: 0,
|
|
409
|
+
keyboardEvents: 0,
|
|
410
|
+
touchEvents: 0,
|
|
353
411
|
clicks: 0,
|
|
412
|
+
registeredScenes,
|
|
413
|
+
visitedScenes,
|
|
414
|
+
restartedScenes,
|
|
415
|
+
checkpoints,
|
|
416
|
+
destroyed: false,
|
|
354
417
|
transitions
|
|
355
418
|
};
|
|
356
419
|
const created = new Promise((resolve, reject) => {
|
|
@@ -396,7 +459,9 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
396
459
|
});
|
|
397
460
|
};
|
|
398
461
|
const timeout = window.setTimeout(() => {
|
|
399
|
-
finish(
|
|
462
|
+
finish(
|
|
463
|
+
new Error(`Phaser HEADLESS boot timed out after ${bootTimeoutMs}ms`)
|
|
464
|
+
);
|
|
400
465
|
}, bootTimeoutMs);
|
|
401
466
|
window.addEventListener("error", onWindowError, true);
|
|
402
467
|
window.addEventListener("unhandledrejection", onUnhandledRejection);
|
|
@@ -408,7 +473,13 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
408
473
|
if (guardedScenes.has(currentScene)) return;
|
|
409
474
|
guardedScenes.add(currentScene);
|
|
410
475
|
const scenePlugin = currentScene.scene;
|
|
411
|
-
for (const method of [
|
|
476
|
+
for (const method of [
|
|
477
|
+
"start",
|
|
478
|
+
"launch",
|
|
479
|
+
"switch",
|
|
480
|
+
"sleep",
|
|
481
|
+
"wake"
|
|
482
|
+
]) {
|
|
412
483
|
const original = scenePlugin[method];
|
|
413
484
|
if (typeof original !== "function") continue;
|
|
414
485
|
scenePlugin[method] = (key, ...args) => {
|
|
@@ -424,29 +495,49 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
424
495
|
scenePlugin[method] = original;
|
|
425
496
|
});
|
|
426
497
|
}
|
|
498
|
+
const originalRestart = scenePlugin.restart;
|
|
499
|
+
if (typeof originalRestart === "function") {
|
|
500
|
+
scenePlugin.restart = (...args) => {
|
|
501
|
+
appendUnique(restartedScenes, currentScene.sys.settings.key);
|
|
502
|
+
return Reflect.apply(originalRestart, currentScene.scene, args);
|
|
503
|
+
};
|
|
504
|
+
restoreGuards.push(() => {
|
|
505
|
+
scenePlugin.restart = originalRestart;
|
|
506
|
+
});
|
|
507
|
+
}
|
|
427
508
|
for (const hook of ["init", "preload", "create", "update"]) {
|
|
428
509
|
const original = Reflect.get(currentScene, hook);
|
|
429
510
|
if (typeof original !== "function") continue;
|
|
430
|
-
const ownDescriptor = Object.getOwnPropertyDescriptor(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
511
|
+
const ownDescriptor = Object.getOwnPropertyDescriptor(
|
|
512
|
+
currentScene,
|
|
513
|
+
hook
|
|
514
|
+
);
|
|
515
|
+
Reflect.set(
|
|
516
|
+
currentScene,
|
|
517
|
+
hook,
|
|
518
|
+
function guardedLifecycleHook(...args) {
|
|
519
|
+
try {
|
|
520
|
+
const result = Reflect.apply(original, this, args);
|
|
521
|
+
if (result && typeof result === "object" && "then" in result) {
|
|
522
|
+
const completion = Promise.resolve(result);
|
|
523
|
+
if (currentScene === scene && hook === "create" && !settled) {
|
|
524
|
+
completion.then(
|
|
525
|
+
() => window.setTimeout(() => finish(), 0),
|
|
526
|
+
reportError
|
|
527
|
+
);
|
|
528
|
+
} else {
|
|
529
|
+
completion.catch(reportError);
|
|
530
|
+
}
|
|
531
|
+
} else if (currentScene === scene && hook === "create" && !settled) {
|
|
532
|
+
window.setTimeout(() => finish(), 0);
|
|
440
533
|
}
|
|
441
|
-
|
|
442
|
-
|
|
534
|
+
return result;
|
|
535
|
+
} catch (error) {
|
|
536
|
+
reportError(error);
|
|
537
|
+
return void 0;
|
|
443
538
|
}
|
|
444
|
-
return result;
|
|
445
|
-
} catch (error) {
|
|
446
|
-
reportError(error);
|
|
447
|
-
return void 0;
|
|
448
539
|
}
|
|
449
|
-
|
|
540
|
+
);
|
|
450
541
|
restoreGuards.push(() => {
|
|
451
542
|
if (ownDescriptor) {
|
|
452
543
|
Object.defineProperty(currentScene, hook, ownDescriptor);
|
|
@@ -475,13 +566,6 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
475
566
|
banner: false,
|
|
476
567
|
autoFocus: false,
|
|
477
568
|
seed: ["phaser-headless-test"],
|
|
478
|
-
physics: {
|
|
479
|
-
default: "arcade",
|
|
480
|
-
arcade: {
|
|
481
|
-
gravity: { x: 0, y: 0 },
|
|
482
|
-
fixedStep: true
|
|
483
|
-
}
|
|
484
|
-
},
|
|
485
569
|
...config,
|
|
486
570
|
type: Phaser3.HEADLESS,
|
|
487
571
|
fps,
|
|
@@ -507,7 +591,10 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
507
591
|
};
|
|
508
592
|
bootedGame.events.on(Phaser3.Core.Events.POST_STEP, checkReadiness);
|
|
509
593
|
removeReadinessCheck = () => {
|
|
510
|
-
bootedGame.events.off(
|
|
594
|
+
bootedGame.events.off(
|
|
595
|
+
Phaser3.Core.Events.POST_STEP,
|
|
596
|
+
checkReadiness
|
|
597
|
+
);
|
|
511
598
|
};
|
|
512
599
|
}
|
|
513
600
|
},
|
|
@@ -547,7 +634,9 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
547
634
|
restoreHostGuards();
|
|
548
635
|
readyGame.destroy(true);
|
|
549
636
|
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
550
|
-
throw new Error(
|
|
637
|
+
throw new Error(
|
|
638
|
+
"Phaser HEADLESS input canvas is not attached to a DOM Window"
|
|
639
|
+
);
|
|
551
640
|
}
|
|
552
641
|
let canvasBounds = {
|
|
553
642
|
left: 0,
|
|
@@ -583,7 +672,9 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
583
672
|
setCanvasBounds(canvasBounds);
|
|
584
673
|
const gameToClient = (x, y) => {
|
|
585
674
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
586
|
-
throw new Error(
|
|
675
|
+
throw new Error(
|
|
676
|
+
`HEADLESS input coordinates must be finite; received (${x}, ${y})`
|
|
677
|
+
);
|
|
587
678
|
}
|
|
588
679
|
return {
|
|
589
680
|
clientX: canvasBounds.left + x / readyGame.scale.displayScale.x,
|
|
@@ -592,7 +683,10 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
592
683
|
};
|
|
593
684
|
const assertHealth = () => {
|
|
594
685
|
for (const currentScene of readyGame.scene.getScenes(false)) {
|
|
595
|
-
|
|
686
|
+
appendUnique(registeredScenes, currentScene.sys.settings.key);
|
|
687
|
+
if (!currentScene.sys.isActive() && !currentScene.sys.isPaused())
|
|
688
|
+
continue;
|
|
689
|
+
appendUnique(visitedScenes, currentScene.sys.settings.key);
|
|
596
690
|
assertSceneTextHealth(currentScene);
|
|
597
691
|
assertSceneInteractiveHealth(currentScene);
|
|
598
692
|
assertSceneRuntimeHealth(currentScene);
|
|
@@ -603,17 +697,166 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
603
697
|
throwRuntimeError();
|
|
604
698
|
assertHealth();
|
|
605
699
|
};
|
|
700
|
+
const getEventTarget = (target, kind) => {
|
|
701
|
+
if (!target || typeof Reflect.get(target, "dispatchEvent") !== "function") {
|
|
702
|
+
throw new Error(
|
|
703
|
+
`Phaser HEADLESS ${kind} input target cannot dispatch DOM events`
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
return target;
|
|
707
|
+
};
|
|
606
708
|
const dispatchMouse = async (type, x, y, buttons) => {
|
|
607
709
|
const { clientX, clientY } = gameToClient(x, y);
|
|
608
|
-
|
|
710
|
+
const mouseTarget = getEventTarget(readyGame.input.mouse?.target, "mouse");
|
|
711
|
+
mouseTarget.dispatchEvent(
|
|
712
|
+
new inputWindow.MouseEvent(type, {
|
|
713
|
+
bubbles: true,
|
|
714
|
+
cancelable: true,
|
|
715
|
+
button: 0,
|
|
716
|
+
buttons,
|
|
717
|
+
clientX,
|
|
718
|
+
clientY
|
|
719
|
+
})
|
|
720
|
+
);
|
|
721
|
+
evidence.mouseEvents += 1;
|
|
722
|
+
await settleRuntime();
|
|
723
|
+
};
|
|
724
|
+
const keyIdentity = (keyCode) => {
|
|
725
|
+
if (keyCode >= 65 && keyCode <= 90) {
|
|
726
|
+
const letter = String.fromCharCode(keyCode);
|
|
727
|
+
return { key: letter.toLowerCase(), code: `Key${letter}` };
|
|
728
|
+
}
|
|
729
|
+
if (keyCode >= 48 && keyCode <= 57) {
|
|
730
|
+
const digit = String.fromCharCode(keyCode);
|
|
731
|
+
return { key: digit, code: `Digit${digit}` };
|
|
732
|
+
}
|
|
733
|
+
const identities = /* @__PURE__ */ new Map([
|
|
734
|
+
[
|
|
735
|
+
Phaser3.Input.Keyboard.KeyCodes.LEFT,
|
|
736
|
+
{ key: "ArrowLeft", code: "ArrowLeft" }
|
|
737
|
+
],
|
|
738
|
+
[
|
|
739
|
+
Phaser3.Input.Keyboard.KeyCodes.RIGHT,
|
|
740
|
+
{ key: "ArrowRight", code: "ArrowRight" }
|
|
741
|
+
],
|
|
742
|
+
[Phaser3.Input.Keyboard.KeyCodes.UP, { key: "ArrowUp", code: "ArrowUp" }],
|
|
743
|
+
[
|
|
744
|
+
Phaser3.Input.Keyboard.KeyCodes.DOWN,
|
|
745
|
+
{ key: "ArrowDown", code: "ArrowDown" }
|
|
746
|
+
],
|
|
747
|
+
[Phaser3.Input.Keyboard.KeyCodes.SPACE, { key: " ", code: "Space" }],
|
|
748
|
+
[Phaser3.Input.Keyboard.KeyCodes.ENTER, { key: "Enter", code: "Enter" }],
|
|
749
|
+
[Phaser3.Input.Keyboard.KeyCodes.ESC, { key: "Escape", code: "Escape" }]
|
|
750
|
+
]);
|
|
751
|
+
return identities.get(keyCode) ?? { key: "", code: "" };
|
|
752
|
+
};
|
|
753
|
+
const dispatchKeyboard = async (type, keyCode, options2 = {}) => {
|
|
754
|
+
if (!Number.isInteger(keyCode) || keyCode < 0) {
|
|
755
|
+
throw new Error(
|
|
756
|
+
`HEADLESS keyboard keyCode must be a non-negative integer; received ${keyCode}`
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
const identity = keyIdentity(keyCode);
|
|
760
|
+
const event = new inputWindow.KeyboardEvent(type, {
|
|
609
761
|
bubbles: true,
|
|
610
762
|
cancelable: true,
|
|
611
|
-
|
|
612
|
-
|
|
763
|
+
key: options2.key ?? identity.key,
|
|
764
|
+
code: options2.code ?? identity.code,
|
|
765
|
+
repeat: options2.repeat ?? false,
|
|
766
|
+
altKey: options2.altKey ?? false,
|
|
767
|
+
ctrlKey: options2.ctrlKey ?? false,
|
|
768
|
+
metaKey: options2.metaKey ?? false,
|
|
769
|
+
shiftKey: options2.shiftKey ?? false
|
|
770
|
+
});
|
|
771
|
+
Object.defineProperties(event, {
|
|
772
|
+
// Phaser KeyboardManager 仍使用这些兼容旧浏览器的数字字段。
|
|
773
|
+
keyCode: { value: keyCode },
|
|
774
|
+
which: { value: keyCode }
|
|
775
|
+
});
|
|
776
|
+
const keyboardTarget = getEventTarget(
|
|
777
|
+
readyGame.input.keyboard?.target,
|
|
778
|
+
"keyboard"
|
|
779
|
+
);
|
|
780
|
+
keyboardTarget.dispatchEvent(event);
|
|
781
|
+
evidence.keyboardEvents += 1;
|
|
782
|
+
await settleRuntime();
|
|
783
|
+
};
|
|
784
|
+
const activeTouches = /* @__PURE__ */ new Map();
|
|
785
|
+
const createTouch = (x, y, identifier, target) => {
|
|
786
|
+
if (!Number.isInteger(identifier) || identifier < 0) {
|
|
787
|
+
throw new Error(
|
|
788
|
+
`HEADLESS touch identifier must be a non-negative integer; received ${identifier}`
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
const { clientX, clientY } = gameToClient(x, y);
|
|
792
|
+
return {
|
|
793
|
+
identifier,
|
|
794
|
+
target,
|
|
613
795
|
clientX,
|
|
614
|
-
clientY
|
|
615
|
-
|
|
616
|
-
|
|
796
|
+
clientY,
|
|
797
|
+
pageX: clientX,
|
|
798
|
+
pageY: clientY,
|
|
799
|
+
screenX: clientX,
|
|
800
|
+
screenY: clientY,
|
|
801
|
+
radiusX: 1,
|
|
802
|
+
radiusY: 1,
|
|
803
|
+
rotationAngle: 0,
|
|
804
|
+
force: 1
|
|
805
|
+
};
|
|
806
|
+
};
|
|
807
|
+
const dispatchTouch = async (type, x, y, identifier) => {
|
|
808
|
+
if (type === "touchstart" && activeTouches.has(identifier)) {
|
|
809
|
+
throw new Error(
|
|
810
|
+
`HEADLESS touch identifier ${identifier} is already active`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
if (type !== "touchstart" && !activeTouches.has(identifier)) {
|
|
814
|
+
throw new Error(`HEADLESS touch identifier ${identifier} is not active`);
|
|
815
|
+
}
|
|
816
|
+
const touchTarget = getEventTarget(readyGame.input.touch?.target, "touch");
|
|
817
|
+
const changedTouch = createTouch(x, y, identifier, touchTarget);
|
|
818
|
+
if (type === "touchstart" || type === "touchmove") {
|
|
819
|
+
activeTouches.set(identifier, changedTouch);
|
|
820
|
+
} else {
|
|
821
|
+
activeTouches.delete(identifier);
|
|
822
|
+
}
|
|
823
|
+
const touches = [...activeTouches.values()];
|
|
824
|
+
const event = new inputWindow.Event(type, {
|
|
825
|
+
bubbles: true,
|
|
826
|
+
cancelable: true
|
|
827
|
+
});
|
|
828
|
+
Object.defineProperties(event, {
|
|
829
|
+
changedTouches: { value: [changedTouch] },
|
|
830
|
+
targetTouches: { value: touches },
|
|
831
|
+
touches: { value: touches }
|
|
832
|
+
});
|
|
833
|
+
const document = inputWindow.document;
|
|
834
|
+
const originalElementFromPoint = document.elementFromPoint;
|
|
835
|
+
const originalElementFromPointDescriptor = Object.getOwnPropertyDescriptor(
|
|
836
|
+
document,
|
|
837
|
+
"elementFromPoint"
|
|
838
|
+
);
|
|
839
|
+
Object.defineProperty(document, "elementFromPoint", {
|
|
840
|
+
configurable: true,
|
|
841
|
+
value(clientX, clientY) {
|
|
842
|
+
const insideCanvas = clientX >= canvasBounds.left && clientX <= canvasBounds.left + canvasBounds.width && clientY >= canvasBounds.top && clientY <= canvasBounds.top + canvasBounds.height;
|
|
843
|
+
return insideCanvas ? canvas : originalElementFromPoint?.call(document, clientX, clientY) ?? null;
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
try {
|
|
847
|
+
touchTarget.dispatchEvent(event);
|
|
848
|
+
evidence.touchEvents += 1;
|
|
849
|
+
} finally {
|
|
850
|
+
if (originalElementFromPointDescriptor) {
|
|
851
|
+
Object.defineProperty(
|
|
852
|
+
document,
|
|
853
|
+
"elementFromPoint",
|
|
854
|
+
originalElementFromPointDescriptor
|
|
855
|
+
);
|
|
856
|
+
} else {
|
|
857
|
+
Reflect.deleteProperty(document, "elementFromPoint");
|
|
858
|
+
}
|
|
859
|
+
}
|
|
617
860
|
await settleRuntime();
|
|
618
861
|
};
|
|
619
862
|
const input = {
|
|
@@ -631,6 +874,24 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
631
874
|
await dispatchMouse("mousedown", x, y, 1);
|
|
632
875
|
await dispatchMouse("mouseup", x, y, 0);
|
|
633
876
|
}
|
|
877
|
+
},
|
|
878
|
+
keyboard: {
|
|
879
|
+
down: (keyCode, options2) => dispatchKeyboard("keydown", keyCode, options2),
|
|
880
|
+
up: (keyCode, options2) => dispatchKeyboard("keyup", keyCode, options2),
|
|
881
|
+
async press(keyCode, options2) {
|
|
882
|
+
await dispatchKeyboard("keydown", keyCode, options2);
|
|
883
|
+
await dispatchKeyboard("keyup", keyCode, options2);
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
touch: {
|
|
887
|
+
start: (x, y, identifier = 1) => dispatchTouch("touchstart", x, y, identifier),
|
|
888
|
+
move: (x, y, identifier = 1) => dispatchTouch("touchmove", x, y, identifier),
|
|
889
|
+
end: (x, y, identifier = 1) => dispatchTouch("touchend", x, y, identifier),
|
|
890
|
+
cancel: (x, y, identifier = 1) => dispatchTouch("touchcancel", x, y, identifier),
|
|
891
|
+
async tap(x, y, identifier = 1) {
|
|
892
|
+
await dispatchTouch("touchstart", x, y, identifier);
|
|
893
|
+
await dispatchTouch("touchend", x, y, identifier);
|
|
894
|
+
}
|
|
634
895
|
}
|
|
635
896
|
};
|
|
636
897
|
const frameDurationMs = 1e3 / (config.fps?.target ?? 60);
|
|
@@ -639,6 +900,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
639
900
|
evidence.frames += 1;
|
|
640
901
|
};
|
|
641
902
|
const stepFrames = (count = 1) => {
|
|
903
|
+
assertStepCount(count, "stepFrames count");
|
|
642
904
|
throwRuntimeError();
|
|
643
905
|
for (let frame = 0; frame < count; frame += 1) stepFrame();
|
|
644
906
|
throwRuntimeError();
|
|
@@ -662,21 +924,59 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
662
924
|
input,
|
|
663
925
|
evidence,
|
|
664
926
|
assertGameplayEvidence(requirements = {}) {
|
|
665
|
-
|
|
666
|
-
|
|
927
|
+
const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
|
|
928
|
+
if (requirements.requireInput && inputEvents === 0) {
|
|
929
|
+
throw new Error(
|
|
930
|
+
"Gameplay evidence is missing real HEADLESS input events."
|
|
931
|
+
);
|
|
667
932
|
}
|
|
668
933
|
if (requirements.requireFrameAdvance && evidence.frames === 0) {
|
|
669
|
-
throw new Error(
|
|
934
|
+
throw new Error(
|
|
935
|
+
"Gameplay evidence is missing complete Phaser frame advancement."
|
|
936
|
+
);
|
|
670
937
|
}
|
|
671
938
|
if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
|
|
672
|
-
throw new Error(
|
|
939
|
+
throw new Error(
|
|
940
|
+
"Gameplay evidence is missing Arcade Physics advancement."
|
|
941
|
+
);
|
|
673
942
|
}
|
|
674
943
|
const requiredTransitions = requirements.requireTransition === void 0 ? [] : typeof requirements.requireTransition === "string" ? [requirements.requireTransition] : requirements.requireTransition;
|
|
675
944
|
for (const target of requiredTransitions) {
|
|
676
945
|
if (!evidence.transitions.some((transition) => transition.to === target)) {
|
|
677
|
-
throw new Error(
|
|
946
|
+
throw new Error(
|
|
947
|
+
`Gameplay evidence is missing a transition to ${target}.`
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const requiredScenes = requirements.requireScene === void 0 ? [] : typeof requirements.requireScene === "string" ? [requirements.requireScene] : requirements.requireScene;
|
|
952
|
+
for (const sceneKey of requiredScenes) {
|
|
953
|
+
if (!evidence.visitedScenes.includes(sceneKey)) {
|
|
954
|
+
throw new Error(
|
|
955
|
+
`Gameplay evidence is missing a visit to Scene ${sceneKey}.`
|
|
956
|
+
);
|
|
678
957
|
}
|
|
679
958
|
}
|
|
959
|
+
const requiredRestarts = requirements.requireRestart === void 0 ? [] : typeof requirements.requireRestart === "string" ? [requirements.requireRestart] : requirements.requireRestart;
|
|
960
|
+
for (const sceneKey of requiredRestarts) {
|
|
961
|
+
if (!evidence.restartedScenes.includes(sceneKey)) {
|
|
962
|
+
throw new Error(
|
|
963
|
+
`Gameplay evidence is missing a restart of Scene ${sceneKey}.`
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const requiredCheckpoints = requirements.requireCheckpoint === void 0 ? [] : typeof requirements.requireCheckpoint === "string" ? [requirements.requireCheckpoint] : requirements.requireCheckpoint;
|
|
968
|
+
for (const checkpoint of requiredCheckpoints) {
|
|
969
|
+
if (!evidence.checkpoints.includes(checkpoint)) {
|
|
970
|
+
throw new Error(
|
|
971
|
+
`Gameplay evidence is missing checkpoint ${checkpoint}.`
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (requirements.requireDestroy && !evidence.destroyed) {
|
|
976
|
+
throw new Error(
|
|
977
|
+
"Gameplay evidence is missing HEADLESS host destruction."
|
|
978
|
+
);
|
|
979
|
+
}
|
|
680
980
|
},
|
|
681
981
|
stepFrames,
|
|
682
982
|
async stepFramesAsync(count = 1) {
|
|
@@ -684,6 +984,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
684
984
|
await settleRuntime();
|
|
685
985
|
},
|
|
686
986
|
stepUntil(condition, maxFrames = 120) {
|
|
987
|
+
assertStepCount(maxFrames, "stepUntil maxFrames");
|
|
687
988
|
throwRuntimeError();
|
|
688
989
|
for (let frame = 0; frame <= maxFrames; frame += 1) {
|
|
689
990
|
if (condition()) {
|
|
@@ -696,13 +997,18 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
696
997
|
}
|
|
697
998
|
}
|
|
698
999
|
assertHealth();
|
|
699
|
-
throw new Error(
|
|
1000
|
+
throw new Error(
|
|
1001
|
+
`Condition was not met within ${maxFrames} complete Phaser frames`
|
|
1002
|
+
);
|
|
700
1003
|
},
|
|
701
1004
|
stepPhysics(steps = 1) {
|
|
1005
|
+
assertStepCount(steps, "stepPhysics steps");
|
|
702
1006
|
throwRuntimeError();
|
|
703
1007
|
const world = scene.physics?.world;
|
|
704
1008
|
if (!world || !getArcadeWorldBodies(world) || typeof world.singleStep !== "function") {
|
|
705
|
-
throw new Error(
|
|
1009
|
+
throw new Error(
|
|
1010
|
+
"stepPhysics() requires an Arcade Physics world; use stepFrames() for other configurations."
|
|
1011
|
+
);
|
|
706
1012
|
}
|
|
707
1013
|
for (let step = 0; step < steps; step += 1) {
|
|
708
1014
|
scene.physics.world.singleStep();
|
|
@@ -711,6 +1017,15 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
711
1017
|
throwRuntimeError();
|
|
712
1018
|
assertHealth();
|
|
713
1019
|
},
|
|
1020
|
+
checkpoint(id) {
|
|
1021
|
+
const normalizedId = id.trim();
|
|
1022
|
+
if (!normalizedId || normalizedId.length > 120) {
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
"Gameplay checkpoint id must contain 1 to 120 non-whitespace characters."
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
appendUnique(checkpoints, normalizedId);
|
|
1028
|
+
},
|
|
714
1029
|
assertTextHealth() {
|
|
715
1030
|
assertSceneTextHealth(scene);
|
|
716
1031
|
},
|
|
@@ -723,6 +1038,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
723
1038
|
readyGame.destroy(true);
|
|
724
1039
|
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
725
1040
|
throwRuntimeError();
|
|
1041
|
+
evidence.destroyed = true;
|
|
726
1042
|
} finally {
|
|
727
1043
|
removeRuntimeListeners();
|
|
728
1044
|
restoreHostGuards();
|
|
@@ -744,9 +1060,11 @@ var SmokeScene = class extends Phaser4.Scene {
|
|
|
744
1060
|
this.randomValue = Phaser4.Math.RND.frac();
|
|
745
1061
|
this.movingObject = this.add.zone(40, 50, 16, 16);
|
|
746
1062
|
this.textObject = this.add.text(8, 8, "READY", { fontSize: "16px" });
|
|
747
|
-
this.physics
|
|
748
|
-
|
|
749
|
-
|
|
1063
|
+
if (this.physics) {
|
|
1064
|
+
this.physics.add.existing(this.movingObject);
|
|
1065
|
+
const body = this.movingObject.body;
|
|
1066
|
+
body.setVelocityX(60);
|
|
1067
|
+
}
|
|
750
1068
|
}
|
|
751
1069
|
update() {
|
|
752
1070
|
this.updates += 1;
|
|
@@ -767,6 +1085,38 @@ var LayeredInputScene = class extends Phaser4.Scene {
|
|
|
767
1085
|
});
|
|
768
1086
|
}
|
|
769
1087
|
};
|
|
1088
|
+
var KeyboardInputScene = class extends Phaser4.Scene {
|
|
1089
|
+
playerX = 0;
|
|
1090
|
+
rightKey;
|
|
1091
|
+
constructor() {
|
|
1092
|
+
super("KeyboardInputScene");
|
|
1093
|
+
}
|
|
1094
|
+
create() {
|
|
1095
|
+
this.rightKey = this.input.keyboard.addKey(
|
|
1096
|
+
Phaser4.Input.Keyboard.KeyCodes.RIGHT
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
update() {
|
|
1100
|
+
if (this.rightKey.isDown) this.playerX += 1;
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
var TouchInputScene = class extends Phaser4.Scene {
|
|
1104
|
+
moves = 0;
|
|
1105
|
+
taps = 0;
|
|
1106
|
+
wasTouch = false;
|
|
1107
|
+
constructor() {
|
|
1108
|
+
super("TouchInputScene");
|
|
1109
|
+
}
|
|
1110
|
+
create() {
|
|
1111
|
+
this.add.zone(80, 60, 40, 40).setInteractive().on("pointerup", (pointer) => {
|
|
1112
|
+
this.taps += 1;
|
|
1113
|
+
this.wasTouch = pointer.wasTouch;
|
|
1114
|
+
});
|
|
1115
|
+
this.input.on("pointermove", (pointer) => {
|
|
1116
|
+
if (pointer.wasTouch) this.moves += 1;
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
770
1120
|
describe("Phaser HEADLESS host", () => {
|
|
771
1121
|
let host;
|
|
772
1122
|
afterEach(() => {
|
|
@@ -774,7 +1124,12 @@ describe("Phaser HEADLESS host", () => {
|
|
|
774
1124
|
host = void 0;
|
|
775
1125
|
});
|
|
776
1126
|
it("boots a scene without a renderer and advances Arcade Physics", async () => {
|
|
777
|
-
host = await createHeadlessGame(new SmokeScene()
|
|
1127
|
+
host = await createHeadlessGame(new SmokeScene(), {
|
|
1128
|
+
physics: {
|
|
1129
|
+
default: "arcade",
|
|
1130
|
+
arcade: { gravity: { x: 0, y: 0 }, fixedStep: true }
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
778
1133
|
expect(host.game.renderer).toBeNull();
|
|
779
1134
|
expect(host.scene.sys.isActive()).toBe(true);
|
|
780
1135
|
const initialX = host.scene.movingObject.x;
|
|
@@ -786,14 +1141,35 @@ describe("Phaser HEADLESS host", () => {
|
|
|
786
1141
|
host.stepFrames(3);
|
|
787
1142
|
expect(host.scene.updates).toBe(initialUpdates + 3);
|
|
788
1143
|
});
|
|
1144
|
+
it("rejects invalid advancement counts instead of entering an unbounded loop", async () => {
|
|
1145
|
+
const boundedHost = await createHeadlessGame(new LayeredInputScene());
|
|
1146
|
+
expect(() => boundedHost.stepFrames(Number.POSITIVE_INFINITY)).toThrow(
|
|
1147
|
+
"stepFrames count must be a non-negative safe integer"
|
|
1148
|
+
);
|
|
1149
|
+
expect(() => boundedHost.stepUntil(() => false, 1.5)).toThrow(
|
|
1150
|
+
"stepUntil maxFrames must be a non-negative safe integer"
|
|
1151
|
+
);
|
|
1152
|
+
expect(() => boundedHost.stepPhysics(-1)).toThrow(
|
|
1153
|
+
"stepPhysics steps must be a non-negative safe integer"
|
|
1154
|
+
);
|
|
1155
|
+
boundedHost.destroy();
|
|
1156
|
+
});
|
|
789
1157
|
it("drives scaled DOM mouse input through hit testing and top-only dispatch", async () => {
|
|
790
1158
|
const inputHost = await createHeadlessGame(new LayeredInputScene(), {
|
|
791
1159
|
width: 160,
|
|
792
1160
|
height: 120
|
|
793
1161
|
});
|
|
794
1162
|
inputHost.stepFrames();
|
|
795
|
-
inputHost.input.setCanvasBounds({
|
|
796
|
-
|
|
1163
|
+
inputHost.input.setCanvasBounds({
|
|
1164
|
+
left: 20,
|
|
1165
|
+
top: 30,
|
|
1166
|
+
width: 80,
|
|
1167
|
+
height: 60
|
|
1168
|
+
});
|
|
1169
|
+
expect(inputHost.input.gameToClient(80, 60)).toEqual({
|
|
1170
|
+
clientX: 60,
|
|
1171
|
+
clientY: 60
|
|
1172
|
+
});
|
|
797
1173
|
await inputHost.input.mouse.click(80, 60);
|
|
798
1174
|
expect({
|
|
799
1175
|
pointerX: inputHost.scene.input.activePointer.x,
|
|
@@ -813,6 +1189,68 @@ describe("Phaser HEADLESS host", () => {
|
|
|
813
1189
|
expect(inputHost.evidence.clicks).toBe(2);
|
|
814
1190
|
inputHost.destroy();
|
|
815
1191
|
});
|
|
1192
|
+
it("drives held DOM keyboard input through Phaser's configured target", async () => {
|
|
1193
|
+
const keyboardHost = await createHeadlessGame(new KeyboardInputScene());
|
|
1194
|
+
await keyboardHost.input.keyboard.down(
|
|
1195
|
+
Phaser4.Input.Keyboard.KeyCodes.RIGHT
|
|
1196
|
+
);
|
|
1197
|
+
keyboardHost.stepFrames(3);
|
|
1198
|
+
expect(keyboardHost.scene.playerX).toBe(3);
|
|
1199
|
+
await keyboardHost.input.keyboard.up(Phaser4.Input.Keyboard.KeyCodes.RIGHT);
|
|
1200
|
+
keyboardHost.stepFrames(2);
|
|
1201
|
+
expect(keyboardHost.scene.playerX).toBe(3);
|
|
1202
|
+
expect(keyboardHost.evidence.keyboardEvents).toBe(2);
|
|
1203
|
+
expect(
|
|
1204
|
+
() => keyboardHost.assertGameplayEvidence({ requireInput: true })
|
|
1205
|
+
).not.toThrow();
|
|
1206
|
+
keyboardHost.destroy();
|
|
1207
|
+
});
|
|
1208
|
+
it("drives DOM touch input through pointer conversion and hit testing", async () => {
|
|
1209
|
+
const touchHost = await createHeadlessGame(new TouchInputScene(), {
|
|
1210
|
+
width: 160,
|
|
1211
|
+
height: 120
|
|
1212
|
+
});
|
|
1213
|
+
touchHost.stepFrames();
|
|
1214
|
+
touchHost.input.setCanvasBounds({
|
|
1215
|
+
left: 20,
|
|
1216
|
+
top: 30,
|
|
1217
|
+
width: 80,
|
|
1218
|
+
height: 60
|
|
1219
|
+
});
|
|
1220
|
+
await touchHost.input.touch.start(40, 40);
|
|
1221
|
+
await touchHost.input.touch.move(80, 60);
|
|
1222
|
+
await touchHost.input.touch.end(80, 60);
|
|
1223
|
+
expect({
|
|
1224
|
+
moves: touchHost.scene.moves,
|
|
1225
|
+
pointerX: touchHost.scene.input.activePointer.x,
|
|
1226
|
+
pointerY: touchHost.scene.input.activePointer.y,
|
|
1227
|
+
taps: touchHost.scene.taps,
|
|
1228
|
+
wasTouch: touchHost.scene.wasTouch
|
|
1229
|
+
}).toEqual({
|
|
1230
|
+
moves: 1,
|
|
1231
|
+
pointerX: 80,
|
|
1232
|
+
pointerY: 60,
|
|
1233
|
+
taps: 1,
|
|
1234
|
+
wasTouch: true
|
|
1235
|
+
});
|
|
1236
|
+
expect(touchHost.evidence.touchEvents).toBe(3);
|
|
1237
|
+
expect(
|
|
1238
|
+
() => touchHost.assertGameplayEvidence({ requireInput: true })
|
|
1239
|
+
).not.toThrow();
|
|
1240
|
+
touchHost.destroy();
|
|
1241
|
+
});
|
|
1242
|
+
it("boots when a production input family is disabled", async () => {
|
|
1243
|
+
const mouseOnlyHost = await createHeadlessGame(new SmokeScene(), {
|
|
1244
|
+
input: { keyboard: false, touch: false }
|
|
1245
|
+
});
|
|
1246
|
+
await expect(
|
|
1247
|
+
mouseOnlyHost.input.keyboard.press(Phaser4.Input.Keyboard.KeyCodes.SPACE)
|
|
1248
|
+
).rejects.toThrow("keyboard input target cannot dispatch DOM events");
|
|
1249
|
+
await expect(mouseOnlyHost.input.touch.tap(20, 20)).rejects.toThrow(
|
|
1250
|
+
"touch input target cannot dispatch DOM events"
|
|
1251
|
+
);
|
|
1252
|
+
mouseOnlyHost.destroy();
|
|
1253
|
+
});
|
|
816
1254
|
it("registers transition targets before boot and settles queued transitions", async () => {
|
|
817
1255
|
class TargetScene extends Phaser4.Scene {
|
|
818
1256
|
receivedValue = 0;
|
|
@@ -841,8 +1279,12 @@ describe("Phaser HEADLESS host", () => {
|
|
|
841
1279
|
transitionHost.stepFrames();
|
|
842
1280
|
expect(transitionHost.game.scene.isActive("TargetScene")).toBe(false);
|
|
843
1281
|
await transitionHost.input.mouse.click(40, 40);
|
|
844
|
-
transitionHost.stepUntil(
|
|
845
|
-
|
|
1282
|
+
transitionHost.stepUntil(
|
|
1283
|
+
() => transitionHost.game.scene.isActive("TargetScene")
|
|
1284
|
+
);
|
|
1285
|
+
const target = transitionHost.game.scene.getScene(
|
|
1286
|
+
"TargetScene"
|
|
1287
|
+
);
|
|
846
1288
|
expect(target.receivedValue).toBe(7);
|
|
847
1289
|
expect(transitionHost.game.scene.isActive("SourceScene")).toBe(false);
|
|
848
1290
|
transitionHost.assertGameplayEvidence({
|
|
@@ -866,11 +1308,39 @@ describe("Phaser HEADLESS host", () => {
|
|
|
866
1308
|
optionalHost.stepFrames();
|
|
867
1309
|
expect(optionalHost.scene.sys.isActive()).toBe(true);
|
|
868
1310
|
expect(optionalHost.evidence.transitions).toEqual([
|
|
869
|
-
{
|
|
870
|
-
|
|
1311
|
+
{
|
|
1312
|
+
from: "OptionalCommandScene",
|
|
1313
|
+
to: "OptionalCommandScene",
|
|
1314
|
+
method: "sleep"
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
from: "OptionalCommandScene",
|
|
1318
|
+
to: "OptionalCommandScene",
|
|
1319
|
+
method: "wake"
|
|
1320
|
+
}
|
|
871
1321
|
]);
|
|
872
1322
|
optionalHost.destroy();
|
|
873
1323
|
});
|
|
1324
|
+
it("records Scene visits, restart, checkpoints, and destruction", async () => {
|
|
1325
|
+
const evidenceHost = await createHeadlessGame(new SmokeScene());
|
|
1326
|
+
evidenceHost.checkpoint("progress");
|
|
1327
|
+
evidenceHost.scene.scene.restart();
|
|
1328
|
+
evidenceHost.stepFrames();
|
|
1329
|
+
evidenceHost.destroy();
|
|
1330
|
+
expect(evidenceHost.evidence.registeredScenes).toContain("SmokeScene");
|
|
1331
|
+
expect(evidenceHost.evidence.visitedScenes).toContain("SmokeScene");
|
|
1332
|
+
expect(evidenceHost.evidence.restartedScenes).toContain("SmokeScene");
|
|
1333
|
+
expect(evidenceHost.evidence.checkpoints).toEqual(["progress"]);
|
|
1334
|
+
expect(evidenceHost.evidence.destroyed).toBe(true);
|
|
1335
|
+
expect(
|
|
1336
|
+
() => evidenceHost.assertGameplayEvidence({
|
|
1337
|
+
requireScene: "SmokeScene",
|
|
1338
|
+
requireRestart: "SmokeScene",
|
|
1339
|
+
requireCheckpoint: "progress",
|
|
1340
|
+
requireDestroy: true
|
|
1341
|
+
})
|
|
1342
|
+
).not.toThrow();
|
|
1343
|
+
});
|
|
874
1344
|
it("surfaces exceptions thrown by DOM-driven input callbacks", async () => {
|
|
875
1345
|
class BrokenInputScene extends Phaser4.Scene {
|
|
876
1346
|
create() {
|
|
@@ -918,7 +1388,9 @@ describe("Phaser HEADLESS host", () => {
|
|
|
918
1388
|
(zone.input?.hitArea).width = 0;
|
|
919
1389
|
}
|
|
920
1390
|
}
|
|
921
|
-
await expect(
|
|
1391
|
+
await expect(
|
|
1392
|
+
createHeadlessGame(new InvalidInteractiveScene())
|
|
1393
|
+
).rejects.toThrow(
|
|
922
1394
|
/interactive health check failed.*invalid default hit area/s
|
|
923
1395
|
);
|
|
924
1396
|
});
|
|
@@ -942,9 +1414,9 @@ describe("Phaser HEADLESS host", () => {
|
|
|
942
1414
|
this.add.zone(Number.NaN, 20, 10, 10);
|
|
943
1415
|
}
|
|
944
1416
|
}
|
|
945
|
-
await expect(
|
|
946
|
-
|
|
947
|
-
);
|
|
1417
|
+
await expect(
|
|
1418
|
+
createHeadlessGame(new InvalidTransformScene())
|
|
1419
|
+
).rejects.toThrow(/runtime health check failed.*non-finite x: NaN/s);
|
|
948
1420
|
});
|
|
949
1421
|
it("rejects zero Camera zoom but allows negative zoom", async () => {
|
|
950
1422
|
class InvalidCameraScene extends Phaser4.Scene {
|
|
@@ -971,9 +1443,11 @@ describe("Phaser HEADLESS host", () => {
|
|
|
971
1443
|
object.body.velocity.x = Number.NaN;
|
|
972
1444
|
}
|
|
973
1445
|
}
|
|
974
|
-
await expect(
|
|
975
|
-
|
|
976
|
-
|
|
1446
|
+
await expect(
|
|
1447
|
+
createHeadlessGame(new InvalidBodyScene(), {
|
|
1448
|
+
physics: { default: "arcade" }
|
|
1449
|
+
})
|
|
1450
|
+
).rejects.toThrow(/runtime health check failed.*velocity.x: NaN/s);
|
|
977
1451
|
class DisabledBodyScene extends Phaser4.Scene {
|
|
978
1452
|
create() {
|
|
979
1453
|
const object = this.add.zone(20, 20, 10, 10);
|
|
@@ -983,7 +1457,9 @@ describe("Phaser HEADLESS host", () => {
|
|
|
983
1457
|
body.velocity.x = Number.NaN;
|
|
984
1458
|
}
|
|
985
1459
|
}
|
|
986
|
-
const disabledHost = await createHeadlessGame(new DisabledBodyScene()
|
|
1460
|
+
const disabledHost = await createHeadlessGame(new DisabledBodyScene(), {
|
|
1461
|
+
physics: { default: "arcade" }
|
|
1462
|
+
});
|
|
987
1463
|
expect(() => disabledHost.destroy()).not.toThrow();
|
|
988
1464
|
});
|
|
989
1465
|
it("captures rejected async lifecycle hooks", async () => {
|
|
@@ -1046,7 +1522,8 @@ describe("Phaser HEADLESS host", () => {
|
|
|
1046
1522
|
this.add.text(8, 8, "NO PHYSICS");
|
|
1047
1523
|
}
|
|
1048
1524
|
}
|
|
1049
|
-
const noPhysicsHost = await createHeadlessGame(new NoPhysicsScene()
|
|
1525
|
+
const noPhysicsHost = await createHeadlessGame(new NoPhysicsScene());
|
|
1526
|
+
expect(noPhysicsHost.scene.physics).toBeUndefined();
|
|
1050
1527
|
expect(() => noPhysicsHost.stepFrames()).not.toThrow();
|
|
1051
1528
|
expect(() => noPhysicsHost.stepPhysics()).toThrow(
|
|
1052
1529
|
"stepPhysics() requires an Arcade Physics world"
|