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
package/dist/index.js
CHANGED
|
@@ -35,6 +35,7 @@ __export(src_exports, {
|
|
|
35
35
|
assertSceneTextHealth: () => assertSceneTextHealth,
|
|
36
36
|
collectMissingBitmapGlyphs: () => collectMissingBitmapGlyphs,
|
|
37
37
|
createHeadlessGame: () => createHeadlessGame,
|
|
38
|
+
gameplayTest: () => gameplayTest,
|
|
38
39
|
registerGameplayContract: () => registerGameplayContract
|
|
39
40
|
});
|
|
40
41
|
module.exports = __toCommonJS(src_exports);
|
|
@@ -62,7 +63,9 @@ function isEffectivelyRenderable(label) {
|
|
|
62
63
|
}
|
|
63
64
|
function requireFinite(problems, label, property, value) {
|
|
64
65
|
if (!Number.isFinite(value)) {
|
|
65
|
-
problems.push(
|
|
66
|
+
problems.push(
|
|
67
|
+
`${describeText(label)} has non-finite ${property}: ${String(value)}`
|
|
68
|
+
);
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
function collectSceneTexts(scene) {
|
|
@@ -116,7 +119,9 @@ function collectMissingBitmapGlyphs(text, chars) {
|
|
|
116
119
|
const code = text.charCodeAt(index);
|
|
117
120
|
if (seen.has(code) || chars[code] !== void 0) continue;
|
|
118
121
|
seen.add(code);
|
|
119
|
-
missing.push(
|
|
122
|
+
missing.push(
|
|
123
|
+
`${JSON.stringify(character)} (U+${code.toString(16).toUpperCase().padStart(4, "0")})`
|
|
124
|
+
);
|
|
120
125
|
}
|
|
121
126
|
return missing;
|
|
122
127
|
}
|
|
@@ -141,19 +146,29 @@ function collectTextHealthProblems(scene) {
|
|
|
141
146
|
requireFinite(problems, label, "resolution", label.style.resolution);
|
|
142
147
|
const requiresPositiveSize = /\S/u.test(label.text) && isEffectivelyRenderable(label);
|
|
143
148
|
if (requiresPositiveSize && Number.isFinite(label.width) && label.width <= 0) {
|
|
144
|
-
problems.push(
|
|
149
|
+
problems.push(
|
|
150
|
+
`${describeText(label)} has non-positive width: ${label.width}`
|
|
151
|
+
);
|
|
145
152
|
}
|
|
146
153
|
if (requiresPositiveSize && Number.isFinite(label.height) && label.height <= 0) {
|
|
147
|
-
problems.push(
|
|
154
|
+
problems.push(
|
|
155
|
+
`${describeText(label)} has non-positive height: ${label.height}`
|
|
156
|
+
);
|
|
148
157
|
}
|
|
149
158
|
if (Number.isFinite(label.style.fixedWidth) && label.style.fixedWidth < 0) {
|
|
150
|
-
problems.push(
|
|
159
|
+
problems.push(
|
|
160
|
+
`${describeText(label)} has negative fixedWidth: ${label.style.fixedWidth}`
|
|
161
|
+
);
|
|
151
162
|
}
|
|
152
163
|
if (Number.isFinite(label.style.fixedHeight) && label.style.fixedHeight < 0) {
|
|
153
|
-
problems.push(
|
|
164
|
+
problems.push(
|
|
165
|
+
`${describeText(label)} has negative fixedHeight: ${label.style.fixedHeight}`
|
|
166
|
+
);
|
|
154
167
|
}
|
|
155
168
|
if (Number.isFinite(label.style.resolution) && label.style.resolution <= 0) {
|
|
156
|
-
problems.push(
|
|
169
|
+
problems.push(
|
|
170
|
+
`${describeText(label)} has non-positive resolution: ${label.style.resolution}`
|
|
171
|
+
);
|
|
157
172
|
}
|
|
158
173
|
const bounds = label.getBounds();
|
|
159
174
|
for (const [property, value] of Object.entries({
|
|
@@ -187,11 +202,13 @@ function assertSceneTextHealth(scene) {
|
|
|
187
202
|
...collectBitmapTextHealthProblems(scene)
|
|
188
203
|
];
|
|
189
204
|
if (problems.length === 0) return;
|
|
190
|
-
throw new Error(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
205
|
+
throw new Error(
|
|
206
|
+
[
|
|
207
|
+
`Phaser text health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
208
|
+
...problems.map((problem) => `- ${problem}`),
|
|
209
|
+
"Fix optional TextStyle values by omitting them or using Phaser defaults (for example fixedWidth: value ?? 0)."
|
|
210
|
+
].join("\n")
|
|
211
|
+
);
|
|
195
212
|
}
|
|
196
213
|
|
|
197
214
|
// src/phaser-runtime-assertions.ts
|
|
@@ -203,8 +220,10 @@ function collectObjects(scene) {
|
|
|
203
220
|
if (visited.has(object)) return;
|
|
204
221
|
visited.add(object);
|
|
205
222
|
objects.push(object);
|
|
206
|
-
if (object instanceof Phaser2.GameObjects.Container)
|
|
207
|
-
|
|
223
|
+
if (object instanceof Phaser2.GameObjects.Container)
|
|
224
|
+
object.list.forEach(visit);
|
|
225
|
+
if (object instanceof Phaser2.GameObjects.Layer)
|
|
226
|
+
object.getChildren().forEach(visit);
|
|
208
227
|
};
|
|
209
228
|
scene.children.getChildren().forEach(visit);
|
|
210
229
|
return objects;
|
|
@@ -238,7 +257,9 @@ function requireFiniteVector(problems, subject, target, property) {
|
|
|
238
257
|
if (!(axis in vector)) continue;
|
|
239
258
|
const value = Reflect.get(vector, axis);
|
|
240
259
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
241
|
-
problems.push(
|
|
260
|
+
problems.push(
|
|
261
|
+
`${subject} has non-finite ${property}.${axis}: ${String(value)}`
|
|
262
|
+
);
|
|
242
263
|
}
|
|
243
264
|
}
|
|
244
265
|
}
|
|
@@ -306,7 +327,9 @@ function getArcadeBodyCollection(value) {
|
|
|
306
327
|
function getArcadeWorldBodies(world) {
|
|
307
328
|
if (!world || typeof world !== "object") return void 0;
|
|
308
329
|
const bodies = getArcadeBodyCollection(Reflect.get(world, "bodies"));
|
|
309
|
-
const staticBodies = getArcadeBodyCollection(
|
|
330
|
+
const staticBodies = getArcadeBodyCollection(
|
|
331
|
+
Reflect.get(world, "staticBodies")
|
|
332
|
+
);
|
|
310
333
|
return bodies && staticBodies ? [...bodies, ...staticBodies] : void 0;
|
|
311
334
|
}
|
|
312
335
|
function collectArcadeBodyHealthProblems(scene) {
|
|
@@ -317,22 +340,38 @@ function collectArcadeBodyHealthProblems(scene) {
|
|
|
317
340
|
if (body.enable === false) continue;
|
|
318
341
|
const gameObject = body.gameObject;
|
|
319
342
|
const subject = gameObject ? `Arcade Body for ${describeObject(gameObject)}` : "Arcade Body";
|
|
320
|
-
for (const property of [
|
|
343
|
+
for (const property of [
|
|
344
|
+
"position",
|
|
345
|
+
"velocity",
|
|
346
|
+
"acceleration",
|
|
347
|
+
"gravity",
|
|
348
|
+
"offset",
|
|
349
|
+
"center"
|
|
350
|
+
]) {
|
|
321
351
|
requireFiniteVector(problems, subject, body, property);
|
|
322
352
|
}
|
|
323
|
-
for (const property of [
|
|
353
|
+
for (const property of [
|
|
354
|
+
"width",
|
|
355
|
+
"height",
|
|
356
|
+
"halfWidth",
|
|
357
|
+
"halfHeight",
|
|
358
|
+
"rotation"
|
|
359
|
+
]) {
|
|
324
360
|
requireFiniteProperty(problems, subject, body, property);
|
|
325
361
|
}
|
|
326
362
|
}
|
|
327
363
|
return problems;
|
|
328
364
|
}
|
|
329
365
|
function usesCustomHitArea(object) {
|
|
330
|
-
return Boolean(
|
|
366
|
+
return Boolean(
|
|
367
|
+
object.input?.customHitArea
|
|
368
|
+
);
|
|
331
369
|
}
|
|
332
370
|
function collectInteractiveHealthProblems(scene) {
|
|
333
371
|
const problems = [];
|
|
334
372
|
for (const object of collectObjects(scene)) {
|
|
335
|
-
if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled)
|
|
373
|
+
if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled)
|
|
374
|
+
continue;
|
|
336
375
|
if (usesCustomHitArea(object)) continue;
|
|
337
376
|
const hitArea = object.input.hitArea;
|
|
338
377
|
if (!hitArea) {
|
|
@@ -355,20 +394,24 @@ function assertSceneRuntimeHealth(scene) {
|
|
|
355
394
|
...collectArcadeBodyHealthProblems(scene)
|
|
356
395
|
];
|
|
357
396
|
if (problems.length === 0) return;
|
|
358
|
-
throw new Error(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
397
|
+
throw new Error(
|
|
398
|
+
[
|
|
399
|
+
`Phaser runtime health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
400
|
+
...problems.map((problem) => `- ${problem}`),
|
|
401
|
+
"Keep values consumed by Phaser finite; zero size and zero scale remain allowed."
|
|
402
|
+
].join("\n")
|
|
403
|
+
);
|
|
363
404
|
}
|
|
364
405
|
function assertSceneInteractiveHealth(scene) {
|
|
365
406
|
const problems = collectInteractiveHealthProblems(scene);
|
|
366
407
|
if (problems.length === 0) return;
|
|
367
|
-
throw new Error(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
408
|
+
throw new Error(
|
|
409
|
+
[
|
|
410
|
+
`Phaser interactive health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
411
|
+
...problems.map((problem) => `- ${problem}`),
|
|
412
|
+
"Fix the default hit area or disable input before making the object interactive."
|
|
413
|
+
].join("\n")
|
|
414
|
+
);
|
|
372
415
|
}
|
|
373
416
|
|
|
374
417
|
// src/phaser-headless-host.ts
|
|
@@ -383,11 +426,32 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
383
426
|
let removeRuntimeListeners = () => {
|
|
384
427
|
};
|
|
385
428
|
const transitions = [];
|
|
429
|
+
const registeredScenes = [];
|
|
430
|
+
const visitedScenes = [];
|
|
431
|
+
const restartedScenes = [];
|
|
432
|
+
const checkpoints = [];
|
|
433
|
+
const appendUnique = (values, value) => {
|
|
434
|
+
if (value && !values.includes(value)) values.push(value);
|
|
435
|
+
};
|
|
436
|
+
const assertStepCount = (value, name) => {
|
|
437
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`${name} must be a non-negative safe integer; received ${value}`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
386
443
|
const evidence = {
|
|
387
444
|
frames: 0,
|
|
388
445
|
physicsSteps: 0,
|
|
389
446
|
mouseEvents: 0,
|
|
447
|
+
keyboardEvents: 0,
|
|
448
|
+
touchEvents: 0,
|
|
390
449
|
clicks: 0,
|
|
450
|
+
registeredScenes,
|
|
451
|
+
visitedScenes,
|
|
452
|
+
restartedScenes,
|
|
453
|
+
checkpoints,
|
|
454
|
+
destroyed: false,
|
|
391
455
|
transitions
|
|
392
456
|
};
|
|
393
457
|
const created = new Promise((resolve, reject) => {
|
|
@@ -433,7 +497,9 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
433
497
|
});
|
|
434
498
|
};
|
|
435
499
|
const timeout = window.setTimeout(() => {
|
|
436
|
-
finish(
|
|
500
|
+
finish(
|
|
501
|
+
new Error(`Phaser HEADLESS boot timed out after ${bootTimeoutMs}ms`)
|
|
502
|
+
);
|
|
437
503
|
}, bootTimeoutMs);
|
|
438
504
|
window.addEventListener("error", onWindowError, true);
|
|
439
505
|
window.addEventListener("unhandledrejection", onUnhandledRejection);
|
|
@@ -445,7 +511,13 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
445
511
|
if (guardedScenes.has(currentScene)) return;
|
|
446
512
|
guardedScenes.add(currentScene);
|
|
447
513
|
const scenePlugin = currentScene.scene;
|
|
448
|
-
for (const method of [
|
|
514
|
+
for (const method of [
|
|
515
|
+
"start",
|
|
516
|
+
"launch",
|
|
517
|
+
"switch",
|
|
518
|
+
"sleep",
|
|
519
|
+
"wake"
|
|
520
|
+
]) {
|
|
449
521
|
const original = scenePlugin[method];
|
|
450
522
|
if (typeof original !== "function") continue;
|
|
451
523
|
scenePlugin[method] = (key, ...args) => {
|
|
@@ -461,29 +533,49 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
461
533
|
scenePlugin[method] = original;
|
|
462
534
|
});
|
|
463
535
|
}
|
|
536
|
+
const originalRestart = scenePlugin.restart;
|
|
537
|
+
if (typeof originalRestart === "function") {
|
|
538
|
+
scenePlugin.restart = (...args) => {
|
|
539
|
+
appendUnique(restartedScenes, currentScene.sys.settings.key);
|
|
540
|
+
return Reflect.apply(originalRestart, currentScene.scene, args);
|
|
541
|
+
};
|
|
542
|
+
restoreGuards.push(() => {
|
|
543
|
+
scenePlugin.restart = originalRestart;
|
|
544
|
+
});
|
|
545
|
+
}
|
|
464
546
|
for (const hook of ["init", "preload", "create", "update"]) {
|
|
465
547
|
const original = Reflect.get(currentScene, hook);
|
|
466
548
|
if (typeof original !== "function") continue;
|
|
467
|
-
const ownDescriptor = Object.getOwnPropertyDescriptor(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
549
|
+
const ownDescriptor = Object.getOwnPropertyDescriptor(
|
|
550
|
+
currentScene,
|
|
551
|
+
hook
|
|
552
|
+
);
|
|
553
|
+
Reflect.set(
|
|
554
|
+
currentScene,
|
|
555
|
+
hook,
|
|
556
|
+
function guardedLifecycleHook(...args) {
|
|
557
|
+
try {
|
|
558
|
+
const result = Reflect.apply(original, this, args);
|
|
559
|
+
if (result && typeof result === "object" && "then" in result) {
|
|
560
|
+
const completion = Promise.resolve(result);
|
|
561
|
+
if (currentScene === scene && hook === "create" && !settled) {
|
|
562
|
+
completion.then(
|
|
563
|
+
() => window.setTimeout(() => finish(), 0),
|
|
564
|
+
reportError
|
|
565
|
+
);
|
|
566
|
+
} else {
|
|
567
|
+
completion.catch(reportError);
|
|
568
|
+
}
|
|
569
|
+
} else if (currentScene === scene && hook === "create" && !settled) {
|
|
570
|
+
window.setTimeout(() => finish(), 0);
|
|
477
571
|
}
|
|
478
|
-
|
|
479
|
-
|
|
572
|
+
return result;
|
|
573
|
+
} catch (error) {
|
|
574
|
+
reportError(error);
|
|
575
|
+
return void 0;
|
|
480
576
|
}
|
|
481
|
-
return result;
|
|
482
|
-
} catch (error) {
|
|
483
|
-
reportError(error);
|
|
484
|
-
return void 0;
|
|
485
577
|
}
|
|
486
|
-
|
|
578
|
+
);
|
|
487
579
|
restoreGuards.push(() => {
|
|
488
580
|
if (ownDescriptor) {
|
|
489
581
|
Object.defineProperty(currentScene, hook, ownDescriptor);
|
|
@@ -512,13 +604,6 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
512
604
|
banner: false,
|
|
513
605
|
autoFocus: false,
|
|
514
606
|
seed: ["phaser-headless-test"],
|
|
515
|
-
physics: {
|
|
516
|
-
default: "arcade",
|
|
517
|
-
arcade: {
|
|
518
|
-
gravity: { x: 0, y: 0 },
|
|
519
|
-
fixedStep: true
|
|
520
|
-
}
|
|
521
|
-
},
|
|
522
607
|
...config,
|
|
523
608
|
type: Phaser3.HEADLESS,
|
|
524
609
|
fps,
|
|
@@ -544,7 +629,10 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
544
629
|
};
|
|
545
630
|
bootedGame.events.on(Phaser3.Core.Events.POST_STEP, checkReadiness);
|
|
546
631
|
removeReadinessCheck = () => {
|
|
547
|
-
bootedGame.events.off(
|
|
632
|
+
bootedGame.events.off(
|
|
633
|
+
Phaser3.Core.Events.POST_STEP,
|
|
634
|
+
checkReadiness
|
|
635
|
+
);
|
|
548
636
|
};
|
|
549
637
|
}
|
|
550
638
|
},
|
|
@@ -584,7 +672,9 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
584
672
|
restoreHostGuards();
|
|
585
673
|
readyGame.destroy(true);
|
|
586
674
|
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
587
|
-
throw new Error(
|
|
675
|
+
throw new Error(
|
|
676
|
+
"Phaser HEADLESS input canvas is not attached to a DOM Window"
|
|
677
|
+
);
|
|
588
678
|
}
|
|
589
679
|
let canvasBounds = {
|
|
590
680
|
left: 0,
|
|
@@ -620,7 +710,9 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
620
710
|
setCanvasBounds(canvasBounds);
|
|
621
711
|
const gameToClient = (x, y) => {
|
|
622
712
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
623
|
-
throw new Error(
|
|
713
|
+
throw new Error(
|
|
714
|
+
`HEADLESS input coordinates must be finite; received (${x}, ${y})`
|
|
715
|
+
);
|
|
624
716
|
}
|
|
625
717
|
return {
|
|
626
718
|
clientX: canvasBounds.left + x / readyGame.scale.displayScale.x,
|
|
@@ -629,7 +721,10 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
629
721
|
};
|
|
630
722
|
const assertHealth = () => {
|
|
631
723
|
for (const currentScene of readyGame.scene.getScenes(false)) {
|
|
632
|
-
|
|
724
|
+
appendUnique(registeredScenes, currentScene.sys.settings.key);
|
|
725
|
+
if (!currentScene.sys.isActive() && !currentScene.sys.isPaused())
|
|
726
|
+
continue;
|
|
727
|
+
appendUnique(visitedScenes, currentScene.sys.settings.key);
|
|
633
728
|
assertSceneTextHealth(currentScene);
|
|
634
729
|
assertSceneInteractiveHealth(currentScene);
|
|
635
730
|
assertSceneRuntimeHealth(currentScene);
|
|
@@ -640,17 +735,166 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
640
735
|
throwRuntimeError();
|
|
641
736
|
assertHealth();
|
|
642
737
|
};
|
|
738
|
+
const getEventTarget = (target, kind) => {
|
|
739
|
+
if (!target || typeof Reflect.get(target, "dispatchEvent") !== "function") {
|
|
740
|
+
throw new Error(
|
|
741
|
+
`Phaser HEADLESS ${kind} input target cannot dispatch DOM events`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
return target;
|
|
745
|
+
};
|
|
643
746
|
const dispatchMouse = async (type, x, y, buttons) => {
|
|
644
747
|
const { clientX, clientY } = gameToClient(x, y);
|
|
645
|
-
|
|
748
|
+
const mouseTarget = getEventTarget(readyGame.input.mouse?.target, "mouse");
|
|
749
|
+
mouseTarget.dispatchEvent(
|
|
750
|
+
new inputWindow.MouseEvent(type, {
|
|
751
|
+
bubbles: true,
|
|
752
|
+
cancelable: true,
|
|
753
|
+
button: 0,
|
|
754
|
+
buttons,
|
|
755
|
+
clientX,
|
|
756
|
+
clientY
|
|
757
|
+
})
|
|
758
|
+
);
|
|
759
|
+
evidence.mouseEvents += 1;
|
|
760
|
+
await settleRuntime();
|
|
761
|
+
};
|
|
762
|
+
const keyIdentity = (keyCode) => {
|
|
763
|
+
if (keyCode >= 65 && keyCode <= 90) {
|
|
764
|
+
const letter = String.fromCharCode(keyCode);
|
|
765
|
+
return { key: letter.toLowerCase(), code: `Key${letter}` };
|
|
766
|
+
}
|
|
767
|
+
if (keyCode >= 48 && keyCode <= 57) {
|
|
768
|
+
const digit = String.fromCharCode(keyCode);
|
|
769
|
+
return { key: digit, code: `Digit${digit}` };
|
|
770
|
+
}
|
|
771
|
+
const identities = /* @__PURE__ */ new Map([
|
|
772
|
+
[
|
|
773
|
+
Phaser3.Input.Keyboard.KeyCodes.LEFT,
|
|
774
|
+
{ key: "ArrowLeft", code: "ArrowLeft" }
|
|
775
|
+
],
|
|
776
|
+
[
|
|
777
|
+
Phaser3.Input.Keyboard.KeyCodes.RIGHT,
|
|
778
|
+
{ key: "ArrowRight", code: "ArrowRight" }
|
|
779
|
+
],
|
|
780
|
+
[Phaser3.Input.Keyboard.KeyCodes.UP, { key: "ArrowUp", code: "ArrowUp" }],
|
|
781
|
+
[
|
|
782
|
+
Phaser3.Input.Keyboard.KeyCodes.DOWN,
|
|
783
|
+
{ key: "ArrowDown", code: "ArrowDown" }
|
|
784
|
+
],
|
|
785
|
+
[Phaser3.Input.Keyboard.KeyCodes.SPACE, { key: " ", code: "Space" }],
|
|
786
|
+
[Phaser3.Input.Keyboard.KeyCodes.ENTER, { key: "Enter", code: "Enter" }],
|
|
787
|
+
[Phaser3.Input.Keyboard.KeyCodes.ESC, { key: "Escape", code: "Escape" }]
|
|
788
|
+
]);
|
|
789
|
+
return identities.get(keyCode) ?? { key: "", code: "" };
|
|
790
|
+
};
|
|
791
|
+
const dispatchKeyboard = async (type, keyCode, options2 = {}) => {
|
|
792
|
+
if (!Number.isInteger(keyCode) || keyCode < 0) {
|
|
793
|
+
throw new Error(
|
|
794
|
+
`HEADLESS keyboard keyCode must be a non-negative integer; received ${keyCode}`
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
const identity = keyIdentity(keyCode);
|
|
798
|
+
const event = new inputWindow.KeyboardEvent(type, {
|
|
646
799
|
bubbles: true,
|
|
647
800
|
cancelable: true,
|
|
648
|
-
|
|
649
|
-
|
|
801
|
+
key: options2.key ?? identity.key,
|
|
802
|
+
code: options2.code ?? identity.code,
|
|
803
|
+
repeat: options2.repeat ?? false,
|
|
804
|
+
altKey: options2.altKey ?? false,
|
|
805
|
+
ctrlKey: options2.ctrlKey ?? false,
|
|
806
|
+
metaKey: options2.metaKey ?? false,
|
|
807
|
+
shiftKey: options2.shiftKey ?? false
|
|
808
|
+
});
|
|
809
|
+
Object.defineProperties(event, {
|
|
810
|
+
// Phaser KeyboardManager 仍使用这些兼容旧浏览器的数字字段。
|
|
811
|
+
keyCode: { value: keyCode },
|
|
812
|
+
which: { value: keyCode }
|
|
813
|
+
});
|
|
814
|
+
const keyboardTarget = getEventTarget(
|
|
815
|
+
readyGame.input.keyboard?.target,
|
|
816
|
+
"keyboard"
|
|
817
|
+
);
|
|
818
|
+
keyboardTarget.dispatchEvent(event);
|
|
819
|
+
evidence.keyboardEvents += 1;
|
|
820
|
+
await settleRuntime();
|
|
821
|
+
};
|
|
822
|
+
const activeTouches = /* @__PURE__ */ new Map();
|
|
823
|
+
const createTouch = (x, y, identifier, target) => {
|
|
824
|
+
if (!Number.isInteger(identifier) || identifier < 0) {
|
|
825
|
+
throw new Error(
|
|
826
|
+
`HEADLESS touch identifier must be a non-negative integer; received ${identifier}`
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
const { clientX, clientY } = gameToClient(x, y);
|
|
830
|
+
return {
|
|
831
|
+
identifier,
|
|
832
|
+
target,
|
|
650
833
|
clientX,
|
|
651
|
-
clientY
|
|
652
|
-
|
|
653
|
-
|
|
834
|
+
clientY,
|
|
835
|
+
pageX: clientX,
|
|
836
|
+
pageY: clientY,
|
|
837
|
+
screenX: clientX,
|
|
838
|
+
screenY: clientY,
|
|
839
|
+
radiusX: 1,
|
|
840
|
+
radiusY: 1,
|
|
841
|
+
rotationAngle: 0,
|
|
842
|
+
force: 1
|
|
843
|
+
};
|
|
844
|
+
};
|
|
845
|
+
const dispatchTouch = async (type, x, y, identifier) => {
|
|
846
|
+
if (type === "touchstart" && activeTouches.has(identifier)) {
|
|
847
|
+
throw new Error(
|
|
848
|
+
`HEADLESS touch identifier ${identifier} is already active`
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
if (type !== "touchstart" && !activeTouches.has(identifier)) {
|
|
852
|
+
throw new Error(`HEADLESS touch identifier ${identifier} is not active`);
|
|
853
|
+
}
|
|
854
|
+
const touchTarget = getEventTarget(readyGame.input.touch?.target, "touch");
|
|
855
|
+
const changedTouch = createTouch(x, y, identifier, touchTarget);
|
|
856
|
+
if (type === "touchstart" || type === "touchmove") {
|
|
857
|
+
activeTouches.set(identifier, changedTouch);
|
|
858
|
+
} else {
|
|
859
|
+
activeTouches.delete(identifier);
|
|
860
|
+
}
|
|
861
|
+
const touches = [...activeTouches.values()];
|
|
862
|
+
const event = new inputWindow.Event(type, {
|
|
863
|
+
bubbles: true,
|
|
864
|
+
cancelable: true
|
|
865
|
+
});
|
|
866
|
+
Object.defineProperties(event, {
|
|
867
|
+
changedTouches: { value: [changedTouch] },
|
|
868
|
+
targetTouches: { value: touches },
|
|
869
|
+
touches: { value: touches }
|
|
870
|
+
});
|
|
871
|
+
const document = inputWindow.document;
|
|
872
|
+
const originalElementFromPoint = document.elementFromPoint;
|
|
873
|
+
const originalElementFromPointDescriptor = Object.getOwnPropertyDescriptor(
|
|
874
|
+
document,
|
|
875
|
+
"elementFromPoint"
|
|
876
|
+
);
|
|
877
|
+
Object.defineProperty(document, "elementFromPoint", {
|
|
878
|
+
configurable: true,
|
|
879
|
+
value(clientX, clientY) {
|
|
880
|
+
const insideCanvas = clientX >= canvasBounds.left && clientX <= canvasBounds.left + canvasBounds.width && clientY >= canvasBounds.top && clientY <= canvasBounds.top + canvasBounds.height;
|
|
881
|
+
return insideCanvas ? canvas : originalElementFromPoint?.call(document, clientX, clientY) ?? null;
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
try {
|
|
885
|
+
touchTarget.dispatchEvent(event);
|
|
886
|
+
evidence.touchEvents += 1;
|
|
887
|
+
} finally {
|
|
888
|
+
if (originalElementFromPointDescriptor) {
|
|
889
|
+
Object.defineProperty(
|
|
890
|
+
document,
|
|
891
|
+
"elementFromPoint",
|
|
892
|
+
originalElementFromPointDescriptor
|
|
893
|
+
);
|
|
894
|
+
} else {
|
|
895
|
+
Reflect.deleteProperty(document, "elementFromPoint");
|
|
896
|
+
}
|
|
897
|
+
}
|
|
654
898
|
await settleRuntime();
|
|
655
899
|
};
|
|
656
900
|
const input = {
|
|
@@ -668,6 +912,24 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
668
912
|
await dispatchMouse("mousedown", x, y, 1);
|
|
669
913
|
await dispatchMouse("mouseup", x, y, 0);
|
|
670
914
|
}
|
|
915
|
+
},
|
|
916
|
+
keyboard: {
|
|
917
|
+
down: (keyCode, options2) => dispatchKeyboard("keydown", keyCode, options2),
|
|
918
|
+
up: (keyCode, options2) => dispatchKeyboard("keyup", keyCode, options2),
|
|
919
|
+
async press(keyCode, options2) {
|
|
920
|
+
await dispatchKeyboard("keydown", keyCode, options2);
|
|
921
|
+
await dispatchKeyboard("keyup", keyCode, options2);
|
|
922
|
+
}
|
|
923
|
+
},
|
|
924
|
+
touch: {
|
|
925
|
+
start: (x, y, identifier = 1) => dispatchTouch("touchstart", x, y, identifier),
|
|
926
|
+
move: (x, y, identifier = 1) => dispatchTouch("touchmove", x, y, identifier),
|
|
927
|
+
end: (x, y, identifier = 1) => dispatchTouch("touchend", x, y, identifier),
|
|
928
|
+
cancel: (x, y, identifier = 1) => dispatchTouch("touchcancel", x, y, identifier),
|
|
929
|
+
async tap(x, y, identifier = 1) {
|
|
930
|
+
await dispatchTouch("touchstart", x, y, identifier);
|
|
931
|
+
await dispatchTouch("touchend", x, y, identifier);
|
|
932
|
+
}
|
|
671
933
|
}
|
|
672
934
|
};
|
|
673
935
|
const frameDurationMs = 1e3 / (config.fps?.target ?? 60);
|
|
@@ -676,6 +938,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
676
938
|
evidence.frames += 1;
|
|
677
939
|
};
|
|
678
940
|
const stepFrames = (count = 1) => {
|
|
941
|
+
assertStepCount(count, "stepFrames count");
|
|
679
942
|
throwRuntimeError();
|
|
680
943
|
for (let frame = 0; frame < count; frame += 1) stepFrame();
|
|
681
944
|
throwRuntimeError();
|
|
@@ -699,21 +962,59 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
699
962
|
input,
|
|
700
963
|
evidence,
|
|
701
964
|
assertGameplayEvidence(requirements = {}) {
|
|
702
|
-
|
|
703
|
-
|
|
965
|
+
const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
|
|
966
|
+
if (requirements.requireInput && inputEvents === 0) {
|
|
967
|
+
throw new Error(
|
|
968
|
+
"Gameplay evidence is missing real HEADLESS input events."
|
|
969
|
+
);
|
|
704
970
|
}
|
|
705
971
|
if (requirements.requireFrameAdvance && evidence.frames === 0) {
|
|
706
|
-
throw new Error(
|
|
972
|
+
throw new Error(
|
|
973
|
+
"Gameplay evidence is missing complete Phaser frame advancement."
|
|
974
|
+
);
|
|
707
975
|
}
|
|
708
976
|
if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
|
|
709
|
-
throw new Error(
|
|
977
|
+
throw new Error(
|
|
978
|
+
"Gameplay evidence is missing Arcade Physics advancement."
|
|
979
|
+
);
|
|
710
980
|
}
|
|
711
981
|
const requiredTransitions = requirements.requireTransition === void 0 ? [] : typeof requirements.requireTransition === "string" ? [requirements.requireTransition] : requirements.requireTransition;
|
|
712
982
|
for (const target of requiredTransitions) {
|
|
713
983
|
if (!evidence.transitions.some((transition) => transition.to === target)) {
|
|
714
|
-
throw new Error(
|
|
984
|
+
throw new Error(
|
|
985
|
+
`Gameplay evidence is missing a transition to ${target}.`
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const requiredScenes = requirements.requireScene === void 0 ? [] : typeof requirements.requireScene === "string" ? [requirements.requireScene] : requirements.requireScene;
|
|
990
|
+
for (const sceneKey of requiredScenes) {
|
|
991
|
+
if (!evidence.visitedScenes.includes(sceneKey)) {
|
|
992
|
+
throw new Error(
|
|
993
|
+
`Gameplay evidence is missing a visit to Scene ${sceneKey}.`
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
const requiredRestarts = requirements.requireRestart === void 0 ? [] : typeof requirements.requireRestart === "string" ? [requirements.requireRestart] : requirements.requireRestart;
|
|
998
|
+
for (const sceneKey of requiredRestarts) {
|
|
999
|
+
if (!evidence.restartedScenes.includes(sceneKey)) {
|
|
1000
|
+
throw new Error(
|
|
1001
|
+
`Gameplay evidence is missing a restart of Scene ${sceneKey}.`
|
|
1002
|
+
);
|
|
715
1003
|
}
|
|
716
1004
|
}
|
|
1005
|
+
const requiredCheckpoints = requirements.requireCheckpoint === void 0 ? [] : typeof requirements.requireCheckpoint === "string" ? [requirements.requireCheckpoint] : requirements.requireCheckpoint;
|
|
1006
|
+
for (const checkpoint of requiredCheckpoints) {
|
|
1007
|
+
if (!evidence.checkpoints.includes(checkpoint)) {
|
|
1008
|
+
throw new Error(
|
|
1009
|
+
`Gameplay evidence is missing checkpoint ${checkpoint}.`
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (requirements.requireDestroy && !evidence.destroyed) {
|
|
1014
|
+
throw new Error(
|
|
1015
|
+
"Gameplay evidence is missing HEADLESS host destruction."
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
717
1018
|
},
|
|
718
1019
|
stepFrames,
|
|
719
1020
|
async stepFramesAsync(count = 1) {
|
|
@@ -721,6 +1022,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
721
1022
|
await settleRuntime();
|
|
722
1023
|
},
|
|
723
1024
|
stepUntil(condition, maxFrames = 120) {
|
|
1025
|
+
assertStepCount(maxFrames, "stepUntil maxFrames");
|
|
724
1026
|
throwRuntimeError();
|
|
725
1027
|
for (let frame = 0; frame <= maxFrames; frame += 1) {
|
|
726
1028
|
if (condition()) {
|
|
@@ -733,13 +1035,18 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
733
1035
|
}
|
|
734
1036
|
}
|
|
735
1037
|
assertHealth();
|
|
736
|
-
throw new Error(
|
|
1038
|
+
throw new Error(
|
|
1039
|
+
`Condition was not met within ${maxFrames} complete Phaser frames`
|
|
1040
|
+
);
|
|
737
1041
|
},
|
|
738
1042
|
stepPhysics(steps = 1) {
|
|
1043
|
+
assertStepCount(steps, "stepPhysics steps");
|
|
739
1044
|
throwRuntimeError();
|
|
740
1045
|
const world = scene.physics?.world;
|
|
741
1046
|
if (!world || !getArcadeWorldBodies(world) || typeof world.singleStep !== "function") {
|
|
742
|
-
throw new Error(
|
|
1047
|
+
throw new Error(
|
|
1048
|
+
"stepPhysics() requires an Arcade Physics world; use stepFrames() for other configurations."
|
|
1049
|
+
);
|
|
743
1050
|
}
|
|
744
1051
|
for (let step = 0; step < steps; step += 1) {
|
|
745
1052
|
scene.physics.world.singleStep();
|
|
@@ -748,6 +1055,15 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
748
1055
|
throwRuntimeError();
|
|
749
1056
|
assertHealth();
|
|
750
1057
|
},
|
|
1058
|
+
checkpoint(id) {
|
|
1059
|
+
const normalizedId = id.trim();
|
|
1060
|
+
if (!normalizedId || normalizedId.length > 120) {
|
|
1061
|
+
throw new Error(
|
|
1062
|
+
"Gameplay checkpoint id must contain 1 to 120 non-whitespace characters."
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
appendUnique(checkpoints, normalizedId);
|
|
1066
|
+
},
|
|
751
1067
|
assertTextHealth() {
|
|
752
1068
|
assertSceneTextHealth(scene);
|
|
753
1069
|
},
|
|
@@ -760,6 +1076,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
760
1076
|
readyGame.destroy(true);
|
|
761
1077
|
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
762
1078
|
throwRuntimeError();
|
|
1079
|
+
evidence.destroyed = true;
|
|
763
1080
|
} finally {
|
|
764
1081
|
removeRuntimeListeners();
|
|
765
1082
|
restoreHostGuards();
|
|
@@ -769,18 +1086,79 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
769
1086
|
}
|
|
770
1087
|
|
|
771
1088
|
// src/gameplay-contracts.ts
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1089
|
+
var import_vitest = require("vitest");
|
|
1090
|
+
|
|
1091
|
+
// src/gameplay-audit.ts
|
|
1092
|
+
function normalizeList(value) {
|
|
1093
|
+
if (value === void 0) return [];
|
|
1094
|
+
const values = typeof value === "string" ? [value] : value;
|
|
1095
|
+
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
|
|
1096
|
+
}
|
|
1097
|
+
function normalizeSet(values) {
|
|
1098
|
+
return [
|
|
1099
|
+
...new Set(values.map((value) => value.trim()).filter(Boolean))
|
|
1100
|
+
].sort();
|
|
1101
|
+
}
|
|
1102
|
+
function normalizeGameplayRequirements(requirements, scenes) {
|
|
1103
|
+
const requiredScenes = normalizeSet([
|
|
1104
|
+
...scenes,
|
|
1105
|
+
...normalizeList(requirements.requireScene)
|
|
1106
|
+
]);
|
|
1107
|
+
return {
|
|
1108
|
+
requireInput: requirements.requireInput || void 0,
|
|
1109
|
+
requireFrameAdvance: requirements.requireFrameAdvance || void 0,
|
|
1110
|
+
requirePhysicsStep: requirements.requirePhysicsStep || void 0,
|
|
1111
|
+
requireTransition: normalizeList(requirements.requireTransition),
|
|
1112
|
+
requireScene: requiredScenes,
|
|
1113
|
+
requireRestart: normalizeList(requirements.requireRestart),
|
|
1114
|
+
requireCheckpoint: normalizeList(requirements.requireCheckpoint),
|
|
1115
|
+
requireDestroy: true
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
function createGameplayContractMetadata(contract) {
|
|
1119
|
+
const scenes = normalizeSet(contract.scenes);
|
|
1120
|
+
return {
|
|
1121
|
+
id: contract.id.trim(),
|
|
1122
|
+
scenes,
|
|
1123
|
+
requirements: normalizeGameplayRequirements(contract, scenes),
|
|
1124
|
+
verified: false
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// src/gameplay-contracts.ts
|
|
1129
|
+
function snapshotEvidence(evidence) {
|
|
1130
|
+
return {
|
|
1131
|
+
frames: evidence.frames,
|
|
1132
|
+
physicsSteps: evidence.physicsSteps,
|
|
1133
|
+
mouseEvents: evidence.mouseEvents,
|
|
1134
|
+
keyboardEvents: evidence.keyboardEvents,
|
|
1135
|
+
touchEvents: evidence.touchEvents,
|
|
1136
|
+
clicks: evidence.clicks,
|
|
1137
|
+
registeredScenes: [...evidence.registeredScenes],
|
|
1138
|
+
visitedScenes: [...evidence.visitedScenes],
|
|
1139
|
+
restartedScenes: [...evidence.restartedScenes],
|
|
1140
|
+
checkpoints: [...evidence.checkpoints],
|
|
1141
|
+
destroyed: evidence.destroyed,
|
|
1142
|
+
transitions: evidence.transitions.map((transition) => ({ ...transition }))
|
|
782
1143
|
};
|
|
783
|
-
|
|
1144
|
+
}
|
|
1145
|
+
function readStaticMetadata(context, options) {
|
|
1146
|
+
const metadata = context.task.meta.gameplayContract;
|
|
1147
|
+
if (!metadata || typeof metadata !== "object") return void 0;
|
|
1148
|
+
const candidate = metadata;
|
|
1149
|
+
if (candidate.id !== options.id) {
|
|
1150
|
+
throw new Error(
|
|
1151
|
+
`\u9759\u6001 gameplay contract "${candidate.id}" \u4E0E\u8FD0\u884C\u65F6 contract "${options.id}" \u4E0D\u4E00\u81F4\u3002`
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
return candidate;
|
|
1155
|
+
}
|
|
1156
|
+
function registerGameplayContract(context, options) {
|
|
1157
|
+
const metadata = readStaticMetadata(context, options) ?? createGameplayContractMetadata({
|
|
1158
|
+
...options,
|
|
1159
|
+
scenes: options.scenes ?? []
|
|
1160
|
+
});
|
|
1161
|
+
context.task.meta.gameplayContract = metadata;
|
|
784
1162
|
let host;
|
|
785
1163
|
let verified = false;
|
|
786
1164
|
let testFailed = false;
|
|
@@ -790,16 +1168,13 @@ function registerGameplayContract(context, options) {
|
|
|
790
1168
|
const verify = () => {
|
|
791
1169
|
if (verified) return;
|
|
792
1170
|
if (!host) {
|
|
793
|
-
throw new Error(
|
|
1171
|
+
throw new Error(
|
|
1172
|
+
`Gameplay contract "${options.id}" \u6CA1\u6709\u7ED1\u5B9A HEADLESS host\u3002`
|
|
1173
|
+
);
|
|
794
1174
|
}
|
|
795
1175
|
host.assertGameplayEvidence(metadata.requirements);
|
|
796
|
-
metadata.evidence =
|
|
797
|
-
|
|
798
|
-
physicsSteps: host.evidence.physicsSteps,
|
|
799
|
-
mouseEvents: host.evidence.mouseEvents,
|
|
800
|
-
clicks: host.evidence.clicks,
|
|
801
|
-
transitions: host.evidence.transitions.map((transition) => ({ ...transition }))
|
|
802
|
-
};
|
|
1176
|
+
metadata.evidence = snapshotEvidence(host.evidence);
|
|
1177
|
+
metadata.verified = true;
|
|
803
1178
|
verified = true;
|
|
804
1179
|
};
|
|
805
1180
|
context.onTestFinished(() => {
|
|
@@ -809,13 +1184,25 @@ function registerGameplayContract(context, options) {
|
|
|
809
1184
|
return {
|
|
810
1185
|
attachHost(currentHost) {
|
|
811
1186
|
if (host) {
|
|
812
|
-
throw new Error(
|
|
1187
|
+
throw new Error(
|
|
1188
|
+
`Gameplay contract "${options.id}" \u91CD\u590D\u7ED1\u5B9A\u4E86 HEADLESS host\u3002`
|
|
1189
|
+
);
|
|
813
1190
|
}
|
|
814
1191
|
host = currentHost;
|
|
815
1192
|
},
|
|
816
1193
|
verify
|
|
817
1194
|
};
|
|
818
1195
|
}
|
|
1196
|
+
function gameplayTest(name, options, run) {
|
|
1197
|
+
const metadata = createGameplayContractMetadata(options);
|
|
1198
|
+
(0, import_vitest.test)(name, {
|
|
1199
|
+
...options.testOptions,
|
|
1200
|
+
meta: { gameplayContract: metadata }
|
|
1201
|
+
}, async (context) => {
|
|
1202
|
+
const contract = registerGameplayContract(context, options);
|
|
1203
|
+
await run({ context, contract });
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
819
1206
|
// Annotate the CommonJS export names for ESM import in node:
|
|
820
1207
|
0 && (module.exports = {
|
|
821
1208
|
assertSceneInteractiveHealth,
|
|
@@ -823,5 +1210,6 @@ function registerGameplayContract(context, options) {
|
|
|
823
1210
|
assertSceneTextHealth,
|
|
824
1211
|
collectMissingBitmapGlyphs,
|
|
825
1212
|
createHeadlessGame,
|
|
1213
|
+
gameplayTest,
|
|
826
1214
|
registerGameplayContract
|
|
827
1215
|
});
|