miaoda-game-devkit 0.2.12 → 0.2.13
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 +18 -8
- package/bin/miaoda-phaser-game-lint.js +3 -0
- package/bin/miaoda-react-game-lint.js +3 -0
- package/dist/cli/phaser-lint.js +163 -0
- package/dist/cli/react-lint.js +163 -0
- package/dist/lint/setup.mjs +23 -41
- package/dist/react/vitest-config.js +34 -1
- package/dist/react/vitest-config.mjs +35 -2
- package/dist/react/vitest-setup.js +72 -1
- package/dist/react/vitest-setup.mjs +72 -1
- package/dist/vitest-config.js +16 -1
- package/dist/vitest-config.mjs +16 -1
- package/package.json +7 -4
- package/bin/miaoda-game-lint.js +0 -3
- package/dist/cli/lint.js +0 -127
- package/dist/lint/contracts.config.mjs +0 -38
- package/dist/lint/game-telemetry.contract.mjs +0 -1402
- package/dist/lint/gameplay-audit.contract.mjs +0 -909
- package/dist/lint/gameplay-contract.contract.mjs +0 -1408
- package/dist/lint/phaser-headless.contract.mjs +0 -1856
- package/dist/lint/phaser-text-assertions.contract.mjs +0 -32
- package/dist/lint/resource-import-plugin.contract.mjs +0 -97
- package/dist/lint/vitest-config.contract.mjs +0 -1028
|
@@ -1,1402 +0,0 @@
|
|
|
1
|
-
// src/lint/game-telemetry.test.ts
|
|
2
|
-
import * as Phaser5 from "phaser";
|
|
3
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
-
|
|
5
|
-
// src/phaser-headless-host.ts
|
|
6
|
-
import * as Phaser3 from "phaser";
|
|
7
|
-
|
|
8
|
-
// src/phaser-engine-warnings.ts
|
|
9
|
-
var originalWarn;
|
|
10
|
-
var originalError;
|
|
11
|
-
var listener;
|
|
12
|
-
function formatWarningArgument(value) {
|
|
13
|
-
if (typeof value === "string") return value;
|
|
14
|
-
if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
15
|
-
return String(value);
|
|
16
|
-
}
|
|
17
|
-
if (value instanceof Error) return value.message;
|
|
18
|
-
const constructorName = typeof value === "object" ? value.constructor?.name : void 0;
|
|
19
|
-
return constructorName ? `[${constructorName}]` : String(value);
|
|
20
|
-
}
|
|
21
|
-
function comesFromPhaser(stack) {
|
|
22
|
-
return /(?:^|[/\\])phaser(?:[/\\]|\.(?:js|mjs|cjs)(?::\d+)?)/m.test(stack);
|
|
23
|
-
}
|
|
24
|
-
function captureEngineDiagnostic(level, original, args) {
|
|
25
|
-
const stack = new Error(`Phaser engine ${level}`).stack ?? "";
|
|
26
|
-
if (!comesFromPhaser(stack)) {
|
|
27
|
-
original(...args);
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
listener?.({
|
|
31
|
-
level,
|
|
32
|
-
message: args.map(formatWarningArgument).join(" "),
|
|
33
|
-
stack
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
function installWarningMultiplexer(currentListener) {
|
|
37
|
-
if (listener) {
|
|
38
|
-
throw new Error(
|
|
39
|
-
"Only one Phaser HEADLESS host may be active in the same Vitest worker. Destroy the current host before creating another one."
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
listener = currentListener;
|
|
43
|
-
const passthroughWarn = console.warn;
|
|
44
|
-
const passthroughError = console.error;
|
|
45
|
-
originalWarn = passthroughWarn;
|
|
46
|
-
originalError = passthroughError;
|
|
47
|
-
console.warn = (...args) => {
|
|
48
|
-
captureEngineDiagnostic("warn", passthroughWarn, args);
|
|
49
|
-
};
|
|
50
|
-
console.error = (...args) => {
|
|
51
|
-
captureEngineDiagnostic("error", passthroughError, args);
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
function uninstallWarningMultiplexer() {
|
|
55
|
-
if (!listener) return;
|
|
56
|
-
if (originalWarn) console.warn = originalWarn;
|
|
57
|
-
if (originalError) console.error = originalError;
|
|
58
|
-
listener = void 0;
|
|
59
|
-
originalWarn = void 0;
|
|
60
|
-
originalError = void 0;
|
|
61
|
-
}
|
|
62
|
-
function observeEngineDiagnostics(currentListener) {
|
|
63
|
-
installWarningMultiplexer(currentListener);
|
|
64
|
-
let observing = true;
|
|
65
|
-
return () => {
|
|
66
|
-
if (!observing) return;
|
|
67
|
-
observing = false;
|
|
68
|
-
uninstallWarningMultiplexer();
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
function matchesEngineWarning(message, matcher) {
|
|
72
|
-
if (typeof matcher === "string") return message.includes(matcher);
|
|
73
|
-
matcher.lastIndex = 0;
|
|
74
|
-
return matcher.test(message);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// src/phaser-text-assertions.ts
|
|
78
|
-
import * as Phaser from "phaser";
|
|
79
|
-
function describeText(label) {
|
|
80
|
-
const name = label.name ? ` name=${JSON.stringify(label.name)}` : "";
|
|
81
|
-
const value = label.text.length > 80 ? `${label.text.slice(0, 77)}...` : label.text;
|
|
82
|
-
return `Text${name} value=${JSON.stringify(value)}`;
|
|
83
|
-
}
|
|
84
|
-
function isEffectivelyRenderable(label) {
|
|
85
|
-
let current = label;
|
|
86
|
-
const visited = /* @__PURE__ */ new Set();
|
|
87
|
-
while (current) {
|
|
88
|
-
if (visited.has(current)) return false;
|
|
89
|
-
visited.add(current);
|
|
90
|
-
const state = current;
|
|
91
|
-
if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
current = state.parentContainer ?? (state.displayList instanceof Phaser.GameObjects.Layer ? state.displayList : null);
|
|
95
|
-
}
|
|
96
|
-
return true;
|
|
97
|
-
}
|
|
98
|
-
function requireFinite(problems, label, property, value) {
|
|
99
|
-
if (!Number.isFinite(value)) {
|
|
100
|
-
problems.push(
|
|
101
|
-
`${describeText(label)} has non-finite ${property}: ${String(value)}`
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
function collectSceneTexts(scene) {
|
|
106
|
-
const labels = [];
|
|
107
|
-
const visited = /* @__PURE__ */ new Set();
|
|
108
|
-
const visit = (child) => {
|
|
109
|
-
if (visited.has(child)) return;
|
|
110
|
-
visited.add(child);
|
|
111
|
-
if (child instanceof Phaser.GameObjects.Text) {
|
|
112
|
-
labels.push(child);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
if (child instanceof Phaser.GameObjects.Container) {
|
|
116
|
-
child.list.forEach(visit);
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
if (child instanceof Phaser.GameObjects.Layer) {
|
|
120
|
-
child.getChildren().forEach(visit);
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
scene.children.getChildren().forEach(visit);
|
|
124
|
-
return labels;
|
|
125
|
-
}
|
|
126
|
-
function collectSceneBitmapTexts(scene) {
|
|
127
|
-
const labels = [];
|
|
128
|
-
const visited = /* @__PURE__ */ new Set();
|
|
129
|
-
const visit = (child) => {
|
|
130
|
-
if (visited.has(child)) return;
|
|
131
|
-
visited.add(child);
|
|
132
|
-
if (child instanceof Phaser.GameObjects.BitmapText) {
|
|
133
|
-
labels.push(child);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
if (child instanceof Phaser.GameObjects.Container) {
|
|
137
|
-
child.list.forEach(visit);
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
if (child instanceof Phaser.GameObjects.Layer) {
|
|
141
|
-
child.getChildren().forEach(visit);
|
|
142
|
-
}
|
|
143
|
-
};
|
|
144
|
-
scene.children.getChildren().forEach(visit);
|
|
145
|
-
return labels;
|
|
146
|
-
}
|
|
147
|
-
function collectMissingBitmapGlyphs(text, chars) {
|
|
148
|
-
const missing = [];
|
|
149
|
-
const seen = /* @__PURE__ */ new Set();
|
|
150
|
-
for (let index = 0; index < text.length; index += 1) {
|
|
151
|
-
const character = text[index];
|
|
152
|
-
if (/\s/u.test(character)) continue;
|
|
153
|
-
const code = text.charCodeAt(index);
|
|
154
|
-
if (seen.has(code) || chars[code] !== void 0) continue;
|
|
155
|
-
seen.add(code);
|
|
156
|
-
missing.push(
|
|
157
|
-
`${JSON.stringify(character)} (U+${code.toString(16).toUpperCase().padStart(4, "0")})`
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
return missing;
|
|
161
|
-
}
|
|
162
|
-
function collectTextHealthProblems(scene) {
|
|
163
|
-
const problems = [];
|
|
164
|
-
const labels = collectSceneTexts(scene);
|
|
165
|
-
for (const label of labels) {
|
|
166
|
-
if (label.text.length === 0) continue;
|
|
167
|
-
requireFinite(problems, label, "x", label.x);
|
|
168
|
-
requireFinite(problems, label, "y", label.y);
|
|
169
|
-
requireFinite(problems, label, "width", label.width);
|
|
170
|
-
requireFinite(problems, label, "height", label.height);
|
|
171
|
-
requireFinite(problems, label, "displayWidth", label.displayWidth);
|
|
172
|
-
requireFinite(problems, label, "displayHeight", label.displayHeight);
|
|
173
|
-
requireFinite(problems, label, "scaleX", label.scaleX);
|
|
174
|
-
requireFinite(problems, label, "scaleY", label.scaleY);
|
|
175
|
-
requireFinite(problems, label, "originX", label.originX);
|
|
176
|
-
requireFinite(problems, label, "originY", label.originY);
|
|
177
|
-
requireFinite(problems, label, "rotation", label.rotation);
|
|
178
|
-
requireFinite(problems, label, "fixedWidth", label.style.fixedWidth);
|
|
179
|
-
requireFinite(problems, label, "fixedHeight", label.style.fixedHeight);
|
|
180
|
-
requireFinite(problems, label, "resolution", label.style.resolution);
|
|
181
|
-
const requiresPositiveSize = /\S/u.test(label.text) && isEffectivelyRenderable(label);
|
|
182
|
-
if (requiresPositiveSize && Number.isFinite(label.width) && label.width <= 0) {
|
|
183
|
-
problems.push(
|
|
184
|
-
`${describeText(label)} has non-positive width: ${label.width}`
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
if (requiresPositiveSize && Number.isFinite(label.height) && label.height <= 0) {
|
|
188
|
-
problems.push(
|
|
189
|
-
`${describeText(label)} has non-positive height: ${label.height}`
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
if (Number.isFinite(label.style.fixedWidth) && label.style.fixedWidth < 0) {
|
|
193
|
-
problems.push(
|
|
194
|
-
`${describeText(label)} has negative fixedWidth: ${label.style.fixedWidth}`
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
if (Number.isFinite(label.style.fixedHeight) && label.style.fixedHeight < 0) {
|
|
198
|
-
problems.push(
|
|
199
|
-
`${describeText(label)} has negative fixedHeight: ${label.style.fixedHeight}`
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
if (Number.isFinite(label.style.resolution) && label.style.resolution <= 0) {
|
|
203
|
-
problems.push(
|
|
204
|
-
`${describeText(label)} has non-positive resolution: ${label.style.resolution}`
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
const bounds = label.getBounds();
|
|
208
|
-
for (const [property, value] of Object.entries({
|
|
209
|
-
boundsX: bounds.x,
|
|
210
|
-
boundsY: bounds.y,
|
|
211
|
-
boundsWidth: bounds.width,
|
|
212
|
-
boundsHeight: bounds.height
|
|
213
|
-
})) {
|
|
214
|
-
requireFinite(problems, label, property, value);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
return problems;
|
|
218
|
-
}
|
|
219
|
-
function collectBitmapTextHealthProblems(scene) {
|
|
220
|
-
const problems = [];
|
|
221
|
-
for (const label of collectSceneBitmapTexts(scene)) {
|
|
222
|
-
const text = Array.isArray(label.text) ? label.text.join("\n") : label.text;
|
|
223
|
-
if (!/\S/u.test(text)) continue;
|
|
224
|
-
const missing = collectMissingBitmapGlyphs(text, label.fontData.chars);
|
|
225
|
-
if (missing.length > 0) {
|
|
226
|
-
problems.push(
|
|
227
|
-
`BitmapText${label.name ? ` name=${JSON.stringify(label.name)}` : ""} value=${JSON.stringify(text)} is missing glyphs: ${missing.join(", ")}`
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return problems;
|
|
232
|
-
}
|
|
233
|
-
function assertSceneTextHealth(scene) {
|
|
234
|
-
const problems = [
|
|
235
|
-
...collectTextHealthProblems(scene),
|
|
236
|
-
...collectBitmapTextHealthProblems(scene)
|
|
237
|
-
];
|
|
238
|
-
if (problems.length === 0) return;
|
|
239
|
-
throw new Error(
|
|
240
|
-
[
|
|
241
|
-
`Phaser text health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
242
|
-
...problems.map((problem) => `- ${problem}`),
|
|
243
|
-
"Fix optional TextStyle values by omitting them or using Phaser defaults (for example fixedWidth: value ?? 0)."
|
|
244
|
-
].join("\n")
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// src/phaser-runtime-assertions.ts
|
|
249
|
-
import * as Phaser2 from "phaser";
|
|
250
|
-
function collectObjects(scene) {
|
|
251
|
-
const objects = [];
|
|
252
|
-
const visited = /* @__PURE__ */ new Set();
|
|
253
|
-
const visit = (object) => {
|
|
254
|
-
if (visited.has(object)) return;
|
|
255
|
-
visited.add(object);
|
|
256
|
-
objects.push(object);
|
|
257
|
-
if (object instanceof Phaser2.GameObjects.Container)
|
|
258
|
-
object.list.forEach(visit);
|
|
259
|
-
if (object instanceof Phaser2.GameObjects.Layer)
|
|
260
|
-
object.getChildren().forEach(visit);
|
|
261
|
-
};
|
|
262
|
-
scene.children.getChildren().forEach(visit);
|
|
263
|
-
return objects;
|
|
264
|
-
}
|
|
265
|
-
function describeObject(object) {
|
|
266
|
-
return `${object.type}${object.name ? ` name=${JSON.stringify(object.name)}` : ""}`;
|
|
267
|
-
}
|
|
268
|
-
function isEffectivelyVisible(object) {
|
|
269
|
-
let current = object;
|
|
270
|
-
const visited = /* @__PURE__ */ new Set();
|
|
271
|
-
while (current) {
|
|
272
|
-
if (visited.has(current)) return false;
|
|
273
|
-
visited.add(current);
|
|
274
|
-
const state = current;
|
|
275
|
-
if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
|
|
276
|
-
return false;
|
|
277
|
-
}
|
|
278
|
-
current = state.parentContainer ?? (state.displayList instanceof Phaser2.GameObjects.Layer ? state.displayList : null);
|
|
279
|
-
}
|
|
280
|
-
return true;
|
|
281
|
-
}
|
|
282
|
-
function requireFiniteProperty(problems, subject, target, property) {
|
|
283
|
-
if (!(property in target)) return;
|
|
284
|
-
const value = Reflect.get(target, property);
|
|
285
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
286
|
-
problems.push(`${subject} has non-finite ${property}: ${String(value)}`);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
function requireFiniteVector(problems, subject, target, property) {
|
|
290
|
-
if (!(property in target)) return;
|
|
291
|
-
const vector = Reflect.get(target, property);
|
|
292
|
-
if (!vector || typeof vector !== "object") return;
|
|
293
|
-
for (const axis of ["x", "y"]) {
|
|
294
|
-
if (!(axis in vector)) continue;
|
|
295
|
-
const value = Reflect.get(vector, axis);
|
|
296
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
297
|
-
problems.push(
|
|
298
|
-
`${subject} has non-finite ${property}.${axis}: ${String(value)}`
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
function checksObjectState(object) {
|
|
304
|
-
return object.active !== false || isEffectivelyVisible(object) || Boolean(object.input?.enabled);
|
|
305
|
-
}
|
|
306
|
-
function collectGameObjectHealthProblems(scene) {
|
|
307
|
-
const problems = [];
|
|
308
|
-
const transformProperties = ["x", "y", "scaleX", "scaleY", "rotation"];
|
|
309
|
-
const optionalComponents = [
|
|
310
|
-
["setAlpha", ["alpha", "_alphaTL", "_alphaTR", "_alphaBL", "_alphaBR"]],
|
|
311
|
-
["setDepth", ["depth"]],
|
|
312
|
-
["setOrigin", ["originX", "originY", "displayOriginX", "displayOriginY"]],
|
|
313
|
-
["setScrollFactor", ["scrollFactorX", "scrollFactorY"]]
|
|
314
|
-
];
|
|
315
|
-
for (const object of collectObjects(scene)) {
|
|
316
|
-
if (!checksObjectState(object)) continue;
|
|
317
|
-
const subject = describeObject(object);
|
|
318
|
-
const target = object;
|
|
319
|
-
const texture = target.texture;
|
|
320
|
-
if (isEffectivelyVisible(object) && texture && typeof texture === "object" && Reflect.get(texture, "key") === "__MISSING") {
|
|
321
|
-
problems.push(
|
|
322
|
-
`${subject} uses Phaser's __MISSING texture; verify the loaded texture key and asset path`
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
if (typeof target.setPosition === "function") {
|
|
326
|
-
for (const property of transformProperties) {
|
|
327
|
-
requireFiniteProperty(problems, subject, object, property);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
for (const [method, properties] of optionalComponents) {
|
|
331
|
-
if (typeof target[method] !== "function") continue;
|
|
332
|
-
for (const property of properties) {
|
|
333
|
-
requireFiniteProperty(problems, subject, object, property);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return problems;
|
|
338
|
-
}
|
|
339
|
-
function collectCameraHealthProblems(scene) {
|
|
340
|
-
const problems = [];
|
|
341
|
-
const properties = [
|
|
342
|
-
"x",
|
|
343
|
-
"y",
|
|
344
|
-
"width",
|
|
345
|
-
"height",
|
|
346
|
-
"scrollX",
|
|
347
|
-
"scrollY",
|
|
348
|
-
"rotation",
|
|
349
|
-
"zoomX",
|
|
350
|
-
"zoomY"
|
|
351
|
-
];
|
|
352
|
-
for (const camera of scene.cameras?.cameras ?? []) {
|
|
353
|
-
if (camera.visible === false) continue;
|
|
354
|
-
const subject = `Camera ${JSON.stringify(camera.name || String(camera.id))}`;
|
|
355
|
-
for (const property of properties) {
|
|
356
|
-
requireFiniteProperty(problems, subject, camera, property);
|
|
357
|
-
}
|
|
358
|
-
if (camera.width !== 0 && camera.height !== 0 && (camera.zoomX === 0 || camera.zoomY === 0)) {
|
|
359
|
-
problems.push(`${subject} has zero zoom while its viewport is active`);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
return problems;
|
|
363
|
-
}
|
|
364
|
-
function getArcadeBodyCollection(value) {
|
|
365
|
-
if (value instanceof Set) return [...value];
|
|
366
|
-
if (!value || typeof value !== "object") return void 0;
|
|
367
|
-
const entries = Reflect.get(value, "entries");
|
|
368
|
-
return Array.isArray(entries) ? entries : void 0;
|
|
369
|
-
}
|
|
370
|
-
function getArcadeWorldBodies(world) {
|
|
371
|
-
if (!world || typeof world !== "object") return void 0;
|
|
372
|
-
const bodies = getArcadeBodyCollection(Reflect.get(world, "bodies"));
|
|
373
|
-
const staticBodies = getArcadeBodyCollection(
|
|
374
|
-
Reflect.get(world, "staticBodies")
|
|
375
|
-
);
|
|
376
|
-
return bodies && staticBodies ? [...bodies, ...staticBodies] : void 0;
|
|
377
|
-
}
|
|
378
|
-
function collectArcadeBodyHealthProblems(scene) {
|
|
379
|
-
const problems = [];
|
|
380
|
-
const bodies = getArcadeWorldBodies(scene.physics?.world);
|
|
381
|
-
if (!bodies) return problems;
|
|
382
|
-
for (const body of bodies) {
|
|
383
|
-
if (body.enable === false) continue;
|
|
384
|
-
const gameObject = body.gameObject;
|
|
385
|
-
const subject = gameObject ? `Arcade Body for ${describeObject(gameObject)}` : "Arcade Body";
|
|
386
|
-
for (const property of [
|
|
387
|
-
"position",
|
|
388
|
-
"velocity",
|
|
389
|
-
"acceleration",
|
|
390
|
-
"gravity",
|
|
391
|
-
"offset",
|
|
392
|
-
"center"
|
|
393
|
-
]) {
|
|
394
|
-
requireFiniteVector(problems, subject, body, property);
|
|
395
|
-
}
|
|
396
|
-
for (const property of [
|
|
397
|
-
"width",
|
|
398
|
-
"height",
|
|
399
|
-
"halfWidth",
|
|
400
|
-
"halfHeight",
|
|
401
|
-
"rotation"
|
|
402
|
-
]) {
|
|
403
|
-
requireFiniteProperty(problems, subject, body, property);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
return problems;
|
|
407
|
-
}
|
|
408
|
-
function usesCustomHitArea(object) {
|
|
409
|
-
return Boolean(
|
|
410
|
-
object.input?.customHitArea
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
function collectInteractiveHealthProblems(scene) {
|
|
414
|
-
const problems = [];
|
|
415
|
-
for (const object of collectObjects(scene)) {
|
|
416
|
-
if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled)
|
|
417
|
-
continue;
|
|
418
|
-
if (usesCustomHitArea(object)) continue;
|
|
419
|
-
const hitArea = object.input.hitArea;
|
|
420
|
-
if (!hitArea) {
|
|
421
|
-
problems.push(`${describeObject(object)} has no default hit area`);
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
424
|
-
const values = [hitArea.x, hitArea.y, hitArea.width, hitArea.height];
|
|
425
|
-
if (!values.every(Number.isFinite) || hitArea.width <= 0 || hitArea.height <= 0) {
|
|
426
|
-
problems.push(
|
|
427
|
-
`${describeObject(object)} has invalid default hit area (${hitArea.x}, ${hitArea.y}, ${hitArea.width}, ${hitArea.height})`
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
return problems;
|
|
432
|
-
}
|
|
433
|
-
function assertSceneRuntimeHealth(scene) {
|
|
434
|
-
const problems = [
|
|
435
|
-
...collectGameObjectHealthProblems(scene),
|
|
436
|
-
...collectCameraHealthProblems(scene),
|
|
437
|
-
...collectArcadeBodyHealthProblems(scene)
|
|
438
|
-
];
|
|
439
|
-
if (problems.length === 0) return;
|
|
440
|
-
throw new Error(
|
|
441
|
-
[
|
|
442
|
-
`Phaser runtime health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
443
|
-
...problems.map((problem) => `- ${problem}`),
|
|
444
|
-
"Keep values consumed by Phaser finite; zero size and zero scale remain allowed."
|
|
445
|
-
].join("\n")
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
|
-
function assertSceneInteractiveHealth(scene) {
|
|
449
|
-
const problems = collectInteractiveHealthProblems(scene);
|
|
450
|
-
if (problems.length === 0) return;
|
|
451
|
-
throw new Error(
|
|
452
|
-
[
|
|
453
|
-
`Phaser interactive health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
454
|
-
...problems.map((problem) => `- ${problem}`),
|
|
455
|
-
"Fix the default hit area or disable input before making the object interactive."
|
|
456
|
-
].join("\n")
|
|
457
|
-
);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// src/phaser-headless-host.ts
|
|
461
|
-
async function createHeadlessGame(scene, options = {}) {
|
|
462
|
-
const {
|
|
463
|
-
bootTimeoutMs = 2e3,
|
|
464
|
-
additionalScenes = [],
|
|
465
|
-
ignoreEngineWarnings = [],
|
|
466
|
-
allowIdleLoaderQueues = [],
|
|
467
|
-
...config
|
|
468
|
-
} = options;
|
|
469
|
-
let game;
|
|
470
|
-
let settled = false;
|
|
471
|
-
let runtimeError;
|
|
472
|
-
let hasRuntimeError = false;
|
|
473
|
-
let restoreHostGuards = () => {
|
|
474
|
-
};
|
|
475
|
-
let removeRuntimeListeners = () => {
|
|
476
|
-
};
|
|
477
|
-
const engineDiagnostics = [];
|
|
478
|
-
const loaderFailures = [];
|
|
479
|
-
const stopObservingEngineDiagnostics = observeEngineDiagnostics((diagnostic) => {
|
|
480
|
-
if (diagnostic.level === "warn" && ignoreEngineWarnings.some(
|
|
481
|
-
(matcher) => matchesEngineWarning(diagnostic.message, matcher)
|
|
482
|
-
)) {
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
engineDiagnostics.push(diagnostic);
|
|
486
|
-
});
|
|
487
|
-
const transitions = [];
|
|
488
|
-
const registeredScenes = [];
|
|
489
|
-
const visitedScenes = [];
|
|
490
|
-
const restartedScenes = [];
|
|
491
|
-
const checkpoints = [];
|
|
492
|
-
const appendUnique = (values, value) => {
|
|
493
|
-
if (value && !values.includes(value)) values.push(value);
|
|
494
|
-
};
|
|
495
|
-
const assertStepCount = (value, name) => {
|
|
496
|
-
if (!Number.isSafeInteger(value) || value < 0) {
|
|
497
|
-
throw new Error(
|
|
498
|
-
`${name} must be a non-negative safe integer; received ${value}`
|
|
499
|
-
);
|
|
500
|
-
}
|
|
501
|
-
};
|
|
502
|
-
const evidence = {
|
|
503
|
-
frames: 0,
|
|
504
|
-
physicsSteps: 0,
|
|
505
|
-
mouseEvents: 0,
|
|
506
|
-
keyboardEvents: 0,
|
|
507
|
-
touchEvents: 0,
|
|
508
|
-
clicks: 0,
|
|
509
|
-
registeredScenes,
|
|
510
|
-
visitedScenes,
|
|
511
|
-
restartedScenes,
|
|
512
|
-
checkpoints,
|
|
513
|
-
destroyed: false,
|
|
514
|
-
transitions
|
|
515
|
-
};
|
|
516
|
-
const created = new Promise((resolve, reject) => {
|
|
517
|
-
const guardedScenes = /* @__PURE__ */ new WeakSet();
|
|
518
|
-
const restoreGuards = [];
|
|
519
|
-
let removeReadinessCheck = () => {
|
|
520
|
-
};
|
|
521
|
-
const finish = (error) => {
|
|
522
|
-
if (settled) {
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
settled = true;
|
|
526
|
-
window.clearTimeout(timeout);
|
|
527
|
-
removeReadinessCheck();
|
|
528
|
-
if (error === void 0) {
|
|
529
|
-
game?.loop.stop();
|
|
530
|
-
resolve();
|
|
531
|
-
} else {
|
|
532
|
-
removeRuntimeListeners();
|
|
533
|
-
reject(error);
|
|
534
|
-
}
|
|
535
|
-
};
|
|
536
|
-
const reportError = (error) => {
|
|
537
|
-
const normalizedError = error instanceof Error ? error : new Error(`Unhandled Phaser runtime error: ${String(error)}`);
|
|
538
|
-
if (!settled) {
|
|
539
|
-
finish(normalizedError);
|
|
540
|
-
} else if (!hasRuntimeError) {
|
|
541
|
-
hasRuntimeError = true;
|
|
542
|
-
runtimeError = normalizedError;
|
|
543
|
-
}
|
|
544
|
-
};
|
|
545
|
-
const onWindowError = (event) => {
|
|
546
|
-
if (event.defaultPrevented) return;
|
|
547
|
-
event.preventDefault();
|
|
548
|
-
const error = event.error ?? new Error(event.message);
|
|
549
|
-
queueMicrotask(() => {
|
|
550
|
-
reportError(error);
|
|
551
|
-
});
|
|
552
|
-
};
|
|
553
|
-
const onUnhandledRejection = (event) => {
|
|
554
|
-
queueMicrotask(() => {
|
|
555
|
-
if (!event.defaultPrevented) reportError(event.reason);
|
|
556
|
-
});
|
|
557
|
-
};
|
|
558
|
-
const timeout = window.setTimeout(() => {
|
|
559
|
-
finish(
|
|
560
|
-
new Error(`Phaser HEADLESS boot timed out after ${bootTimeoutMs}ms`)
|
|
561
|
-
);
|
|
562
|
-
}, bootTimeoutMs);
|
|
563
|
-
window.addEventListener("error", onWindowError, true);
|
|
564
|
-
window.addEventListener("unhandledrejection", onUnhandledRejection);
|
|
565
|
-
removeRuntimeListeners = () => {
|
|
566
|
-
window.removeEventListener("error", onWindowError, true);
|
|
567
|
-
window.removeEventListener("unhandledrejection", onUnhandledRejection);
|
|
568
|
-
};
|
|
569
|
-
const guardScene = (currentScene) => {
|
|
570
|
-
if (guardedScenes.has(currentScene)) return;
|
|
571
|
-
guardedScenes.add(currentScene);
|
|
572
|
-
const onFileLoadError = (file) => {
|
|
573
|
-
const source = file.src || file.url || "unknown source";
|
|
574
|
-
loaderFailures.push({
|
|
575
|
-
scene: currentScene.sys.settings.key,
|
|
576
|
-
type: String(file.type),
|
|
577
|
-
key: String(file.key),
|
|
578
|
-
source: String(source)
|
|
579
|
-
});
|
|
580
|
-
};
|
|
581
|
-
currentScene.load.on(
|
|
582
|
-
Phaser3.Loader.Events.FILE_LOAD_ERROR,
|
|
583
|
-
onFileLoadError
|
|
584
|
-
);
|
|
585
|
-
restoreGuards.push(() => {
|
|
586
|
-
currentScene.load.off(
|
|
587
|
-
Phaser3.Loader.Events.FILE_LOAD_ERROR,
|
|
588
|
-
onFileLoadError
|
|
589
|
-
);
|
|
590
|
-
});
|
|
591
|
-
const scenePlugin = currentScene.scene;
|
|
592
|
-
for (const method of [
|
|
593
|
-
"start",
|
|
594
|
-
"launch",
|
|
595
|
-
"switch",
|
|
596
|
-
"sleep",
|
|
597
|
-
"wake"
|
|
598
|
-
]) {
|
|
599
|
-
const original = scenePlugin[method];
|
|
600
|
-
if (typeof original !== "function") continue;
|
|
601
|
-
scenePlugin[method] = (key, ...args) => {
|
|
602
|
-
const target = key === void 0 ? currentScene : typeof key === "string" ? currentScene.scene.get(key) : key;
|
|
603
|
-
if (!target) {
|
|
604
|
-
throw new Error(
|
|
605
|
-
`[SCENE_NOT_REGISTERED] Scene ${JSON.stringify(currentScene.sys.settings.key)} called ${method}(${JSON.stringify(key)}), but the target Scene is not registered. Add it to the production Scene registry and pass it through additionalScenes in focused HEADLESS tests.`
|
|
606
|
-
);
|
|
607
|
-
}
|
|
608
|
-
guardScene(target);
|
|
609
|
-
const from = currentScene.sys.settings.key;
|
|
610
|
-
const to = key === void 0 ? from : typeof key === "string" ? key : key.sys.settings.key;
|
|
611
|
-
if (from && to) transitions.push({ from, to, method });
|
|
612
|
-
const callArgs = key === void 0 && args.length === 0 ? [] : [key, ...args];
|
|
613
|
-
return Reflect.apply(original, currentScene.scene, callArgs);
|
|
614
|
-
};
|
|
615
|
-
restoreGuards.push(() => {
|
|
616
|
-
scenePlugin[method] = original;
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
const originalRestart = scenePlugin.restart;
|
|
620
|
-
if (typeof originalRestart === "function") {
|
|
621
|
-
scenePlugin.restart = (...args) => {
|
|
622
|
-
appendUnique(restartedScenes, currentScene.sys.settings.key);
|
|
623
|
-
return Reflect.apply(originalRestart, currentScene.scene, args);
|
|
624
|
-
};
|
|
625
|
-
restoreGuards.push(() => {
|
|
626
|
-
scenePlugin.restart = originalRestart;
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
for (const hook of ["init", "preload", "create", "update"]) {
|
|
630
|
-
const original = Reflect.get(currentScene, hook);
|
|
631
|
-
if (typeof original !== "function") continue;
|
|
632
|
-
const ownDescriptor = Object.getOwnPropertyDescriptor(
|
|
633
|
-
currentScene,
|
|
634
|
-
hook
|
|
635
|
-
);
|
|
636
|
-
Reflect.set(
|
|
637
|
-
currentScene,
|
|
638
|
-
hook,
|
|
639
|
-
function guardedLifecycleHook(...args) {
|
|
640
|
-
try {
|
|
641
|
-
const result = Reflect.apply(original, this, args);
|
|
642
|
-
if (result && typeof result === "object" && "then" in result) {
|
|
643
|
-
const completion = Promise.resolve(result);
|
|
644
|
-
if (currentScene === scene && hook === "create" && !settled) {
|
|
645
|
-
completion.then(
|
|
646
|
-
() => window.setTimeout(() => finish(), 0),
|
|
647
|
-
reportError
|
|
648
|
-
);
|
|
649
|
-
} else {
|
|
650
|
-
completion.catch(reportError);
|
|
651
|
-
}
|
|
652
|
-
} else if (currentScene === scene && hook === "create" && !settled) {
|
|
653
|
-
window.setTimeout(() => finish(), 0);
|
|
654
|
-
}
|
|
655
|
-
return result;
|
|
656
|
-
} catch (error) {
|
|
657
|
-
reportError(error);
|
|
658
|
-
return void 0;
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
);
|
|
662
|
-
restoreGuards.push(() => {
|
|
663
|
-
if (ownDescriptor) {
|
|
664
|
-
Object.defineProperty(currentScene, hook, ownDescriptor);
|
|
665
|
-
} else {
|
|
666
|
-
Reflect.deleteProperty(currentScene, hook);
|
|
667
|
-
}
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
};
|
|
671
|
-
restoreHostGuards = () => {
|
|
672
|
-
while (restoreGuards.length > 0) restoreGuards.pop()?.();
|
|
673
|
-
};
|
|
674
|
-
const fps = {
|
|
675
|
-
...config.fps,
|
|
676
|
-
target: config.fps?.target ?? 60,
|
|
677
|
-
forceSetTimeOut: true
|
|
678
|
-
};
|
|
679
|
-
const audio = {
|
|
680
|
-
...config.audio,
|
|
681
|
-
noAudio: true
|
|
682
|
-
};
|
|
683
|
-
try {
|
|
684
|
-
game = new Phaser3.Game({
|
|
685
|
-
width: 320,
|
|
686
|
-
height: 180,
|
|
687
|
-
banner: false,
|
|
688
|
-
autoFocus: false,
|
|
689
|
-
seed: ["phaser-headless-test"],
|
|
690
|
-
...config,
|
|
691
|
-
type: Phaser3.HEADLESS,
|
|
692
|
-
fps,
|
|
693
|
-
audio,
|
|
694
|
-
callbacks: {
|
|
695
|
-
preBoot(bootedGame) {
|
|
696
|
-
const manager = bootedGame.scene;
|
|
697
|
-
const originalStart = manager.start;
|
|
698
|
-
manager.start = ((key, data) => {
|
|
699
|
-
const currentScene = typeof key === "string" ? manager.getScene(key) : key;
|
|
700
|
-
if (currentScene) guardScene(currentScene);
|
|
701
|
-
return Reflect.apply(originalStart, manager, [key, data]);
|
|
702
|
-
});
|
|
703
|
-
restoreGuards.push(() => {
|
|
704
|
-
manager.start = originalStart;
|
|
705
|
-
});
|
|
706
|
-
},
|
|
707
|
-
postBoot(bootedGame) {
|
|
708
|
-
const checkReadiness = () => {
|
|
709
|
-
if (typeof Reflect.get(scene, "create") !== "function" && scene.sys.isActive()) {
|
|
710
|
-
finish();
|
|
711
|
-
}
|
|
712
|
-
};
|
|
713
|
-
bootedGame.events.on(Phaser3.Core.Events.POST_STEP, checkReadiness);
|
|
714
|
-
removeReadinessCheck = () => {
|
|
715
|
-
bootedGame.events.off(
|
|
716
|
-
Phaser3.Core.Events.POST_STEP,
|
|
717
|
-
checkReadiness
|
|
718
|
-
);
|
|
719
|
-
};
|
|
720
|
-
}
|
|
721
|
-
},
|
|
722
|
-
scene: additionalScenes.length > 0 ? [scene, ...additionalScenes] : scene
|
|
723
|
-
});
|
|
724
|
-
} catch (error) {
|
|
725
|
-
reportError(error);
|
|
726
|
-
}
|
|
727
|
-
});
|
|
728
|
-
try {
|
|
729
|
-
await created;
|
|
730
|
-
} catch (error) {
|
|
731
|
-
if (game) {
|
|
732
|
-
game.destroy(true);
|
|
733
|
-
game.headlessStep(game.loop.lastTime, 0);
|
|
734
|
-
}
|
|
735
|
-
restoreHostGuards();
|
|
736
|
-
stopObservingEngineDiagnostics();
|
|
737
|
-
throw error;
|
|
738
|
-
}
|
|
739
|
-
const readyGame = game;
|
|
740
|
-
const throwRuntimeError = () => {
|
|
741
|
-
if (hasRuntimeError) {
|
|
742
|
-
throw runtimeError instanceof Error ? runtimeError : new Error(`Unhandled Phaser runtime error: ${String(runtimeError)}`);
|
|
743
|
-
}
|
|
744
|
-
if (loaderFailures.length > 0) {
|
|
745
|
-
const imageTypes = /* @__PURE__ */ new Set(["image", "normalMap", "spritesheet"]);
|
|
746
|
-
const hasHeadlessImageFailure = loaderFailures.some(
|
|
747
|
-
(failure) => imageTypes.has(failure.type)
|
|
748
|
-
);
|
|
749
|
-
throw new Error(
|
|
750
|
-
[
|
|
751
|
-
"Phaser loader failed to load one or more files:",
|
|
752
|
-
...loaderFailures.map(
|
|
753
|
-
(failure) => `- ${failure.scene}: failed to load ${failure.type} ${JSON.stringify(failure.key)} from ${JSON.stringify(failure.source)}`
|
|
754
|
-
),
|
|
755
|
-
hasHeadlessImageFailure ? "HEADLESS image loading supports data:image/ URLs only; use a data URL or a deliberate loader stub in tests, and verify production asset paths in a browser." : "Verify the resource URL and data, or install a deliberate loader stub for network resources in HEADLESS tests."
|
|
756
|
-
].join("\n")
|
|
757
|
-
);
|
|
758
|
-
}
|
|
759
|
-
if (engineDiagnostics.length > 0) {
|
|
760
|
-
throw new Error(
|
|
761
|
-
[
|
|
762
|
-
"Phaser emitted an engine diagnostic:",
|
|
763
|
-
...engineDiagnostics.flatMap((diagnostic) => [
|
|
764
|
-
`- ${diagnostic.level}: ${diagnostic.message}`,
|
|
765
|
-
...diagnostic.stack.split("\n").slice(2, 8).map((frame) => ` ${frame.trim()}`)
|
|
766
|
-
]),
|
|
767
|
-
"Fix the invalid Phaser operation. Only known warnings (not errors) may be allowed with ignoreEngineWarnings."
|
|
768
|
-
].join("\n")
|
|
769
|
-
);
|
|
770
|
-
}
|
|
771
|
-
};
|
|
772
|
-
const canvas = readyGame.canvas;
|
|
773
|
-
if (!canvas) {
|
|
774
|
-
removeRuntimeListeners();
|
|
775
|
-
restoreHostGuards();
|
|
776
|
-
stopObservingEngineDiagnostics();
|
|
777
|
-
readyGame.destroy(true);
|
|
778
|
-
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
779
|
-
throw new Error("Phaser HEADLESS did not create an input canvas");
|
|
780
|
-
}
|
|
781
|
-
const inputWindow = canvas.ownerDocument.defaultView;
|
|
782
|
-
if (!inputWindow) {
|
|
783
|
-
removeRuntimeListeners();
|
|
784
|
-
restoreHostGuards();
|
|
785
|
-
stopObservingEngineDiagnostics();
|
|
786
|
-
readyGame.destroy(true);
|
|
787
|
-
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
788
|
-
throw new Error(
|
|
789
|
-
"Phaser HEADLESS input canvas is not attached to a DOM Window"
|
|
790
|
-
);
|
|
791
|
-
}
|
|
792
|
-
let canvasBounds = {
|
|
793
|
-
left: 0,
|
|
794
|
-
top: 0,
|
|
795
|
-
width: canvas.width,
|
|
796
|
-
height: canvas.height
|
|
797
|
-
};
|
|
798
|
-
const setCanvasBounds = (bounds) => {
|
|
799
|
-
const values = [bounds.left, bounds.top, bounds.width, bounds.height];
|
|
800
|
-
if (!values.every(Number.isFinite) || bounds.width <= 0 || bounds.height <= 0) {
|
|
801
|
-
throw new Error(
|
|
802
|
-
`HEADLESS canvas bounds must be finite with positive dimensions; received ${JSON.stringify(bounds)}`
|
|
803
|
-
);
|
|
804
|
-
}
|
|
805
|
-
canvasBounds = { ...bounds };
|
|
806
|
-
canvas.getBoundingClientRect = () => ({
|
|
807
|
-
x: canvasBounds.left,
|
|
808
|
-
y: canvasBounds.top,
|
|
809
|
-
left: canvasBounds.left,
|
|
810
|
-
top: canvasBounds.top,
|
|
811
|
-
right: canvasBounds.left + canvasBounds.width,
|
|
812
|
-
bottom: canvasBounds.top + canvasBounds.height,
|
|
813
|
-
width: canvasBounds.width,
|
|
814
|
-
height: canvasBounds.height,
|
|
815
|
-
toJSON: () => ({ ...canvasBounds })
|
|
816
|
-
});
|
|
817
|
-
readyGame.scale.updateBounds();
|
|
818
|
-
readyGame.scale.displayScale.set(
|
|
819
|
-
readyGame.scale.baseSize.width / canvasBounds.width,
|
|
820
|
-
readyGame.scale.baseSize.height / canvasBounds.height
|
|
821
|
-
);
|
|
822
|
-
};
|
|
823
|
-
setCanvasBounds(canvasBounds);
|
|
824
|
-
const gameToClient = (x, y) => {
|
|
825
|
-
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
826
|
-
throw new Error(
|
|
827
|
-
`HEADLESS input coordinates must be finite; received (${x}, ${y})`
|
|
828
|
-
);
|
|
829
|
-
}
|
|
830
|
-
return {
|
|
831
|
-
clientX: canvasBounds.left + x / readyGame.scale.displayScale.x,
|
|
832
|
-
clientY: canvasBounds.top + y / readyGame.scale.displayScale.y
|
|
833
|
-
};
|
|
834
|
-
};
|
|
835
|
-
const assertHealth = () => {
|
|
836
|
-
for (const currentScene of readyGame.scene.getScenes(false)) {
|
|
837
|
-
appendUnique(registeredScenes, currentScene.sys.settings.key);
|
|
838
|
-
if (!currentScene.sys.isActive() && !currentScene.sys.isPaused())
|
|
839
|
-
continue;
|
|
840
|
-
appendUnique(visitedScenes, currentScene.sys.settings.key);
|
|
841
|
-
if (!allowIdleLoaderQueues.includes(currentScene.sys.settings.key) && !currentScene.load.isLoading() && currentScene.load.list.size > 0) {
|
|
842
|
-
const queuedFiles = [...currentScene.load.list].map(
|
|
843
|
-
(file) => `${file.type}:${String(file.key)}`
|
|
844
|
-
);
|
|
845
|
-
throw new Error(
|
|
846
|
-
`Phaser loader queue is not running in scene ${JSON.stringify(currentScene.sys.settings.key)}: ${queuedFiles.length} file(s) are queued (${queuedFiles.join(", ")}). Files added outside preload() require this.load.start().`
|
|
847
|
-
);
|
|
848
|
-
}
|
|
849
|
-
assertSceneTextHealth(currentScene);
|
|
850
|
-
assertSceneInteractiveHealth(currentScene);
|
|
851
|
-
assertSceneRuntimeHealth(currentScene);
|
|
852
|
-
}
|
|
853
|
-
};
|
|
854
|
-
const settleRuntime = async () => {
|
|
855
|
-
await new Promise((resolve) => inputWindow.setTimeout(resolve, 0));
|
|
856
|
-
throwRuntimeError();
|
|
857
|
-
assertHealth();
|
|
858
|
-
};
|
|
859
|
-
const getEventTarget = (target, kind) => {
|
|
860
|
-
if (!target || typeof Reflect.get(target, "dispatchEvent") !== "function") {
|
|
861
|
-
throw new Error(
|
|
862
|
-
`Phaser HEADLESS ${kind} input target cannot dispatch DOM events`
|
|
863
|
-
);
|
|
864
|
-
}
|
|
865
|
-
return target;
|
|
866
|
-
};
|
|
867
|
-
const dispatchMouse = async (type, x, y, buttons) => {
|
|
868
|
-
const { clientX, clientY } = gameToClient(x, y);
|
|
869
|
-
const mouseTarget = getEventTarget(readyGame.input.mouse?.target, "mouse");
|
|
870
|
-
mouseTarget.dispatchEvent(
|
|
871
|
-
new inputWindow.MouseEvent(type, {
|
|
872
|
-
bubbles: true,
|
|
873
|
-
cancelable: true,
|
|
874
|
-
button: 0,
|
|
875
|
-
buttons,
|
|
876
|
-
clientX,
|
|
877
|
-
clientY
|
|
878
|
-
})
|
|
879
|
-
);
|
|
880
|
-
evidence.mouseEvents += 1;
|
|
881
|
-
await settleRuntime();
|
|
882
|
-
};
|
|
883
|
-
const keyIdentity = (keyCode) => {
|
|
884
|
-
if (keyCode >= 65 && keyCode <= 90) {
|
|
885
|
-
const letter = String.fromCharCode(keyCode);
|
|
886
|
-
return { key: letter.toLowerCase(), code: `Key${letter}` };
|
|
887
|
-
}
|
|
888
|
-
if (keyCode >= 48 && keyCode <= 57) {
|
|
889
|
-
const digit = String.fromCharCode(keyCode);
|
|
890
|
-
return { key: digit, code: `Digit${digit}` };
|
|
891
|
-
}
|
|
892
|
-
const identities = /* @__PURE__ */ new Map([
|
|
893
|
-
[
|
|
894
|
-
Phaser3.Input.Keyboard.KeyCodes.LEFT,
|
|
895
|
-
{ key: "ArrowLeft", code: "ArrowLeft" }
|
|
896
|
-
],
|
|
897
|
-
[
|
|
898
|
-
Phaser3.Input.Keyboard.KeyCodes.RIGHT,
|
|
899
|
-
{ key: "ArrowRight", code: "ArrowRight" }
|
|
900
|
-
],
|
|
901
|
-
[Phaser3.Input.Keyboard.KeyCodes.UP, { key: "ArrowUp", code: "ArrowUp" }],
|
|
902
|
-
[
|
|
903
|
-
Phaser3.Input.Keyboard.KeyCodes.DOWN,
|
|
904
|
-
{ key: "ArrowDown", code: "ArrowDown" }
|
|
905
|
-
],
|
|
906
|
-
[Phaser3.Input.Keyboard.KeyCodes.SPACE, { key: " ", code: "Space" }],
|
|
907
|
-
[Phaser3.Input.Keyboard.KeyCodes.ENTER, { key: "Enter", code: "Enter" }],
|
|
908
|
-
[Phaser3.Input.Keyboard.KeyCodes.ESC, { key: "Escape", code: "Escape" }]
|
|
909
|
-
]);
|
|
910
|
-
return identities.get(keyCode) ?? { key: "", code: "" };
|
|
911
|
-
};
|
|
912
|
-
const dispatchKeyboard = async (type, keyCode, options2 = {}) => {
|
|
913
|
-
if (!Number.isInteger(keyCode) || keyCode < 0) {
|
|
914
|
-
throw new Error(
|
|
915
|
-
`HEADLESS keyboard keyCode must be a non-negative integer; received ${keyCode}`
|
|
916
|
-
);
|
|
917
|
-
}
|
|
918
|
-
const identity = keyIdentity(keyCode);
|
|
919
|
-
const event = new inputWindow.KeyboardEvent(type, {
|
|
920
|
-
bubbles: true,
|
|
921
|
-
cancelable: true,
|
|
922
|
-
key: options2.key ?? identity.key,
|
|
923
|
-
code: options2.code ?? identity.code,
|
|
924
|
-
repeat: options2.repeat ?? false,
|
|
925
|
-
altKey: options2.altKey ?? false,
|
|
926
|
-
ctrlKey: options2.ctrlKey ?? false,
|
|
927
|
-
metaKey: options2.metaKey ?? false,
|
|
928
|
-
shiftKey: options2.shiftKey ?? false
|
|
929
|
-
});
|
|
930
|
-
Object.defineProperties(event, {
|
|
931
|
-
// Phaser KeyboardManager 仍使用这些兼容旧浏览器的数字字段。
|
|
932
|
-
keyCode: { value: keyCode },
|
|
933
|
-
which: { value: keyCode }
|
|
934
|
-
});
|
|
935
|
-
const keyboardTarget = getEventTarget(
|
|
936
|
-
readyGame.input.keyboard?.target,
|
|
937
|
-
"keyboard"
|
|
938
|
-
);
|
|
939
|
-
keyboardTarget.dispatchEvent(event);
|
|
940
|
-
evidence.keyboardEvents += 1;
|
|
941
|
-
await settleRuntime();
|
|
942
|
-
};
|
|
943
|
-
const activeTouches = /* @__PURE__ */ new Map();
|
|
944
|
-
const createTouch = (x, y, identifier, target) => {
|
|
945
|
-
if (!Number.isInteger(identifier) || identifier < 0) {
|
|
946
|
-
throw new Error(
|
|
947
|
-
`HEADLESS touch identifier must be a non-negative integer; received ${identifier}`
|
|
948
|
-
);
|
|
949
|
-
}
|
|
950
|
-
const { clientX, clientY } = gameToClient(x, y);
|
|
951
|
-
return {
|
|
952
|
-
identifier,
|
|
953
|
-
target,
|
|
954
|
-
clientX,
|
|
955
|
-
clientY,
|
|
956
|
-
pageX: clientX,
|
|
957
|
-
pageY: clientY,
|
|
958
|
-
screenX: clientX,
|
|
959
|
-
screenY: clientY,
|
|
960
|
-
radiusX: 1,
|
|
961
|
-
radiusY: 1,
|
|
962
|
-
rotationAngle: 0,
|
|
963
|
-
force: 1
|
|
964
|
-
};
|
|
965
|
-
};
|
|
966
|
-
const dispatchTouch = async (type, x, y, identifier) => {
|
|
967
|
-
if (type === "touchstart" && activeTouches.has(identifier)) {
|
|
968
|
-
throw new Error(
|
|
969
|
-
`HEADLESS touch identifier ${identifier} is already active`
|
|
970
|
-
);
|
|
971
|
-
}
|
|
972
|
-
if (type !== "touchstart" && !activeTouches.has(identifier)) {
|
|
973
|
-
throw new Error(`HEADLESS touch identifier ${identifier} is not active`);
|
|
974
|
-
}
|
|
975
|
-
const touchTarget = getEventTarget(readyGame.input.touch?.target, "touch");
|
|
976
|
-
const changedTouch = createTouch(x, y, identifier, touchTarget);
|
|
977
|
-
if (type === "touchstart" || type === "touchmove") {
|
|
978
|
-
activeTouches.set(identifier, changedTouch);
|
|
979
|
-
} else {
|
|
980
|
-
activeTouches.delete(identifier);
|
|
981
|
-
}
|
|
982
|
-
const touches = [...activeTouches.values()];
|
|
983
|
-
const event = new inputWindow.Event(type, {
|
|
984
|
-
bubbles: true,
|
|
985
|
-
cancelable: true
|
|
986
|
-
});
|
|
987
|
-
Object.defineProperties(event, {
|
|
988
|
-
changedTouches: { value: [changedTouch] },
|
|
989
|
-
targetTouches: { value: touches },
|
|
990
|
-
touches: { value: touches }
|
|
991
|
-
});
|
|
992
|
-
const document = inputWindow.document;
|
|
993
|
-
const originalElementFromPoint = document.elementFromPoint;
|
|
994
|
-
const originalElementFromPointDescriptor = Object.getOwnPropertyDescriptor(
|
|
995
|
-
document,
|
|
996
|
-
"elementFromPoint"
|
|
997
|
-
);
|
|
998
|
-
Object.defineProperty(document, "elementFromPoint", {
|
|
999
|
-
configurable: true,
|
|
1000
|
-
value(clientX, clientY) {
|
|
1001
|
-
const insideCanvas = clientX >= canvasBounds.left && clientX <= canvasBounds.left + canvasBounds.width && clientY >= canvasBounds.top && clientY <= canvasBounds.top + canvasBounds.height;
|
|
1002
|
-
return insideCanvas ? canvas : originalElementFromPoint?.call(document, clientX, clientY) ?? null;
|
|
1003
|
-
}
|
|
1004
|
-
});
|
|
1005
|
-
try {
|
|
1006
|
-
touchTarget.dispatchEvent(event);
|
|
1007
|
-
evidence.touchEvents += 1;
|
|
1008
|
-
} finally {
|
|
1009
|
-
if (originalElementFromPointDescriptor) {
|
|
1010
|
-
Object.defineProperty(
|
|
1011
|
-
document,
|
|
1012
|
-
"elementFromPoint",
|
|
1013
|
-
originalElementFromPointDescriptor
|
|
1014
|
-
);
|
|
1015
|
-
} else {
|
|
1016
|
-
Reflect.deleteProperty(document, "elementFromPoint");
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
await settleRuntime();
|
|
1020
|
-
};
|
|
1021
|
-
const input = {
|
|
1022
|
-
canvas,
|
|
1023
|
-
setCanvasBounds,
|
|
1024
|
-
gameToClient,
|
|
1025
|
-
settle: settleRuntime,
|
|
1026
|
-
mouse: {
|
|
1027
|
-
move: (x, y, buttons = 0) => dispatchMouse("mousemove", x, y, buttons),
|
|
1028
|
-
down: (x, y) => dispatchMouse("mousedown", x, y, 1),
|
|
1029
|
-
up: (x, y) => dispatchMouse("mouseup", x, y, 0),
|
|
1030
|
-
async click(x, y) {
|
|
1031
|
-
evidence.clicks += 1;
|
|
1032
|
-
await dispatchMouse("mousemove", x, y, 0);
|
|
1033
|
-
await dispatchMouse("mousedown", x, y, 1);
|
|
1034
|
-
await dispatchMouse("mouseup", x, y, 0);
|
|
1035
|
-
}
|
|
1036
|
-
},
|
|
1037
|
-
keyboard: {
|
|
1038
|
-
down: (keyCode, options2) => dispatchKeyboard("keydown", keyCode, options2),
|
|
1039
|
-
up: (keyCode, options2) => dispatchKeyboard("keyup", keyCode, options2),
|
|
1040
|
-
async press(keyCode, options2) {
|
|
1041
|
-
await dispatchKeyboard("keydown", keyCode, options2);
|
|
1042
|
-
await dispatchKeyboard("keyup", keyCode, options2);
|
|
1043
|
-
}
|
|
1044
|
-
},
|
|
1045
|
-
touch: {
|
|
1046
|
-
start: (x, y, identifier = 1) => dispatchTouch("touchstart", x, y, identifier),
|
|
1047
|
-
move: (x, y, identifier = 1) => dispatchTouch("touchmove", x, y, identifier),
|
|
1048
|
-
end: (x, y, identifier = 1) => dispatchTouch("touchend", x, y, identifier),
|
|
1049
|
-
cancel: (x, y, identifier = 1) => dispatchTouch("touchcancel", x, y, identifier),
|
|
1050
|
-
async tap(x, y, identifier = 1) {
|
|
1051
|
-
await dispatchTouch("touchstart", x, y, identifier);
|
|
1052
|
-
await dispatchTouch("touchend", x, y, identifier);
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
};
|
|
1056
|
-
const frameDurationMs = 1e3 / (config.fps?.target ?? 60);
|
|
1057
|
-
const stepFrame = () => {
|
|
1058
|
-
readyGame.loop.step(readyGame.loop.lastTime + frameDurationMs);
|
|
1059
|
-
evidence.frames += 1;
|
|
1060
|
-
};
|
|
1061
|
-
const stepFrames = (count = 1) => {
|
|
1062
|
-
assertStepCount(count, "stepFrames count");
|
|
1063
|
-
throwRuntimeError();
|
|
1064
|
-
for (let frame = 0; frame < count; frame += 1) stepFrame();
|
|
1065
|
-
throwRuntimeError();
|
|
1066
|
-
assertHealth();
|
|
1067
|
-
};
|
|
1068
|
-
try {
|
|
1069
|
-
throwRuntimeError();
|
|
1070
|
-
assertHealth();
|
|
1071
|
-
} catch (error) {
|
|
1072
|
-
removeRuntimeListeners();
|
|
1073
|
-
restoreHostGuards();
|
|
1074
|
-
stopObservingEngineDiagnostics();
|
|
1075
|
-
readyGame.destroy(true);
|
|
1076
|
-
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
1077
|
-
throw error;
|
|
1078
|
-
}
|
|
1079
|
-
let destroyed = false;
|
|
1080
|
-
return {
|
|
1081
|
-
game: readyGame,
|
|
1082
|
-
scene,
|
|
1083
|
-
settle: settleRuntime,
|
|
1084
|
-
input,
|
|
1085
|
-
evidence,
|
|
1086
|
-
assertGameplayEvidence(requirements = {}) {
|
|
1087
|
-
const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
|
|
1088
|
-
if (requirements.requireInput && inputEvents === 0) {
|
|
1089
|
-
throw new Error(
|
|
1090
|
-
"Gameplay evidence is missing real HEADLESS input events."
|
|
1091
|
-
);
|
|
1092
|
-
}
|
|
1093
|
-
if (requirements.requireFrameAdvance && evidence.frames === 0) {
|
|
1094
|
-
throw new Error(
|
|
1095
|
-
"Gameplay evidence is missing complete Phaser frame advancement."
|
|
1096
|
-
);
|
|
1097
|
-
}
|
|
1098
|
-
if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
|
|
1099
|
-
throw new Error(
|
|
1100
|
-
"Gameplay evidence is missing Arcade Physics advancement."
|
|
1101
|
-
);
|
|
1102
|
-
}
|
|
1103
|
-
const requiredTransitions = requirements.requireTransition === void 0 ? [] : typeof requirements.requireTransition === "string" ? [requirements.requireTransition] : requirements.requireTransition;
|
|
1104
|
-
for (const target of requiredTransitions) {
|
|
1105
|
-
if (!evidence.transitions.some((transition) => transition.to === target)) {
|
|
1106
|
-
throw new Error(
|
|
1107
|
-
`Gameplay evidence is missing a transition to ${target}.`
|
|
1108
|
-
);
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
const requiredScenes = requirements.requireScene === void 0 ? [] : typeof requirements.requireScene === "string" ? [requirements.requireScene] : requirements.requireScene;
|
|
1112
|
-
for (const sceneKey of requiredScenes) {
|
|
1113
|
-
if (!evidence.visitedScenes.includes(sceneKey)) {
|
|
1114
|
-
throw new Error(
|
|
1115
|
-
`Gameplay evidence is missing a visit to Scene ${sceneKey}.`
|
|
1116
|
-
);
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
const requiredRestarts = requirements.requireRestart === void 0 ? [] : typeof requirements.requireRestart === "string" ? [requirements.requireRestart] : requirements.requireRestart;
|
|
1120
|
-
for (const sceneKey of requiredRestarts) {
|
|
1121
|
-
if (!evidence.restartedScenes.includes(sceneKey)) {
|
|
1122
|
-
throw new Error(
|
|
1123
|
-
`Gameplay evidence is missing a restart of Scene ${sceneKey}.`
|
|
1124
|
-
);
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
const requiredCheckpoints = requirements.requireCheckpoint === void 0 ? [] : typeof requirements.requireCheckpoint === "string" ? [requirements.requireCheckpoint] : requirements.requireCheckpoint;
|
|
1128
|
-
for (const checkpoint of requiredCheckpoints) {
|
|
1129
|
-
if (!evidence.checkpoints.includes(checkpoint)) {
|
|
1130
|
-
throw new Error(
|
|
1131
|
-
`Gameplay evidence is missing checkpoint ${checkpoint}.`
|
|
1132
|
-
);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
if (requirements.requireDestroy && !evidence.destroyed) {
|
|
1136
|
-
throw new Error(
|
|
1137
|
-
"Gameplay evidence is missing HEADLESS host destruction."
|
|
1138
|
-
);
|
|
1139
|
-
}
|
|
1140
|
-
},
|
|
1141
|
-
stepFrames,
|
|
1142
|
-
async stepFramesAsync(count = 1) {
|
|
1143
|
-
stepFrames(count);
|
|
1144
|
-
await settleRuntime();
|
|
1145
|
-
},
|
|
1146
|
-
stepUntil(condition, maxFramesOrOptions = 120) {
|
|
1147
|
-
const maxFrames = typeof maxFramesOrOptions === "number" ? maxFramesOrOptions : maxFramesOrOptions.maxFrames ?? 120;
|
|
1148
|
-
assertStepCount(maxFrames, "stepUntil maxFrames");
|
|
1149
|
-
throwRuntimeError();
|
|
1150
|
-
for (let frame = 0; frame <= maxFrames; frame += 1) {
|
|
1151
|
-
if (condition()) {
|
|
1152
|
-
assertHealth();
|
|
1153
|
-
return frame;
|
|
1154
|
-
}
|
|
1155
|
-
if (frame < maxFrames) {
|
|
1156
|
-
stepFrame();
|
|
1157
|
-
throwRuntimeError();
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
assertHealth();
|
|
1161
|
-
throw new Error(
|
|
1162
|
-
`Condition was not met within ${maxFrames} complete Phaser frames`
|
|
1163
|
-
);
|
|
1164
|
-
},
|
|
1165
|
-
stepPhysics(steps = 1) {
|
|
1166
|
-
assertStepCount(steps, "stepPhysics steps");
|
|
1167
|
-
throwRuntimeError();
|
|
1168
|
-
const world = scene.physics?.world;
|
|
1169
|
-
if (!world || !getArcadeWorldBodies(world) || typeof world.singleStep !== "function") {
|
|
1170
|
-
throw new Error(
|
|
1171
|
-
"stepPhysics() requires an Arcade Physics world; use stepFrames() for other configurations."
|
|
1172
|
-
);
|
|
1173
|
-
}
|
|
1174
|
-
for (let step = 0; step < steps; step += 1) {
|
|
1175
|
-
scene.physics.world.singleStep();
|
|
1176
|
-
evidence.physicsSteps += 1;
|
|
1177
|
-
}
|
|
1178
|
-
throwRuntimeError();
|
|
1179
|
-
assertHealth();
|
|
1180
|
-
},
|
|
1181
|
-
checkpoint(id) {
|
|
1182
|
-
const normalizedId = id.trim();
|
|
1183
|
-
if (!normalizedId || normalizedId.length > 120) {
|
|
1184
|
-
throw new Error(
|
|
1185
|
-
"Gameplay checkpoint id must contain 1 to 120 non-whitespace characters."
|
|
1186
|
-
);
|
|
1187
|
-
}
|
|
1188
|
-
appendUnique(checkpoints, normalizedId);
|
|
1189
|
-
},
|
|
1190
|
-
assertTextHealth() {
|
|
1191
|
-
assertSceneTextHealth(scene);
|
|
1192
|
-
},
|
|
1193
|
-
destroy() {
|
|
1194
|
-
if (destroyed) {
|
|
1195
|
-
return;
|
|
1196
|
-
}
|
|
1197
|
-
destroyed = true;
|
|
1198
|
-
try {
|
|
1199
|
-
readyGame.destroy(true);
|
|
1200
|
-
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
1201
|
-
throwRuntimeError();
|
|
1202
|
-
evidence.destroyed = true;
|
|
1203
|
-
} finally {
|
|
1204
|
-
removeRuntimeListeners();
|
|
1205
|
-
restoreHostGuards();
|
|
1206
|
-
stopObservingEngineDiagnostics();
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
};
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
// src/gameplay-contracts.ts
|
|
1213
|
-
import { test } from "vitest";
|
|
1214
|
-
|
|
1215
|
-
// src/gameplay-audit.ts
|
|
1216
|
-
import { readdirSync, readFileSync, statSync } from "fs";
|
|
1217
|
-
import { join } from "path";
|
|
1218
|
-
|
|
1219
|
-
// src/game-telemetry.ts
|
|
1220
|
-
import * as Phaser4 from "phaser";
|
|
1221
|
-
var GAME_TELEMETRY_GLOBAL = "gameTelemetry";
|
|
1222
|
-
function telemetryGlobal() {
|
|
1223
|
-
return globalThis;
|
|
1224
|
-
}
|
|
1225
|
-
function assertTelemetryShell(value) {
|
|
1226
|
-
if (value.version !== 1 || !value.read || typeof value.read.session !== "function" || !value.controls || typeof value.controls !== "object") {
|
|
1227
|
-
throw new Error(
|
|
1228
|
-
"Game Telemetry must provide version 1, read.session(), and a controls object."
|
|
1229
|
-
);
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
function installGameTelemetry(owner, telemetry) {
|
|
1233
|
-
assertTelemetryShell(telemetry);
|
|
1234
|
-
const scope = telemetryGlobal();
|
|
1235
|
-
if (scope[GAME_TELEMETRY_GLOBAL]) {
|
|
1236
|
-
throw new Error(
|
|
1237
|
-
"Only one active owner may install globalThis.gameTelemetry. Use one game-owned facade or clean up the current Scene-owned facade first."
|
|
1238
|
-
);
|
|
1239
|
-
}
|
|
1240
|
-
const events = owner.events;
|
|
1241
|
-
const lifecycleEvents = owner instanceof Phaser4.Game ? [Phaser4.Core.Events.DESTROY] : [Phaser4.Scenes.Events.SHUTDOWN, Phaser4.Scenes.Events.DESTROY];
|
|
1242
|
-
let installed = true;
|
|
1243
|
-
const cleanup = () => {
|
|
1244
|
-
if (!installed) return;
|
|
1245
|
-
installed = false;
|
|
1246
|
-
for (const event of lifecycleEvents) events.off(event, cleanup);
|
|
1247
|
-
if (scope[GAME_TELEMETRY_GLOBAL] === telemetry) {
|
|
1248
|
-
scope[GAME_TELEMETRY_GLOBAL] = void 0;
|
|
1249
|
-
}
|
|
1250
|
-
};
|
|
1251
|
-
scope[GAME_TELEMETRY_GLOBAL] = telemetry;
|
|
1252
|
-
for (const event of lifecycleEvents) events.once(event, cleanup);
|
|
1253
|
-
return cleanup;
|
|
1254
|
-
}
|
|
1255
|
-
function getGameTelemetry() {
|
|
1256
|
-
const telemetry = telemetryGlobal()[GAME_TELEMETRY_GLOBAL];
|
|
1257
|
-
if (!telemetry) {
|
|
1258
|
-
throw new Error(
|
|
1259
|
-
"Game Telemetry is not installed. Install it from a Scene create() method with a Game or Scene lifecycle owner."
|
|
1260
|
-
);
|
|
1261
|
-
}
|
|
1262
|
-
return telemetry;
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
// src/lint/game-telemetry.test.ts
|
|
1266
|
-
var TelemetryScene = class _TelemetryScene extends Phaser5.Scene {
|
|
1267
|
-
static generation = 0;
|
|
1268
|
-
value = 0;
|
|
1269
|
-
commandCalls = 0;
|
|
1270
|
-
currentGeneration = 0;
|
|
1271
|
-
constructor() {
|
|
1272
|
-
super("TelemetryScene");
|
|
1273
|
-
}
|
|
1274
|
-
create() {
|
|
1275
|
-
this.currentGeneration = ++_TelemetryScene.generation;
|
|
1276
|
-
installGameTelemetry(this, {
|
|
1277
|
-
version: 1,
|
|
1278
|
-
read: {
|
|
1279
|
-
session: () => ({
|
|
1280
|
-
generation: this.currentGeneration,
|
|
1281
|
-
value: this.value,
|
|
1282
|
-
commandCalls: this.commandCalls
|
|
1283
|
-
})
|
|
1284
|
-
},
|
|
1285
|
-
controls: {
|
|
1286
|
-
increment: (amount) => this.increment(amount),
|
|
1287
|
-
restart: () => this.scene.restart()
|
|
1288
|
-
}
|
|
1289
|
-
});
|
|
1290
|
-
}
|
|
1291
|
-
increment(amount) {
|
|
1292
|
-
this.commandCalls += 1;
|
|
1293
|
-
this.value += amount;
|
|
1294
|
-
}
|
|
1295
|
-
};
|
|
1296
|
-
var PassiveScene = class extends Phaser5.Scene {
|
|
1297
|
-
constructor() {
|
|
1298
|
-
super("PassiveScene");
|
|
1299
|
-
}
|
|
1300
|
-
};
|
|
1301
|
-
var GameOwnedTargetScene = class extends Phaser5.Scene {
|
|
1302
|
-
constructor() {
|
|
1303
|
-
super("GameOwnedTargetScene");
|
|
1304
|
-
}
|
|
1305
|
-
};
|
|
1306
|
-
var GameOwnedSourceScene = class extends Phaser5.Scene {
|
|
1307
|
-
constructor() {
|
|
1308
|
-
super("GameOwnedSourceScene");
|
|
1309
|
-
}
|
|
1310
|
-
create() {
|
|
1311
|
-
installGameTelemetry(this.game, {
|
|
1312
|
-
version: 1,
|
|
1313
|
-
read: {
|
|
1314
|
-
session: () => ({
|
|
1315
|
-
activeScene: this.game.scene.getScenes(true)[0]?.sys.settings.key ?? ""
|
|
1316
|
-
})
|
|
1317
|
-
},
|
|
1318
|
-
controls: {
|
|
1319
|
-
startTarget: () => this.scene.start("GameOwnedTargetScene")
|
|
1320
|
-
}
|
|
1321
|
-
});
|
|
1322
|
-
}
|
|
1323
|
-
};
|
|
1324
|
-
describe("game Telemetry lifecycle", () => {
|
|
1325
|
-
let host;
|
|
1326
|
-
afterEach(() => {
|
|
1327
|
-
host?.destroy();
|
|
1328
|
-
host = void 0;
|
|
1329
|
-
});
|
|
1330
|
-
it("drives the same authoritative command exposed by the Scene", async () => {
|
|
1331
|
-
host = await createHeadlessGame(new TelemetryScene());
|
|
1332
|
-
const telemetry = getGameTelemetry();
|
|
1333
|
-
telemetry.controls.increment(3);
|
|
1334
|
-
expect(telemetry.read.session()).toMatchObject({
|
|
1335
|
-
value: 3,
|
|
1336
|
-
commandCalls: 1
|
|
1337
|
-
});
|
|
1338
|
-
});
|
|
1339
|
-
it("replaces the handle across restart and removes it on destroy", async () => {
|
|
1340
|
-
host = await createHeadlessGame(new TelemetryScene());
|
|
1341
|
-
const first = getGameTelemetry();
|
|
1342
|
-
const firstGeneration = first.read.session().generation;
|
|
1343
|
-
first.controls.restart();
|
|
1344
|
-
host.stepUntil(
|
|
1345
|
-
() => getGameTelemetry().read.session().generation !== firstGeneration,
|
|
1346
|
-
60
|
|
1347
|
-
);
|
|
1348
|
-
const restarted = getGameTelemetry();
|
|
1349
|
-
expect(restarted).not.toBe(first);
|
|
1350
|
-
expect(restarted.read.session()).toMatchObject({ value: 0, commandCalls: 0 });
|
|
1351
|
-
host.destroy();
|
|
1352
|
-
expect(() => getGameTelemetry()).toThrow(
|
|
1353
|
-
"Game Telemetry is not installed"
|
|
1354
|
-
);
|
|
1355
|
-
});
|
|
1356
|
-
it("keeps a game-owned handle across Scene shutdown and removes it on destroy", async () => {
|
|
1357
|
-
const gameOwnedHost = await createHeadlessGame(new GameOwnedSourceScene(), {
|
|
1358
|
-
additionalScenes: [GameOwnedTargetScene]
|
|
1359
|
-
});
|
|
1360
|
-
host = gameOwnedHost;
|
|
1361
|
-
const telemetry = getGameTelemetry();
|
|
1362
|
-
telemetry.controls.startTarget();
|
|
1363
|
-
gameOwnedHost.stepUntil(
|
|
1364
|
-
() => gameOwnedHost.game.scene.isActive("GameOwnedTargetScene")
|
|
1365
|
-
);
|
|
1366
|
-
expect(gameOwnedHost.game.scene.isActive("GameOwnedSourceScene")).toBe(false);
|
|
1367
|
-
expect(telemetry.read.session().activeScene).toBe("GameOwnedTargetScene");
|
|
1368
|
-
expect(getGameTelemetry()).toBe(telemetry);
|
|
1369
|
-
gameOwnedHost.destroy();
|
|
1370
|
-
expect(() => getGameTelemetry()).toThrow(
|
|
1371
|
-
"Game Telemetry is not installed"
|
|
1372
|
-
);
|
|
1373
|
-
});
|
|
1374
|
-
it("rejects a different active Scene owner instead of silently clobbering the handle", async () => {
|
|
1375
|
-
const currentHost = await createHeadlessGame(new TelemetryScene(), {
|
|
1376
|
-
additionalScenes: [PassiveScene]
|
|
1377
|
-
});
|
|
1378
|
-
host = currentHost;
|
|
1379
|
-
const first = getGameTelemetry();
|
|
1380
|
-
currentHost.scene.scene.launch("PassiveScene");
|
|
1381
|
-
currentHost.stepUntil(() => currentHost.game.scene.isActive("PassiveScene"));
|
|
1382
|
-
const passiveScene = currentHost.game.scene.getScene("PassiveScene");
|
|
1383
|
-
const competingTelemetry = {
|
|
1384
|
-
version: 1,
|
|
1385
|
-
read: {
|
|
1386
|
-
session: () => ({ generation: 0, value: 0, commandCalls: 0 })
|
|
1387
|
-
},
|
|
1388
|
-
controls: {
|
|
1389
|
-
increment: () => {
|
|
1390
|
-
},
|
|
1391
|
-
restart: () => {
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
};
|
|
1395
|
-
expect(
|
|
1396
|
-
() => installGameTelemetry(passiveScene, competingTelemetry)
|
|
1397
|
-
).toThrow(
|
|
1398
|
-
"Only one active owner may install globalThis.gameTelemetry"
|
|
1399
|
-
);
|
|
1400
|
-
expect(getGameTelemetry()).toBe(first);
|
|
1401
|
-
});
|
|
1402
|
-
});
|