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
|
+
);
|
|
957
|
+
}
|
|
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
|
+
);
|
|
678
973
|
}
|
|
679
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();
|
|
@@ -732,18 +1048,79 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
732
1048
|
}
|
|
733
1049
|
|
|
734
1050
|
// src/gameplay-contracts.ts
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1051
|
+
import { test } from "vitest";
|
|
1052
|
+
|
|
1053
|
+
// src/gameplay-audit.ts
|
|
1054
|
+
function normalizeList(value) {
|
|
1055
|
+
if (value === void 0) return [];
|
|
1056
|
+
const values = typeof value === "string" ? [value] : value;
|
|
1057
|
+
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
|
|
1058
|
+
}
|
|
1059
|
+
function normalizeSet(values) {
|
|
1060
|
+
return [
|
|
1061
|
+
...new Set(values.map((value) => value.trim()).filter(Boolean))
|
|
1062
|
+
].sort();
|
|
1063
|
+
}
|
|
1064
|
+
function normalizeGameplayRequirements(requirements, scenes) {
|
|
1065
|
+
const requiredScenes = normalizeSet([
|
|
1066
|
+
...scenes,
|
|
1067
|
+
...normalizeList(requirements.requireScene)
|
|
1068
|
+
]);
|
|
1069
|
+
return {
|
|
1070
|
+
requireInput: requirements.requireInput || void 0,
|
|
1071
|
+
requireFrameAdvance: requirements.requireFrameAdvance || void 0,
|
|
1072
|
+
requirePhysicsStep: requirements.requirePhysicsStep || void 0,
|
|
1073
|
+
requireTransition: normalizeList(requirements.requireTransition),
|
|
1074
|
+
requireScene: requiredScenes,
|
|
1075
|
+
requireRestart: normalizeList(requirements.requireRestart),
|
|
1076
|
+
requireCheckpoint: normalizeList(requirements.requireCheckpoint),
|
|
1077
|
+
requireDestroy: true
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
function createGameplayContractMetadata(contract) {
|
|
1081
|
+
const scenes = normalizeSet(contract.scenes);
|
|
1082
|
+
return {
|
|
1083
|
+
id: contract.id.trim(),
|
|
1084
|
+
scenes,
|
|
1085
|
+
requirements: normalizeGameplayRequirements(contract, scenes),
|
|
1086
|
+
verified: false
|
|
745
1087
|
};
|
|
746
|
-
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// src/gameplay-contracts.ts
|
|
1091
|
+
function snapshotEvidence(evidence) {
|
|
1092
|
+
return {
|
|
1093
|
+
frames: evidence.frames,
|
|
1094
|
+
physicsSteps: evidence.physicsSteps,
|
|
1095
|
+
mouseEvents: evidence.mouseEvents,
|
|
1096
|
+
keyboardEvents: evidence.keyboardEvents,
|
|
1097
|
+
touchEvents: evidence.touchEvents,
|
|
1098
|
+
clicks: evidence.clicks,
|
|
1099
|
+
registeredScenes: [...evidence.registeredScenes],
|
|
1100
|
+
visitedScenes: [...evidence.visitedScenes],
|
|
1101
|
+
restartedScenes: [...evidence.restartedScenes],
|
|
1102
|
+
checkpoints: [...evidence.checkpoints],
|
|
1103
|
+
destroyed: evidence.destroyed,
|
|
1104
|
+
transitions: evidence.transitions.map((transition) => ({ ...transition }))
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
function readStaticMetadata(context, options) {
|
|
1108
|
+
const metadata = context.task.meta.gameplayContract;
|
|
1109
|
+
if (!metadata || typeof metadata !== "object") return void 0;
|
|
1110
|
+
const candidate = metadata;
|
|
1111
|
+
if (candidate.id !== options.id) {
|
|
1112
|
+
throw new Error(
|
|
1113
|
+
`\u9759\u6001 gameplay contract "${candidate.id}" \u4E0E\u8FD0\u884C\u65F6 contract "${options.id}" \u4E0D\u4E00\u81F4\u3002`
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
return candidate;
|
|
1117
|
+
}
|
|
1118
|
+
function registerGameplayContract(context, options) {
|
|
1119
|
+
const metadata = readStaticMetadata(context, options) ?? createGameplayContractMetadata({
|
|
1120
|
+
...options,
|
|
1121
|
+
scenes: options.scenes ?? []
|
|
1122
|
+
});
|
|
1123
|
+
context.task.meta.gameplayContract = metadata;
|
|
747
1124
|
let host;
|
|
748
1125
|
let verified = false;
|
|
749
1126
|
let testFailed = false;
|
|
@@ -753,16 +1130,13 @@ function registerGameplayContract(context, options) {
|
|
|
753
1130
|
const verify = () => {
|
|
754
1131
|
if (verified) return;
|
|
755
1132
|
if (!host) {
|
|
756
|
-
throw new Error(
|
|
1133
|
+
throw new Error(
|
|
1134
|
+
`Gameplay contract "${options.id}" \u6CA1\u6709\u7ED1\u5B9A HEADLESS host\u3002`
|
|
1135
|
+
);
|
|
757
1136
|
}
|
|
758
1137
|
host.assertGameplayEvidence(metadata.requirements);
|
|
759
|
-
metadata.evidence =
|
|
760
|
-
|
|
761
|
-
physicsSteps: host.evidence.physicsSteps,
|
|
762
|
-
mouseEvents: host.evidence.mouseEvents,
|
|
763
|
-
clicks: host.evidence.clicks,
|
|
764
|
-
transitions: host.evidence.transitions.map((transition) => ({ ...transition }))
|
|
765
|
-
};
|
|
1138
|
+
metadata.evidence = snapshotEvidence(host.evidence);
|
|
1139
|
+
metadata.verified = true;
|
|
766
1140
|
verified = true;
|
|
767
1141
|
};
|
|
768
1142
|
context.onTestFinished(() => {
|
|
@@ -772,7 +1146,9 @@ function registerGameplayContract(context, options) {
|
|
|
772
1146
|
return {
|
|
773
1147
|
attachHost(currentHost) {
|
|
774
1148
|
if (host) {
|
|
775
|
-
throw new Error(
|
|
1149
|
+
throw new Error(
|
|
1150
|
+
`Gameplay contract "${options.id}" \u91CD\u590D\u7ED1\u5B9A\u4E86 HEADLESS host\u3002`
|
|
1151
|
+
);
|
|
776
1152
|
}
|
|
777
1153
|
host = currentHost;
|
|
778
1154
|
},
|
|
@@ -841,7 +1217,9 @@ describe("gameplay contract lifecycle integration", () => {
|
|
|
841
1217
|
});
|
|
842
1218
|
const host = await createHeadlessGame(new InputScene());
|
|
843
1219
|
contract.attachHost(host);
|
|
844
|
-
await expect(
|
|
1220
|
+
await expect(
|
|
1221
|
+
Promise.resolve().then(() => callbacks[0]?.())
|
|
1222
|
+
).rejects.toThrow(
|
|
845
1223
|
"Gameplay evidence is missing real HEADLESS input events"
|
|
846
1224
|
);
|
|
847
1225
|
host.destroy();
|