miaoda-game-devkit 0.1.0 → 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 +44 -14
- package/bin/miaoda-game-lint.js +1 -1
- package/dist/cli/lint.js +1 -1
- 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 +36 -0
- package/dist/lint/gameplay-audit.contract.mjs +426 -0
- package/dist/lint/gameplay-contract.contract.mjs +1227 -0
- package/dist/lint/phaser-headless.contract.mjs +1575 -0
- package/dist/lint/phaser-text-assertions.contract.mjs +32 -0
- package/dist/lint/resource-import-plugin.contract.mjs +97 -0
- package/dist/lint/setup.mjs +31 -34
- package/dist/lint/vitest-config.contract.mjs +471 -0
- 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.mjs
CHANGED
|
@@ -21,7 +21,9 @@ function isEffectivelyRenderable(label) {
|
|
|
21
21
|
}
|
|
22
22
|
function requireFinite(problems, label, property, value) {
|
|
23
23
|
if (!Number.isFinite(value)) {
|
|
24
|
-
problems.push(
|
|
24
|
+
problems.push(
|
|
25
|
+
`${describeText(label)} has non-finite ${property}: ${String(value)}`
|
|
26
|
+
);
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
function collectSceneTexts(scene) {
|
|
@@ -75,7 +77,9 @@ function collectMissingBitmapGlyphs(text, chars) {
|
|
|
75
77
|
const code = text.charCodeAt(index);
|
|
76
78
|
if (seen.has(code) || chars[code] !== void 0) continue;
|
|
77
79
|
seen.add(code);
|
|
78
|
-
missing.push(
|
|
80
|
+
missing.push(
|
|
81
|
+
`${JSON.stringify(character)} (U+${code.toString(16).toUpperCase().padStart(4, "0")})`
|
|
82
|
+
);
|
|
79
83
|
}
|
|
80
84
|
return missing;
|
|
81
85
|
}
|
|
@@ -100,19 +104,29 @@ function collectTextHealthProblems(scene) {
|
|
|
100
104
|
requireFinite(problems, label, "resolution", label.style.resolution);
|
|
101
105
|
const requiresPositiveSize = /\S/u.test(label.text) && isEffectivelyRenderable(label);
|
|
102
106
|
if (requiresPositiveSize && Number.isFinite(label.width) && label.width <= 0) {
|
|
103
|
-
problems.push(
|
|
107
|
+
problems.push(
|
|
108
|
+
`${describeText(label)} has non-positive width: ${label.width}`
|
|
109
|
+
);
|
|
104
110
|
}
|
|
105
111
|
if (requiresPositiveSize && Number.isFinite(label.height) && label.height <= 0) {
|
|
106
|
-
problems.push(
|
|
112
|
+
problems.push(
|
|
113
|
+
`${describeText(label)} has non-positive height: ${label.height}`
|
|
114
|
+
);
|
|
107
115
|
}
|
|
108
116
|
if (Number.isFinite(label.style.fixedWidth) && label.style.fixedWidth < 0) {
|
|
109
|
-
problems.push(
|
|
117
|
+
problems.push(
|
|
118
|
+
`${describeText(label)} has negative fixedWidth: ${label.style.fixedWidth}`
|
|
119
|
+
);
|
|
110
120
|
}
|
|
111
121
|
if (Number.isFinite(label.style.fixedHeight) && label.style.fixedHeight < 0) {
|
|
112
|
-
problems.push(
|
|
122
|
+
problems.push(
|
|
123
|
+
`${describeText(label)} has negative fixedHeight: ${label.style.fixedHeight}`
|
|
124
|
+
);
|
|
113
125
|
}
|
|
114
126
|
if (Number.isFinite(label.style.resolution) && label.style.resolution <= 0) {
|
|
115
|
-
problems.push(
|
|
127
|
+
problems.push(
|
|
128
|
+
`${describeText(label)} has non-positive resolution: ${label.style.resolution}`
|
|
129
|
+
);
|
|
116
130
|
}
|
|
117
131
|
const bounds = label.getBounds();
|
|
118
132
|
for (const [property, value] of Object.entries({
|
|
@@ -146,11 +160,13 @@ function assertSceneTextHealth(scene) {
|
|
|
146
160
|
...collectBitmapTextHealthProblems(scene)
|
|
147
161
|
];
|
|
148
162
|
if (problems.length === 0) return;
|
|
149
|
-
throw new Error(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
163
|
+
throw new Error(
|
|
164
|
+
[
|
|
165
|
+
`Phaser text health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
166
|
+
...problems.map((problem) => `- ${problem}`),
|
|
167
|
+
"Fix optional TextStyle values by omitting them or using Phaser defaults (for example fixedWidth: value ?? 0)."
|
|
168
|
+
].join("\n")
|
|
169
|
+
);
|
|
154
170
|
}
|
|
155
171
|
|
|
156
172
|
// src/phaser-runtime-assertions.ts
|
|
@@ -162,8 +178,10 @@ function collectObjects(scene) {
|
|
|
162
178
|
if (visited.has(object)) return;
|
|
163
179
|
visited.add(object);
|
|
164
180
|
objects.push(object);
|
|
165
|
-
if (object instanceof Phaser2.GameObjects.Container)
|
|
166
|
-
|
|
181
|
+
if (object instanceof Phaser2.GameObjects.Container)
|
|
182
|
+
object.list.forEach(visit);
|
|
183
|
+
if (object instanceof Phaser2.GameObjects.Layer)
|
|
184
|
+
object.getChildren().forEach(visit);
|
|
167
185
|
};
|
|
168
186
|
scene.children.getChildren().forEach(visit);
|
|
169
187
|
return objects;
|
|
@@ -197,7 +215,9 @@ function requireFiniteVector(problems, subject, target, property) {
|
|
|
197
215
|
if (!(axis in vector)) continue;
|
|
198
216
|
const value = Reflect.get(vector, axis);
|
|
199
217
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
200
|
-
problems.push(
|
|
218
|
+
problems.push(
|
|
219
|
+
`${subject} has non-finite ${property}.${axis}: ${String(value)}`
|
|
220
|
+
);
|
|
201
221
|
}
|
|
202
222
|
}
|
|
203
223
|
}
|
|
@@ -265,7 +285,9 @@ function getArcadeBodyCollection(value) {
|
|
|
265
285
|
function getArcadeWorldBodies(world) {
|
|
266
286
|
if (!world || typeof world !== "object") return void 0;
|
|
267
287
|
const bodies = getArcadeBodyCollection(Reflect.get(world, "bodies"));
|
|
268
|
-
const staticBodies = getArcadeBodyCollection(
|
|
288
|
+
const staticBodies = getArcadeBodyCollection(
|
|
289
|
+
Reflect.get(world, "staticBodies")
|
|
290
|
+
);
|
|
269
291
|
return bodies && staticBodies ? [...bodies, ...staticBodies] : void 0;
|
|
270
292
|
}
|
|
271
293
|
function collectArcadeBodyHealthProblems(scene) {
|
|
@@ -276,22 +298,38 @@ function collectArcadeBodyHealthProblems(scene) {
|
|
|
276
298
|
if (body.enable === false) continue;
|
|
277
299
|
const gameObject = body.gameObject;
|
|
278
300
|
const subject = gameObject ? `Arcade Body for ${describeObject(gameObject)}` : "Arcade Body";
|
|
279
|
-
for (const property of [
|
|
301
|
+
for (const property of [
|
|
302
|
+
"position",
|
|
303
|
+
"velocity",
|
|
304
|
+
"acceleration",
|
|
305
|
+
"gravity",
|
|
306
|
+
"offset",
|
|
307
|
+
"center"
|
|
308
|
+
]) {
|
|
280
309
|
requireFiniteVector(problems, subject, body, property);
|
|
281
310
|
}
|
|
282
|
-
for (const property of [
|
|
311
|
+
for (const property of [
|
|
312
|
+
"width",
|
|
313
|
+
"height",
|
|
314
|
+
"halfWidth",
|
|
315
|
+
"halfHeight",
|
|
316
|
+
"rotation"
|
|
317
|
+
]) {
|
|
283
318
|
requireFiniteProperty(problems, subject, body, property);
|
|
284
319
|
}
|
|
285
320
|
}
|
|
286
321
|
return problems;
|
|
287
322
|
}
|
|
288
323
|
function usesCustomHitArea(object) {
|
|
289
|
-
return Boolean(
|
|
324
|
+
return Boolean(
|
|
325
|
+
object.input?.customHitArea
|
|
326
|
+
);
|
|
290
327
|
}
|
|
291
328
|
function collectInteractiveHealthProblems(scene) {
|
|
292
329
|
const problems = [];
|
|
293
330
|
for (const object of collectObjects(scene)) {
|
|
294
|
-
if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled)
|
|
331
|
+
if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled)
|
|
332
|
+
continue;
|
|
295
333
|
if (usesCustomHitArea(object)) continue;
|
|
296
334
|
const hitArea = object.input.hitArea;
|
|
297
335
|
if (!hitArea) {
|
|
@@ -314,20 +352,24 @@ function assertSceneRuntimeHealth(scene) {
|
|
|
314
352
|
...collectArcadeBodyHealthProblems(scene)
|
|
315
353
|
];
|
|
316
354
|
if (problems.length === 0) return;
|
|
317
|
-
throw new Error(
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
355
|
+
throw new Error(
|
|
356
|
+
[
|
|
357
|
+
`Phaser runtime health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
358
|
+
...problems.map((problem) => `- ${problem}`),
|
|
359
|
+
"Keep values consumed by Phaser finite; zero size and zero scale remain allowed."
|
|
360
|
+
].join("\n")
|
|
361
|
+
);
|
|
322
362
|
}
|
|
323
363
|
function assertSceneInteractiveHealth(scene) {
|
|
324
364
|
const problems = collectInteractiveHealthProblems(scene);
|
|
325
365
|
if (problems.length === 0) return;
|
|
326
|
-
throw new Error(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
366
|
+
throw new Error(
|
|
367
|
+
[
|
|
368
|
+
`Phaser interactive health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
369
|
+
...problems.map((problem) => `- ${problem}`),
|
|
370
|
+
"Fix the default hit area or disable input before making the object interactive."
|
|
371
|
+
].join("\n")
|
|
372
|
+
);
|
|
331
373
|
}
|
|
332
374
|
|
|
333
375
|
// src/phaser-headless-host.ts
|
|
@@ -342,11 +384,32 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
342
384
|
let removeRuntimeListeners = () => {
|
|
343
385
|
};
|
|
344
386
|
const transitions = [];
|
|
387
|
+
const registeredScenes = [];
|
|
388
|
+
const visitedScenes = [];
|
|
389
|
+
const restartedScenes = [];
|
|
390
|
+
const checkpoints = [];
|
|
391
|
+
const appendUnique = (values, value) => {
|
|
392
|
+
if (value && !values.includes(value)) values.push(value);
|
|
393
|
+
};
|
|
394
|
+
const assertStepCount = (value, name) => {
|
|
395
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`${name} must be a non-negative safe integer; received ${value}`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
345
401
|
const evidence = {
|
|
346
402
|
frames: 0,
|
|
347
403
|
physicsSteps: 0,
|
|
348
404
|
mouseEvents: 0,
|
|
405
|
+
keyboardEvents: 0,
|
|
406
|
+
touchEvents: 0,
|
|
349
407
|
clicks: 0,
|
|
408
|
+
registeredScenes,
|
|
409
|
+
visitedScenes,
|
|
410
|
+
restartedScenes,
|
|
411
|
+
checkpoints,
|
|
412
|
+
destroyed: false,
|
|
350
413
|
transitions
|
|
351
414
|
};
|
|
352
415
|
const created = new Promise((resolve, reject) => {
|
|
@@ -392,7 +455,9 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
392
455
|
});
|
|
393
456
|
};
|
|
394
457
|
const timeout = window.setTimeout(() => {
|
|
395
|
-
finish(
|
|
458
|
+
finish(
|
|
459
|
+
new Error(`Phaser HEADLESS boot timed out after ${bootTimeoutMs}ms`)
|
|
460
|
+
);
|
|
396
461
|
}, bootTimeoutMs);
|
|
397
462
|
window.addEventListener("error", onWindowError, true);
|
|
398
463
|
window.addEventListener("unhandledrejection", onUnhandledRejection);
|
|
@@ -404,7 +469,13 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
404
469
|
if (guardedScenes.has(currentScene)) return;
|
|
405
470
|
guardedScenes.add(currentScene);
|
|
406
471
|
const scenePlugin = currentScene.scene;
|
|
407
|
-
for (const method of [
|
|
472
|
+
for (const method of [
|
|
473
|
+
"start",
|
|
474
|
+
"launch",
|
|
475
|
+
"switch",
|
|
476
|
+
"sleep",
|
|
477
|
+
"wake"
|
|
478
|
+
]) {
|
|
408
479
|
const original = scenePlugin[method];
|
|
409
480
|
if (typeof original !== "function") continue;
|
|
410
481
|
scenePlugin[method] = (key, ...args) => {
|
|
@@ -420,29 +491,49 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
420
491
|
scenePlugin[method] = original;
|
|
421
492
|
});
|
|
422
493
|
}
|
|
494
|
+
const originalRestart = scenePlugin.restart;
|
|
495
|
+
if (typeof originalRestart === "function") {
|
|
496
|
+
scenePlugin.restart = (...args) => {
|
|
497
|
+
appendUnique(restartedScenes, currentScene.sys.settings.key);
|
|
498
|
+
return Reflect.apply(originalRestart, currentScene.scene, args);
|
|
499
|
+
};
|
|
500
|
+
restoreGuards.push(() => {
|
|
501
|
+
scenePlugin.restart = originalRestart;
|
|
502
|
+
});
|
|
503
|
+
}
|
|
423
504
|
for (const hook of ["init", "preload", "create", "update"]) {
|
|
424
505
|
const original = Reflect.get(currentScene, hook);
|
|
425
506
|
if (typeof original !== "function") continue;
|
|
426
|
-
const ownDescriptor = Object.getOwnPropertyDescriptor(
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
507
|
+
const ownDescriptor = Object.getOwnPropertyDescriptor(
|
|
508
|
+
currentScene,
|
|
509
|
+
hook
|
|
510
|
+
);
|
|
511
|
+
Reflect.set(
|
|
512
|
+
currentScene,
|
|
513
|
+
hook,
|
|
514
|
+
function guardedLifecycleHook(...args) {
|
|
515
|
+
try {
|
|
516
|
+
const result = Reflect.apply(original, this, args);
|
|
517
|
+
if (result && typeof result === "object" && "then" in result) {
|
|
518
|
+
const completion = Promise.resolve(result);
|
|
519
|
+
if (currentScene === scene && hook === "create" && !settled) {
|
|
520
|
+
completion.then(
|
|
521
|
+
() => window.setTimeout(() => finish(), 0),
|
|
522
|
+
reportError
|
|
523
|
+
);
|
|
524
|
+
} else {
|
|
525
|
+
completion.catch(reportError);
|
|
526
|
+
}
|
|
527
|
+
} else if (currentScene === scene && hook === "create" && !settled) {
|
|
528
|
+
window.setTimeout(() => finish(), 0);
|
|
436
529
|
}
|
|
437
|
-
|
|
438
|
-
|
|
530
|
+
return result;
|
|
531
|
+
} catch (error) {
|
|
532
|
+
reportError(error);
|
|
533
|
+
return void 0;
|
|
439
534
|
}
|
|
440
|
-
return result;
|
|
441
|
-
} catch (error) {
|
|
442
|
-
reportError(error);
|
|
443
|
-
return void 0;
|
|
444
535
|
}
|
|
445
|
-
|
|
536
|
+
);
|
|
446
537
|
restoreGuards.push(() => {
|
|
447
538
|
if (ownDescriptor) {
|
|
448
539
|
Object.defineProperty(currentScene, hook, ownDescriptor);
|
|
@@ -471,13 +562,6 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
471
562
|
banner: false,
|
|
472
563
|
autoFocus: false,
|
|
473
564
|
seed: ["phaser-headless-test"],
|
|
474
|
-
physics: {
|
|
475
|
-
default: "arcade",
|
|
476
|
-
arcade: {
|
|
477
|
-
gravity: { x: 0, y: 0 },
|
|
478
|
-
fixedStep: true
|
|
479
|
-
}
|
|
480
|
-
},
|
|
481
565
|
...config,
|
|
482
566
|
type: Phaser3.HEADLESS,
|
|
483
567
|
fps,
|
|
@@ -503,7 +587,10 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
503
587
|
};
|
|
504
588
|
bootedGame.events.on(Phaser3.Core.Events.POST_STEP, checkReadiness);
|
|
505
589
|
removeReadinessCheck = () => {
|
|
506
|
-
bootedGame.events.off(
|
|
590
|
+
bootedGame.events.off(
|
|
591
|
+
Phaser3.Core.Events.POST_STEP,
|
|
592
|
+
checkReadiness
|
|
593
|
+
);
|
|
507
594
|
};
|
|
508
595
|
}
|
|
509
596
|
},
|
|
@@ -543,7 +630,9 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
543
630
|
restoreHostGuards();
|
|
544
631
|
readyGame.destroy(true);
|
|
545
632
|
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
546
|
-
throw new Error(
|
|
633
|
+
throw new Error(
|
|
634
|
+
"Phaser HEADLESS input canvas is not attached to a DOM Window"
|
|
635
|
+
);
|
|
547
636
|
}
|
|
548
637
|
let canvasBounds = {
|
|
549
638
|
left: 0,
|
|
@@ -579,7 +668,9 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
579
668
|
setCanvasBounds(canvasBounds);
|
|
580
669
|
const gameToClient = (x, y) => {
|
|
581
670
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
582
|
-
throw new Error(
|
|
671
|
+
throw new Error(
|
|
672
|
+
`HEADLESS input coordinates must be finite; received (${x}, ${y})`
|
|
673
|
+
);
|
|
583
674
|
}
|
|
584
675
|
return {
|
|
585
676
|
clientX: canvasBounds.left + x / readyGame.scale.displayScale.x,
|
|
@@ -588,7 +679,10 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
588
679
|
};
|
|
589
680
|
const assertHealth = () => {
|
|
590
681
|
for (const currentScene of readyGame.scene.getScenes(false)) {
|
|
591
|
-
|
|
682
|
+
appendUnique(registeredScenes, currentScene.sys.settings.key);
|
|
683
|
+
if (!currentScene.sys.isActive() && !currentScene.sys.isPaused())
|
|
684
|
+
continue;
|
|
685
|
+
appendUnique(visitedScenes, currentScene.sys.settings.key);
|
|
592
686
|
assertSceneTextHealth(currentScene);
|
|
593
687
|
assertSceneInteractiveHealth(currentScene);
|
|
594
688
|
assertSceneRuntimeHealth(currentScene);
|
|
@@ -599,17 +693,166 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
599
693
|
throwRuntimeError();
|
|
600
694
|
assertHealth();
|
|
601
695
|
};
|
|
696
|
+
const getEventTarget = (target, kind) => {
|
|
697
|
+
if (!target || typeof Reflect.get(target, "dispatchEvent") !== "function") {
|
|
698
|
+
throw new Error(
|
|
699
|
+
`Phaser HEADLESS ${kind} input target cannot dispatch DOM events`
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
return target;
|
|
703
|
+
};
|
|
602
704
|
const dispatchMouse = async (type, x, y, buttons) => {
|
|
603
705
|
const { clientX, clientY } = gameToClient(x, y);
|
|
604
|
-
|
|
706
|
+
const mouseTarget = getEventTarget(readyGame.input.mouse?.target, "mouse");
|
|
707
|
+
mouseTarget.dispatchEvent(
|
|
708
|
+
new inputWindow.MouseEvent(type, {
|
|
709
|
+
bubbles: true,
|
|
710
|
+
cancelable: true,
|
|
711
|
+
button: 0,
|
|
712
|
+
buttons,
|
|
713
|
+
clientX,
|
|
714
|
+
clientY
|
|
715
|
+
})
|
|
716
|
+
);
|
|
717
|
+
evidence.mouseEvents += 1;
|
|
718
|
+
await settleRuntime();
|
|
719
|
+
};
|
|
720
|
+
const keyIdentity = (keyCode) => {
|
|
721
|
+
if (keyCode >= 65 && keyCode <= 90) {
|
|
722
|
+
const letter = String.fromCharCode(keyCode);
|
|
723
|
+
return { key: letter.toLowerCase(), code: `Key${letter}` };
|
|
724
|
+
}
|
|
725
|
+
if (keyCode >= 48 && keyCode <= 57) {
|
|
726
|
+
const digit = String.fromCharCode(keyCode);
|
|
727
|
+
return { key: digit, code: `Digit${digit}` };
|
|
728
|
+
}
|
|
729
|
+
const identities = /* @__PURE__ */ new Map([
|
|
730
|
+
[
|
|
731
|
+
Phaser3.Input.Keyboard.KeyCodes.LEFT,
|
|
732
|
+
{ key: "ArrowLeft", code: "ArrowLeft" }
|
|
733
|
+
],
|
|
734
|
+
[
|
|
735
|
+
Phaser3.Input.Keyboard.KeyCodes.RIGHT,
|
|
736
|
+
{ key: "ArrowRight", code: "ArrowRight" }
|
|
737
|
+
],
|
|
738
|
+
[Phaser3.Input.Keyboard.KeyCodes.UP, { key: "ArrowUp", code: "ArrowUp" }],
|
|
739
|
+
[
|
|
740
|
+
Phaser3.Input.Keyboard.KeyCodes.DOWN,
|
|
741
|
+
{ key: "ArrowDown", code: "ArrowDown" }
|
|
742
|
+
],
|
|
743
|
+
[Phaser3.Input.Keyboard.KeyCodes.SPACE, { key: " ", code: "Space" }],
|
|
744
|
+
[Phaser3.Input.Keyboard.KeyCodes.ENTER, { key: "Enter", code: "Enter" }],
|
|
745
|
+
[Phaser3.Input.Keyboard.KeyCodes.ESC, { key: "Escape", code: "Escape" }]
|
|
746
|
+
]);
|
|
747
|
+
return identities.get(keyCode) ?? { key: "", code: "" };
|
|
748
|
+
};
|
|
749
|
+
const dispatchKeyboard = async (type, keyCode, options2 = {}) => {
|
|
750
|
+
if (!Number.isInteger(keyCode) || keyCode < 0) {
|
|
751
|
+
throw new Error(
|
|
752
|
+
`HEADLESS keyboard keyCode must be a non-negative integer; received ${keyCode}`
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
const identity = keyIdentity(keyCode);
|
|
756
|
+
const event = new inputWindow.KeyboardEvent(type, {
|
|
605
757
|
bubbles: true,
|
|
606
758
|
cancelable: true,
|
|
607
|
-
|
|
608
|
-
|
|
759
|
+
key: options2.key ?? identity.key,
|
|
760
|
+
code: options2.code ?? identity.code,
|
|
761
|
+
repeat: options2.repeat ?? false,
|
|
762
|
+
altKey: options2.altKey ?? false,
|
|
763
|
+
ctrlKey: options2.ctrlKey ?? false,
|
|
764
|
+
metaKey: options2.metaKey ?? false,
|
|
765
|
+
shiftKey: options2.shiftKey ?? false
|
|
766
|
+
});
|
|
767
|
+
Object.defineProperties(event, {
|
|
768
|
+
// Phaser KeyboardManager 仍使用这些兼容旧浏览器的数字字段。
|
|
769
|
+
keyCode: { value: keyCode },
|
|
770
|
+
which: { value: keyCode }
|
|
771
|
+
});
|
|
772
|
+
const keyboardTarget = getEventTarget(
|
|
773
|
+
readyGame.input.keyboard?.target,
|
|
774
|
+
"keyboard"
|
|
775
|
+
);
|
|
776
|
+
keyboardTarget.dispatchEvent(event);
|
|
777
|
+
evidence.keyboardEvents += 1;
|
|
778
|
+
await settleRuntime();
|
|
779
|
+
};
|
|
780
|
+
const activeTouches = /* @__PURE__ */ new Map();
|
|
781
|
+
const createTouch = (x, y, identifier, target) => {
|
|
782
|
+
if (!Number.isInteger(identifier) || identifier < 0) {
|
|
783
|
+
throw new Error(
|
|
784
|
+
`HEADLESS touch identifier must be a non-negative integer; received ${identifier}`
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
const { clientX, clientY } = gameToClient(x, y);
|
|
788
|
+
return {
|
|
789
|
+
identifier,
|
|
790
|
+
target,
|
|
609
791
|
clientX,
|
|
610
|
-
clientY
|
|
611
|
-
|
|
612
|
-
|
|
792
|
+
clientY,
|
|
793
|
+
pageX: clientX,
|
|
794
|
+
pageY: clientY,
|
|
795
|
+
screenX: clientX,
|
|
796
|
+
screenY: clientY,
|
|
797
|
+
radiusX: 1,
|
|
798
|
+
radiusY: 1,
|
|
799
|
+
rotationAngle: 0,
|
|
800
|
+
force: 1
|
|
801
|
+
};
|
|
802
|
+
};
|
|
803
|
+
const dispatchTouch = async (type, x, y, identifier) => {
|
|
804
|
+
if (type === "touchstart" && activeTouches.has(identifier)) {
|
|
805
|
+
throw new Error(
|
|
806
|
+
`HEADLESS touch identifier ${identifier} is already active`
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
if (type !== "touchstart" && !activeTouches.has(identifier)) {
|
|
810
|
+
throw new Error(`HEADLESS touch identifier ${identifier} is not active`);
|
|
811
|
+
}
|
|
812
|
+
const touchTarget = getEventTarget(readyGame.input.touch?.target, "touch");
|
|
813
|
+
const changedTouch = createTouch(x, y, identifier, touchTarget);
|
|
814
|
+
if (type === "touchstart" || type === "touchmove") {
|
|
815
|
+
activeTouches.set(identifier, changedTouch);
|
|
816
|
+
} else {
|
|
817
|
+
activeTouches.delete(identifier);
|
|
818
|
+
}
|
|
819
|
+
const touches = [...activeTouches.values()];
|
|
820
|
+
const event = new inputWindow.Event(type, {
|
|
821
|
+
bubbles: true,
|
|
822
|
+
cancelable: true
|
|
823
|
+
});
|
|
824
|
+
Object.defineProperties(event, {
|
|
825
|
+
changedTouches: { value: [changedTouch] },
|
|
826
|
+
targetTouches: { value: touches },
|
|
827
|
+
touches: { value: touches }
|
|
828
|
+
});
|
|
829
|
+
const document = inputWindow.document;
|
|
830
|
+
const originalElementFromPoint = document.elementFromPoint;
|
|
831
|
+
const originalElementFromPointDescriptor = Object.getOwnPropertyDescriptor(
|
|
832
|
+
document,
|
|
833
|
+
"elementFromPoint"
|
|
834
|
+
);
|
|
835
|
+
Object.defineProperty(document, "elementFromPoint", {
|
|
836
|
+
configurable: true,
|
|
837
|
+
value(clientX, clientY) {
|
|
838
|
+
const insideCanvas = clientX >= canvasBounds.left && clientX <= canvasBounds.left + canvasBounds.width && clientY >= canvasBounds.top && clientY <= canvasBounds.top + canvasBounds.height;
|
|
839
|
+
return insideCanvas ? canvas : originalElementFromPoint?.call(document, clientX, clientY) ?? null;
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
try {
|
|
843
|
+
touchTarget.dispatchEvent(event);
|
|
844
|
+
evidence.touchEvents += 1;
|
|
845
|
+
} finally {
|
|
846
|
+
if (originalElementFromPointDescriptor) {
|
|
847
|
+
Object.defineProperty(
|
|
848
|
+
document,
|
|
849
|
+
"elementFromPoint",
|
|
850
|
+
originalElementFromPointDescriptor
|
|
851
|
+
);
|
|
852
|
+
} else {
|
|
853
|
+
Reflect.deleteProperty(document, "elementFromPoint");
|
|
854
|
+
}
|
|
855
|
+
}
|
|
613
856
|
await settleRuntime();
|
|
614
857
|
};
|
|
615
858
|
const input = {
|
|
@@ -627,6 +870,24 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
627
870
|
await dispatchMouse("mousedown", x, y, 1);
|
|
628
871
|
await dispatchMouse("mouseup", x, y, 0);
|
|
629
872
|
}
|
|
873
|
+
},
|
|
874
|
+
keyboard: {
|
|
875
|
+
down: (keyCode, options2) => dispatchKeyboard("keydown", keyCode, options2),
|
|
876
|
+
up: (keyCode, options2) => dispatchKeyboard("keyup", keyCode, options2),
|
|
877
|
+
async press(keyCode, options2) {
|
|
878
|
+
await dispatchKeyboard("keydown", keyCode, options2);
|
|
879
|
+
await dispatchKeyboard("keyup", keyCode, options2);
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
touch: {
|
|
883
|
+
start: (x, y, identifier = 1) => dispatchTouch("touchstart", x, y, identifier),
|
|
884
|
+
move: (x, y, identifier = 1) => dispatchTouch("touchmove", x, y, identifier),
|
|
885
|
+
end: (x, y, identifier = 1) => dispatchTouch("touchend", x, y, identifier),
|
|
886
|
+
cancel: (x, y, identifier = 1) => dispatchTouch("touchcancel", x, y, identifier),
|
|
887
|
+
async tap(x, y, identifier = 1) {
|
|
888
|
+
await dispatchTouch("touchstart", x, y, identifier);
|
|
889
|
+
await dispatchTouch("touchend", x, y, identifier);
|
|
890
|
+
}
|
|
630
891
|
}
|
|
631
892
|
};
|
|
632
893
|
const frameDurationMs = 1e3 / (config.fps?.target ?? 60);
|
|
@@ -635,6 +896,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
635
896
|
evidence.frames += 1;
|
|
636
897
|
};
|
|
637
898
|
const stepFrames = (count = 1) => {
|
|
899
|
+
assertStepCount(count, "stepFrames count");
|
|
638
900
|
throwRuntimeError();
|
|
639
901
|
for (let frame = 0; frame < count; frame += 1) stepFrame();
|
|
640
902
|
throwRuntimeError();
|
|
@@ -658,21 +920,59 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
658
920
|
input,
|
|
659
921
|
evidence,
|
|
660
922
|
assertGameplayEvidence(requirements = {}) {
|
|
661
|
-
|
|
662
|
-
|
|
923
|
+
const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
|
|
924
|
+
if (requirements.requireInput && inputEvents === 0) {
|
|
925
|
+
throw new Error(
|
|
926
|
+
"Gameplay evidence is missing real HEADLESS input events."
|
|
927
|
+
);
|
|
663
928
|
}
|
|
664
929
|
if (requirements.requireFrameAdvance && evidence.frames === 0) {
|
|
665
|
-
throw new Error(
|
|
930
|
+
throw new Error(
|
|
931
|
+
"Gameplay evidence is missing complete Phaser frame advancement."
|
|
932
|
+
);
|
|
666
933
|
}
|
|
667
934
|
if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
|
|
668
|
-
throw new Error(
|
|
935
|
+
throw new Error(
|
|
936
|
+
"Gameplay evidence is missing Arcade Physics advancement."
|
|
937
|
+
);
|
|
669
938
|
}
|
|
670
939
|
const requiredTransitions = requirements.requireTransition === void 0 ? [] : typeof requirements.requireTransition === "string" ? [requirements.requireTransition] : requirements.requireTransition;
|
|
671
940
|
for (const target of requiredTransitions) {
|
|
672
941
|
if (!evidence.transitions.some((transition) => transition.to === target)) {
|
|
673
|
-
throw new Error(
|
|
942
|
+
throw new Error(
|
|
943
|
+
`Gameplay evidence is missing a transition to ${target}.`
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
const requiredScenes = requirements.requireScene === void 0 ? [] : typeof requirements.requireScene === "string" ? [requirements.requireScene] : requirements.requireScene;
|
|
948
|
+
for (const sceneKey of requiredScenes) {
|
|
949
|
+
if (!evidence.visitedScenes.includes(sceneKey)) {
|
|
950
|
+
throw new Error(
|
|
951
|
+
`Gameplay evidence is missing a visit to Scene ${sceneKey}.`
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const requiredRestarts = requirements.requireRestart === void 0 ? [] : typeof requirements.requireRestart === "string" ? [requirements.requireRestart] : requirements.requireRestart;
|
|
956
|
+
for (const sceneKey of requiredRestarts) {
|
|
957
|
+
if (!evidence.restartedScenes.includes(sceneKey)) {
|
|
958
|
+
throw new Error(
|
|
959
|
+
`Gameplay evidence is missing a restart of Scene ${sceneKey}.`
|
|
960
|
+
);
|
|
674
961
|
}
|
|
675
962
|
}
|
|
963
|
+
const requiredCheckpoints = requirements.requireCheckpoint === void 0 ? [] : typeof requirements.requireCheckpoint === "string" ? [requirements.requireCheckpoint] : requirements.requireCheckpoint;
|
|
964
|
+
for (const checkpoint of requiredCheckpoints) {
|
|
965
|
+
if (!evidence.checkpoints.includes(checkpoint)) {
|
|
966
|
+
throw new Error(
|
|
967
|
+
`Gameplay evidence is missing checkpoint ${checkpoint}.`
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (requirements.requireDestroy && !evidence.destroyed) {
|
|
972
|
+
throw new Error(
|
|
973
|
+
"Gameplay evidence is missing HEADLESS host destruction."
|
|
974
|
+
);
|
|
975
|
+
}
|
|
676
976
|
},
|
|
677
977
|
stepFrames,
|
|
678
978
|
async stepFramesAsync(count = 1) {
|
|
@@ -680,6 +980,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
680
980
|
await settleRuntime();
|
|
681
981
|
},
|
|
682
982
|
stepUntil(condition, maxFrames = 120) {
|
|
983
|
+
assertStepCount(maxFrames, "stepUntil maxFrames");
|
|
683
984
|
throwRuntimeError();
|
|
684
985
|
for (let frame = 0; frame <= maxFrames; frame += 1) {
|
|
685
986
|
if (condition()) {
|
|
@@ -692,13 +993,18 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
692
993
|
}
|
|
693
994
|
}
|
|
694
995
|
assertHealth();
|
|
695
|
-
throw new Error(
|
|
996
|
+
throw new Error(
|
|
997
|
+
`Condition was not met within ${maxFrames} complete Phaser frames`
|
|
998
|
+
);
|
|
696
999
|
},
|
|
697
1000
|
stepPhysics(steps = 1) {
|
|
1001
|
+
assertStepCount(steps, "stepPhysics steps");
|
|
698
1002
|
throwRuntimeError();
|
|
699
1003
|
const world = scene.physics?.world;
|
|
700
1004
|
if (!world || !getArcadeWorldBodies(world) || typeof world.singleStep !== "function") {
|
|
701
|
-
throw new Error(
|
|
1005
|
+
throw new Error(
|
|
1006
|
+
"stepPhysics() requires an Arcade Physics world; use stepFrames() for other configurations."
|
|
1007
|
+
);
|
|
702
1008
|
}
|
|
703
1009
|
for (let step = 0; step < steps; step += 1) {
|
|
704
1010
|
scene.physics.world.singleStep();
|
|
@@ -707,6 +1013,15 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
707
1013
|
throwRuntimeError();
|
|
708
1014
|
assertHealth();
|
|
709
1015
|
},
|
|
1016
|
+
checkpoint(id) {
|
|
1017
|
+
const normalizedId = id.trim();
|
|
1018
|
+
if (!normalizedId || normalizedId.length > 120) {
|
|
1019
|
+
throw new Error(
|
|
1020
|
+
"Gameplay checkpoint id must contain 1 to 120 non-whitespace characters."
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
appendUnique(checkpoints, normalizedId);
|
|
1024
|
+
},
|
|
710
1025
|
assertTextHealth() {
|
|
711
1026
|
assertSceneTextHealth(scene);
|
|
712
1027
|
},
|
|
@@ -719,6 +1034,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
719
1034
|
readyGame.destroy(true);
|
|
720
1035
|
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
721
1036
|
throwRuntimeError();
|
|
1037
|
+
evidence.destroyed = true;
|
|
722
1038
|
} finally {
|
|
723
1039
|
removeRuntimeListeners();
|
|
724
1040
|
restoreHostGuards();
|
|
@@ -728,18 +1044,79 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
728
1044
|
}
|
|
729
1045
|
|
|
730
1046
|
// src/gameplay-contracts.ts
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1047
|
+
import { test } from "vitest";
|
|
1048
|
+
|
|
1049
|
+
// src/gameplay-audit.ts
|
|
1050
|
+
function normalizeList(value) {
|
|
1051
|
+
if (value === void 0) return [];
|
|
1052
|
+
const values = typeof value === "string" ? [value] : value;
|
|
1053
|
+
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
|
|
1054
|
+
}
|
|
1055
|
+
function normalizeSet(values) {
|
|
1056
|
+
return [
|
|
1057
|
+
...new Set(values.map((value) => value.trim()).filter(Boolean))
|
|
1058
|
+
].sort();
|
|
1059
|
+
}
|
|
1060
|
+
function normalizeGameplayRequirements(requirements, scenes) {
|
|
1061
|
+
const requiredScenes = normalizeSet([
|
|
1062
|
+
...scenes,
|
|
1063
|
+
...normalizeList(requirements.requireScene)
|
|
1064
|
+
]);
|
|
1065
|
+
return {
|
|
1066
|
+
requireInput: requirements.requireInput || void 0,
|
|
1067
|
+
requireFrameAdvance: requirements.requireFrameAdvance || void 0,
|
|
1068
|
+
requirePhysicsStep: requirements.requirePhysicsStep || void 0,
|
|
1069
|
+
requireTransition: normalizeList(requirements.requireTransition),
|
|
1070
|
+
requireScene: requiredScenes,
|
|
1071
|
+
requireRestart: normalizeList(requirements.requireRestart),
|
|
1072
|
+
requireCheckpoint: normalizeList(requirements.requireCheckpoint),
|
|
1073
|
+
requireDestroy: true
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
function createGameplayContractMetadata(contract) {
|
|
1077
|
+
const scenes = normalizeSet(contract.scenes);
|
|
1078
|
+
return {
|
|
1079
|
+
id: contract.id.trim(),
|
|
1080
|
+
scenes,
|
|
1081
|
+
requirements: normalizeGameplayRequirements(contract, scenes),
|
|
1082
|
+
verified: false
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// src/gameplay-contracts.ts
|
|
1087
|
+
function snapshotEvidence(evidence) {
|
|
1088
|
+
return {
|
|
1089
|
+
frames: evidence.frames,
|
|
1090
|
+
physicsSteps: evidence.physicsSteps,
|
|
1091
|
+
mouseEvents: evidence.mouseEvents,
|
|
1092
|
+
keyboardEvents: evidence.keyboardEvents,
|
|
1093
|
+
touchEvents: evidence.touchEvents,
|
|
1094
|
+
clicks: evidence.clicks,
|
|
1095
|
+
registeredScenes: [...evidence.registeredScenes],
|
|
1096
|
+
visitedScenes: [...evidence.visitedScenes],
|
|
1097
|
+
restartedScenes: [...evidence.restartedScenes],
|
|
1098
|
+
checkpoints: [...evidence.checkpoints],
|
|
1099
|
+
destroyed: evidence.destroyed,
|
|
1100
|
+
transitions: evidence.transitions.map((transition) => ({ ...transition }))
|
|
741
1101
|
};
|
|
742
|
-
|
|
1102
|
+
}
|
|
1103
|
+
function readStaticMetadata(context, options) {
|
|
1104
|
+
const metadata = context.task.meta.gameplayContract;
|
|
1105
|
+
if (!metadata || typeof metadata !== "object") return void 0;
|
|
1106
|
+
const candidate = metadata;
|
|
1107
|
+
if (candidate.id !== options.id) {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
`\u9759\u6001 gameplay contract "${candidate.id}" \u4E0E\u8FD0\u884C\u65F6 contract "${options.id}" \u4E0D\u4E00\u81F4\u3002`
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
return candidate;
|
|
1113
|
+
}
|
|
1114
|
+
function registerGameplayContract(context, options) {
|
|
1115
|
+
const metadata = readStaticMetadata(context, options) ?? createGameplayContractMetadata({
|
|
1116
|
+
...options,
|
|
1117
|
+
scenes: options.scenes ?? []
|
|
1118
|
+
});
|
|
1119
|
+
context.task.meta.gameplayContract = metadata;
|
|
743
1120
|
let host;
|
|
744
1121
|
let verified = false;
|
|
745
1122
|
let testFailed = false;
|
|
@@ -749,16 +1126,13 @@ function registerGameplayContract(context, options) {
|
|
|
749
1126
|
const verify = () => {
|
|
750
1127
|
if (verified) return;
|
|
751
1128
|
if (!host) {
|
|
752
|
-
throw new Error(
|
|
1129
|
+
throw new Error(
|
|
1130
|
+
`Gameplay contract "${options.id}" \u6CA1\u6709\u7ED1\u5B9A HEADLESS host\u3002`
|
|
1131
|
+
);
|
|
753
1132
|
}
|
|
754
1133
|
host.assertGameplayEvidence(metadata.requirements);
|
|
755
|
-
metadata.evidence =
|
|
756
|
-
|
|
757
|
-
physicsSteps: host.evidence.physicsSteps,
|
|
758
|
-
mouseEvents: host.evidence.mouseEvents,
|
|
759
|
-
clicks: host.evidence.clicks,
|
|
760
|
-
transitions: host.evidence.transitions.map((transition) => ({ ...transition }))
|
|
761
|
-
};
|
|
1134
|
+
metadata.evidence = snapshotEvidence(host.evidence);
|
|
1135
|
+
metadata.verified = true;
|
|
762
1136
|
verified = true;
|
|
763
1137
|
};
|
|
764
1138
|
context.onTestFinished(() => {
|
|
@@ -768,18 +1142,31 @@ function registerGameplayContract(context, options) {
|
|
|
768
1142
|
return {
|
|
769
1143
|
attachHost(currentHost) {
|
|
770
1144
|
if (host) {
|
|
771
|
-
throw new Error(
|
|
1145
|
+
throw new Error(
|
|
1146
|
+
`Gameplay contract "${options.id}" \u91CD\u590D\u7ED1\u5B9A\u4E86 HEADLESS host\u3002`
|
|
1147
|
+
);
|
|
772
1148
|
}
|
|
773
1149
|
host = currentHost;
|
|
774
1150
|
},
|
|
775
1151
|
verify
|
|
776
1152
|
};
|
|
777
1153
|
}
|
|
1154
|
+
function gameplayTest(name, options, run) {
|
|
1155
|
+
const metadata = createGameplayContractMetadata(options);
|
|
1156
|
+
test(name, {
|
|
1157
|
+
...options.testOptions,
|
|
1158
|
+
meta: { gameplayContract: metadata }
|
|
1159
|
+
}, async (context) => {
|
|
1160
|
+
const contract = registerGameplayContract(context, options);
|
|
1161
|
+
await run({ context, contract });
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
778
1164
|
export {
|
|
779
1165
|
assertSceneInteractiveHealth,
|
|
780
1166
|
assertSceneRuntimeHealth,
|
|
781
1167
|
assertSceneTextHealth,
|
|
782
1168
|
collectMissingBitmapGlyphs,
|
|
783
1169
|
createHeadlessGame,
|
|
1170
|
+
gameplayTest,
|
|
784
1171
|
registerGameplayContract
|
|
785
1172
|
};
|