@threenative/core 0.2.0 → 0.3.1
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/LICENSE +21 -0
- package/README.md +55 -0
- package/capabilities.json +5292 -0
- package/dist/assets-kyoF7JlJ.d.ts +103 -0
- package/dist/audio-BFiGneTL.d.ts +156 -0
- package/dist/canvas-layer-BLVijiUJ.d.ts +62 -0
- package/dist/game-XGrTzapq.d.ts +1166 -0
- package/dist/gpu-readback-D2iRvoe9.d.ts +112 -0
- package/dist/hot.d.ts +20 -2
- package/dist/hot.js +14 -2
- package/dist/index.d.ts +2108 -55
- package/dist/index.js +15645 -2222
- package/dist/net.d.ts +65 -0
- package/dist/net.js +643 -0
- package/dist/playtest.d.ts +37 -4
- package/dist/playtest.js +246 -546
- package/dist/react.d.ts +177 -0
- package/dist/react.js +635 -0
- package/dist/renderer-C6hqZpoG.d.ts +770 -0
- package/dist/ui-layer.d.ts +306 -0
- package/dist/ui-layer.js +425 -0
- package/dist/world.d.ts +254 -0
- package/dist/world.js +2686 -0
- package/gpl/LICENSE.GPL +117 -0
- package/gpl/convert.py +192 -0
- package/gpl/recipes/_common.py +169 -0
- package/gpl/recipes/bake_ao.py +111 -0
- package/gpl/recipes/decimate.py +64 -0
- package/gpl/recipes/retarget.py +131 -0
- package/gpl/recipes/unwrap.py +71 -0
- package/mcp/assets.mjs +5 -0
- package/mcp/blender-server.mjs +632 -0
- package/mcp/blender.mjs +27 -0
- package/mcp/engine-server.mjs +501 -0
- package/mcp/engine.mjs +31 -0
- package/mcp/install.d.mts +37 -0
- package/mcp/install.mjs +145 -0
- package/mcp/launch.mjs +72 -0
- package/mcp/sculpt.mjs +5 -0
- package/mcp/servers.d.mts +34 -0
- package/mcp/servers.mjs +160 -0
- package/package.json +76 -6
- package/patches/three@0.185.1.patch +522 -0
- package/scripts/apply-three-patch.mjs +297 -0
- package/scripts/ensure-mcp.mjs +43 -0
- package/scripts/postinstall.mjs +6 -0
- package/dist/audio-CEAw0w5y.d.ts +0 -35
- package/dist/game-DRt1Qhq3.d.ts +0 -429
package/dist/playtest.js
CHANGED
|
@@ -1,535 +1,74 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { assertJsonSafe, PLAYTEST_PROTOCOL_LIMITS } from '@threenative/playtest/protocol';
|
|
2
|
+
import { installThreePlaytestBridge } from '@threenative/playtest/three';
|
|
3
|
+
import { Object3D } from 'three';
|
|
2
4
|
|
|
3
|
-
//
|
|
4
|
-
var PLAYTEST_CAPABILITY_REGISTRY = [
|
|
5
|
-
capability("browser.canvas", "Samples canvas pixels and health."),
|
|
6
|
-
capability("browser.console", "Captures browser console and page errors."),
|
|
7
|
-
capability("browser.dom", "Reads bounded DOM observations."),
|
|
8
|
-
capability("browser.input", "Delivers keyboard and pointer input."),
|
|
9
|
-
capability("browser.network", "Captures failed browser requests."),
|
|
10
|
-
capability("browser.screenshot", "Captures viewport screenshots."),
|
|
11
|
-
capability("browser.trace", "Captures a bounded Playwright trace."),
|
|
12
|
-
capability("camera.observe", "Samples the active camera transform and projection."),
|
|
13
|
-
capability("entity.bounds", "Projects registered entity bounds into the viewport."),
|
|
14
|
-
capability("entity.observe", "Samples registered entity transforms and visibility."),
|
|
15
|
-
capability("entity.setup", "Applies bounded transforms to registered entities."),
|
|
16
|
-
capability("runtime.animation", "Samples application-owned animation state."),
|
|
17
|
-
capability("runtime.audio", "Samples application-owned audio runtime state."),
|
|
18
|
-
capability("runtime.components", "Samples registered JSON-safe component state."),
|
|
19
|
-
capability("runtime.contacts", "Samples bounded collision and trigger contacts."),
|
|
20
|
-
capability("runtime.diagnostics", "Captures application runtime errors."),
|
|
21
|
-
capability("runtime.events", "Drains bounded application event observations."),
|
|
22
|
-
capability("runtime.fixedStep", "Advances an application-owned deterministic tick."),
|
|
23
|
-
capability("runtime.physics", "Samples bounded application-owned physics observations."),
|
|
24
|
-
capability("runtime.performance", "Samples bounded per-render frame cost and renderer counts."),
|
|
25
|
-
capability("runtime.resources", "Reads and writes registered JSON-safe application state."),
|
|
26
|
-
capability("runtime.state", "Samples application-owned state-machine state."),
|
|
27
|
-
capability("runtime.tags", "Samples bounded application-owned entity tags."),
|
|
28
|
-
capability("runtime.ui", "Samples registered JSON-safe UI and HUD state."),
|
|
29
|
-
capability("runtime.world", "Samples bounded runtime world metadata.")
|
|
30
|
-
];
|
|
31
|
-
new Set(PLAYTEST_CAPABILITY_REGISTRY.map(({ name }) => name));
|
|
32
|
-
function capability(name, description) {
|
|
33
|
-
return { description, name, protocolVersion: 1 };
|
|
34
|
-
}
|
|
35
|
-
var PLAYTEST_PROTOCOL_LIMITS = {
|
|
36
|
-
maxEventsPerDrain: 1e3};
|
|
37
|
-
function assertJsonSafe(value, path = "$") {
|
|
38
|
-
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
if (typeof value === "number") {
|
|
42
|
-
if (Number.isFinite(value)) {
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
throw new TypeError(`${path} must contain only finite JSON numbers.`);
|
|
46
|
-
}
|
|
47
|
-
if (Array.isArray(value)) {
|
|
48
|
-
value.forEach((item, index) => assertJsonSafe(item, `${path}[${index}]`));
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
if (typeof value === "object") {
|
|
52
|
-
for (const [key, item] of Object.entries(value)) {
|
|
53
|
-
assertJsonSafe(item, `${path}.${key}`);
|
|
54
|
-
}
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
throw new TypeError(`${path} must be JSON-safe.`);
|
|
58
|
-
}
|
|
59
|
-
var PLAYTEST_BRIDGE_GLOBAL = "__THREENATIVE_PLAYTEST_BRIDGE__";
|
|
60
|
-
var PLAYTEST_PROTOCOL_VERSION = 1;
|
|
61
|
-
var PLAYTEST_PROTOCOL_LIMITS2 = {
|
|
62
|
-
maxEntitiesPerSample: 100,
|
|
63
|
-
maxEventsPerDrain: 1e3,
|
|
64
|
-
maxPayloadBytes: 1e6,
|
|
65
|
-
operationTimeoutMs: 5e3
|
|
66
|
-
};
|
|
67
|
-
function jsonByteLength(value) {
|
|
68
|
-
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
69
|
-
}
|
|
70
|
-
function assertJsonSafe2(value, path = "$") {
|
|
71
|
-
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
if (typeof value === "number") {
|
|
75
|
-
if (Number.isFinite(value)) {
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
throw new TypeError(`${path} must contain only finite JSON numbers.`);
|
|
79
|
-
}
|
|
80
|
-
if (Array.isArray(value)) {
|
|
81
|
-
value.forEach((item, index) => assertJsonSafe2(item, `${path}[${index}]`));
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
if (typeof value === "object") {
|
|
85
|
-
for (const [key, item] of Object.entries(value)) {
|
|
86
|
-
assertJsonSafe2(item, `${path}.${key}`);
|
|
87
|
-
}
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
throw new TypeError(`${path} must be JSON-safe.`);
|
|
91
|
-
}
|
|
92
|
-
var ThreePlaytestEntityRegistry = class {
|
|
93
|
-
#entries = /* @__PURE__ */ new Map();
|
|
94
|
-
register(entry) {
|
|
95
|
-
const path = entry.path ?? objectPath(entry.object);
|
|
96
|
-
const existing = this.#entries.get(entry.id);
|
|
97
|
-
if (existing !== void 0) {
|
|
98
|
-
throw new Error(
|
|
99
|
-
`Duplicate playtest entity id '${entry.id}' conflicts between '${existing.path}' and '${path}'. Register a unique stable id for each observed object.`
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
this.#entries.set(entry.id, { ...entry, path });
|
|
103
|
-
}
|
|
104
|
-
get(id) {
|
|
105
|
-
return this.#entries.get(id);
|
|
106
|
-
}
|
|
107
|
-
replace(entries) {
|
|
108
|
-
this.#entries.clear();
|
|
109
|
-
entries.forEach((entry) => this.register(entry));
|
|
110
|
-
}
|
|
111
|
-
select(ids) {
|
|
112
|
-
if (ids === void 0) return [...this.#entries.values()];
|
|
113
|
-
return ids.flatMap((id) => {
|
|
114
|
-
const entry = this.#entries.get(id);
|
|
115
|
-
return entry === void 0 ? [] : [entry];
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
function objectPath(object) {
|
|
120
|
-
const parts = [];
|
|
121
|
-
let current = object;
|
|
122
|
-
while (current !== null) {
|
|
123
|
-
parts.unshift(current.name || `${current.type}[${current.uuid.slice(0, 8)}]`);
|
|
124
|
-
current = current.parent;
|
|
125
|
-
}
|
|
126
|
-
return parts.join("/");
|
|
127
|
-
}
|
|
128
|
-
function sampleThreeObservations(input, request) {
|
|
129
|
-
input.scene.updateMatrixWorld(true);
|
|
130
|
-
input.camera.updateMatrixWorld(true);
|
|
131
|
-
const rendererSize = input.renderer.getDrawingBufferSize(new Vector2());
|
|
132
|
-
const entities = input.registry.select(request.entities).map(({ id, object }) => observeEntity(id, object, input.camera, rendererSize.x, rendererSize.y));
|
|
133
|
-
const renderPerformance = rendererPerformance(input.renderer);
|
|
134
|
-
return {
|
|
135
|
-
clock: {
|
|
136
|
-
mode: input.clockMode,
|
|
137
|
-
...input.tick === void 0 ? { timeMs: performance.now() } : { tick: input.tick }
|
|
138
|
-
},
|
|
139
|
-
...input.diagnostics === void 0 ? {} : { diagnostics: input.diagnostics() },
|
|
140
|
-
entities,
|
|
141
|
-
...input.gameplay === void 0 ? {} : { gameplay: input.gameplay() },
|
|
142
|
-
...renderPerformance === void 0 ? {} : { performance: renderPerformance },
|
|
143
|
-
...input.runtimeDiagnosticsSeries === void 0 ? {} : { runtimeDiagnosticsSeries: input.runtimeDiagnosticsSeries().map((sample) => ({ ...sample })) },
|
|
144
|
-
...input.resources === void 0 ? {} : { resources: input.resources() }
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
function rendererPerformance(renderer) {
|
|
148
|
-
const render = renderer.info?.render;
|
|
149
|
-
const drawCalls = finiteNumber(render?.drawCalls) ? render.drawCalls : void 0;
|
|
150
|
-
const triangles = finiteNumber(render?.triangles) ? render.triangles : void 0;
|
|
151
|
-
if (drawCalls === void 0 && triangles === void 0) return void 0;
|
|
152
|
-
return {
|
|
153
|
-
...drawCalls === void 0 ? {} : { drawCalls },
|
|
154
|
-
...triangles === void 0 ? {} : { triangles }
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
function finiteNumber(value) {
|
|
158
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
159
|
-
}
|
|
160
|
-
function observeEntity(id, object, camera, viewportWidth, viewportHeight) {
|
|
161
|
-
const position = object.getWorldPosition(new Vector3());
|
|
162
|
-
const bounds = projectedBounds(object, camera, viewportWidth, viewportHeight);
|
|
163
|
-
return {
|
|
164
|
-
...bounds === void 0 ? {} : { bounds },
|
|
165
|
-
id,
|
|
166
|
-
transform: {
|
|
167
|
-
position: [position.x, position.y, position.z],
|
|
168
|
-
rotation: [object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w],
|
|
169
|
-
scale: [object.scale.x, object.scale.y, object.scale.z]
|
|
170
|
-
},
|
|
171
|
-
visible: object.visible && bounds !== void 0 && bounds.width > 0 && bounds.height > 0
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
function projectedBounds(object, camera, viewportWidth, viewportHeight) {
|
|
175
|
-
const worldBounds = new Box3().setFromObject(object);
|
|
176
|
-
if (worldBounds.isEmpty()) return void 0;
|
|
177
|
-
const projection = new Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
|
178
|
-
if (!new Frustum().setFromProjectionMatrix(projection).intersectsBox(worldBounds)) return void 0;
|
|
179
|
-
const min = worldBounds.min;
|
|
180
|
-
const max = worldBounds.max;
|
|
181
|
-
const points = [
|
|
182
|
-
new Vector3(min.x, min.y, min.z),
|
|
183
|
-
new Vector3(min.x, min.y, max.z),
|
|
184
|
-
new Vector3(min.x, max.y, min.z),
|
|
185
|
-
new Vector3(min.x, max.y, max.z),
|
|
186
|
-
new Vector3(max.x, min.y, min.z),
|
|
187
|
-
new Vector3(max.x, min.y, max.z),
|
|
188
|
-
new Vector3(max.x, max.y, min.z),
|
|
189
|
-
new Vector3(max.x, max.y, max.z)
|
|
190
|
-
].map((point) => point.project(camera));
|
|
191
|
-
const minX = Math.min(...points.map(({ x }) => x));
|
|
192
|
-
const maxX = Math.max(...points.map(({ x }) => x));
|
|
193
|
-
const minY = Math.min(...points.map(({ y }) => y));
|
|
194
|
-
const maxY = Math.max(...points.map(({ y }) => y));
|
|
195
|
-
return {
|
|
196
|
-
height: Math.max(0, (maxY - minY) * 0.5 * viewportHeight),
|
|
197
|
-
width: Math.max(0, (maxX - minX) * 0.5 * viewportWidth),
|
|
198
|
-
x: (minX + 1) * 0.5 * viewportWidth,
|
|
199
|
-
y: (1 - maxY) * 0.5 * viewportHeight
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
var PLAYTEST_PHYSICS_BODY_LIMIT = 100;
|
|
203
|
-
var PLAYTEST_PHYSICS_SAMPLE_LIMIT = 100;
|
|
204
|
-
var ThreePlaytestPhysicsRecorder = class {
|
|
205
|
-
#physics;
|
|
206
|
-
#series = [];
|
|
207
|
-
constructor(physics) {
|
|
208
|
-
this.#physics = physics;
|
|
209
|
-
}
|
|
210
|
-
sample(request, tick) {
|
|
211
|
-
if (request.label !== void 0) {
|
|
212
|
-
if (this.#series.some(({ label }) => label === request.label))
|
|
213
|
-
throw new Error(`TN_PLAYTEST_PHYSICS_LABEL_DUPLICATE: '${request.label}' was already sampled.`);
|
|
214
|
-
if (this.#series.length >= PLAYTEST_PHYSICS_SAMPLE_LIMIT)
|
|
215
|
-
throw new Error(`TN_PLAYTEST_PHYSICS_SAMPLE_LIMIT: at most ${PLAYTEST_PHYSICS_SAMPLE_LIMIT} labelled samples are retained.`);
|
|
216
|
-
this.#series.push({ label: request.label, snapshot: physicsSnapshot(this.#physics.bodies()), tick });
|
|
217
|
-
}
|
|
218
|
-
return this.#series.map((sample) => ({ ...sample }));
|
|
219
|
-
}
|
|
220
|
-
};
|
|
221
|
-
function physicsSnapshot(bodies) {
|
|
222
|
-
if (!Array.isArray(bodies))
|
|
223
|
-
throw new Error("TN_PLAYTEST_PHYSICS_BODIES: the physics provider must return an array of bodies.");
|
|
224
|
-
const retained = bodies.slice(0, PLAYTEST_PHYSICS_BODY_LIMIT);
|
|
225
|
-
const seen = /* @__PURE__ */ new Set();
|
|
226
|
-
const primitives = retained.flatMap((body) => {
|
|
227
|
-
if (typeof body?.id !== "string" || body.id.length === 0)
|
|
228
|
-
throw new Error("TN_PLAYTEST_PHYSICS_BODY_ID: every observed physics body needs a non-empty string id.");
|
|
229
|
-
if (seen.has(body.id))
|
|
230
|
-
throw new Error(`TN_PLAYTEST_PHYSICS_BODY_DUPLICATE: body id '${body.id}' was reported twice in one sample.`);
|
|
231
|
-
seen.add(body.id);
|
|
232
|
-
if (typeof body.sleeping !== "boolean")
|
|
233
|
-
throw new Error(`TN_PLAYTEST_PHYSICS_BODY_SLEEPING: body '${body.id}' must report sleeping as a boolean.`);
|
|
234
|
-
if (!isFiniteVec3(body.position))
|
|
235
|
-
throw new Error(`TN_PLAYTEST_PHYSICS_BODY_POSITION: body '${body.id}' must report a finite [x, y, z] position.`);
|
|
236
|
-
const sleep = { category: "sleep", entity: body.id, value: body.sleeping ? 1 : 0 };
|
|
237
|
-
const centerOfMass = { category: "center-of-mass", entity: body.id, position: [...body.position] };
|
|
238
|
-
return [sleep, centerOfMass];
|
|
239
|
-
});
|
|
240
|
-
return { artifact: { overflow: { omittedBodies: bodies.length - retained.length }, primitives } };
|
|
241
|
-
}
|
|
242
|
-
function isFiniteVec3(value) {
|
|
243
|
-
return Array.isArray(value) && value.length === 3 && value.every((component) => typeof component === "number" && Number.isFinite(component));
|
|
244
|
-
}
|
|
245
|
-
var BRIDGE_METHODS = /* @__PURE__ */ new Set([
|
|
246
|
-
"advance",
|
|
247
|
-
"applySetup",
|
|
248
|
-
"describe",
|
|
249
|
-
"drainEvents",
|
|
250
|
-
"focus",
|
|
251
|
-
"ready",
|
|
252
|
-
"sample"
|
|
253
|
-
]);
|
|
254
|
-
function connectDevicePlaytestBridge(bridge, endpoint) {
|
|
255
|
-
const nativeInstallation = connectNativeMailbox(bridge);
|
|
256
|
-
if (nativeInstallation !== void 0) return nativeInstallation;
|
|
257
|
-
let closed = false;
|
|
258
|
-
let reportedError = false;
|
|
259
|
-
let scheduled;
|
|
260
|
-
const schedule = () => {
|
|
261
|
-
if (closed) return;
|
|
262
|
-
if (typeof globalThis.requestAnimationFrame === "function") {
|
|
263
|
-
scheduled = globalThis.requestAnimationFrame(() => void poll());
|
|
264
|
-
} else {
|
|
265
|
-
scheduled = setTimeout(() => void poll(), 50);
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
const poll = async () => {
|
|
269
|
-
if (closed) return;
|
|
270
|
-
try {
|
|
271
|
-
const response = await fetch(endpoint);
|
|
272
|
-
if (response.status !== 204) {
|
|
273
|
-
if (!response.ok) throw new Error(`Device bridge poll failed with HTTP ${response.status}.`);
|
|
274
|
-
const request = parseRequest(await response.json());
|
|
275
|
-
await postResponse(endpoint, await dispatch(bridge, request));
|
|
276
|
-
}
|
|
277
|
-
} catch (error) {
|
|
278
|
-
if (!reportedError) {
|
|
279
|
-
reportedError = true;
|
|
280
|
-
console.error(`TN_PLAYTEST_DEVICE_TRANSPORT: ${error instanceof Error ? error.message : String(error)}`);
|
|
281
|
-
}
|
|
282
|
-
} finally {
|
|
283
|
-
schedule();
|
|
284
|
-
}
|
|
285
|
-
};
|
|
286
|
-
schedule();
|
|
287
|
-
return {
|
|
288
|
-
close: () => {
|
|
289
|
-
closed = true;
|
|
290
|
-
if (scheduled === void 0) return;
|
|
291
|
-
if (typeof globalThis.cancelAnimationFrame === "function" && typeof scheduled === "number") {
|
|
292
|
-
globalThis.cancelAnimationFrame(scheduled);
|
|
293
|
-
} else clearTimeout(scheduled);
|
|
294
|
-
}
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
function connectNativeMailbox(bridge) {
|
|
298
|
-
const globals = globalThis;
|
|
299
|
-
const mailbox = globals.TN_PLAYTEST_MAILBOX;
|
|
300
|
-
const host = globals.__THREENATIVE_NATIVE__?.playtest;
|
|
301
|
-
if (typeof mailbox?.request !== "string" || typeof mailbox.response !== "string" || typeof host?.receive !== "function" || typeof host.respond !== "function") return void 0;
|
|
302
|
-
let closed = false;
|
|
303
|
-
let frame;
|
|
304
|
-
const respond = (response) => {
|
|
305
|
-
if (!host.respond(mailbox.response, JSON.stringify(response))) {
|
|
306
|
-
console.error("TN_PLAYTEST_DEVICE_TRANSPORT: native mailbox response failed");
|
|
307
|
-
}
|
|
308
|
-
};
|
|
309
|
-
const poll = () => {
|
|
310
|
-
if (closed) return;
|
|
311
|
-
const raw = host.receive(mailbox.request);
|
|
312
|
-
if (raw !== void 0) {
|
|
313
|
-
try {
|
|
314
|
-
const request = parseRequest(JSON.parse(raw));
|
|
315
|
-
void dispatch(bridge, request).then(respond);
|
|
316
|
-
} catch (error) {
|
|
317
|
-
respond({
|
|
318
|
-
error: { message: error instanceof Error ? error.message : String(error) },
|
|
319
|
-
id: "invalid"
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
frame = globalThis.requestAnimationFrame(poll);
|
|
324
|
-
};
|
|
325
|
-
respond({ id: "ready", result: null });
|
|
326
|
-
frame = globalThis.requestAnimationFrame(poll);
|
|
327
|
-
return {
|
|
328
|
-
close: () => {
|
|
329
|
-
closed = true;
|
|
330
|
-
if (frame !== void 0) globalThis.cancelAnimationFrame(frame);
|
|
331
|
-
}
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
function readPlaytestEndpoint() {
|
|
335
|
-
const value = globalThis.TN_PLAYTEST_ENDPOINT;
|
|
336
|
-
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
337
|
-
}
|
|
338
|
-
async function dispatch(bridge, request) {
|
|
339
|
-
try {
|
|
340
|
-
if (request.method.startsWith("input.")) {
|
|
341
|
-
return { id: request.id, result: dispatchInput(request.method, request.argument) };
|
|
342
|
-
}
|
|
343
|
-
if (!BRIDGE_METHODS.has(request.method)) {
|
|
344
|
-
throw new Error(`Bridge operation '${request.method}' is unavailable.`);
|
|
345
|
-
}
|
|
346
|
-
const operation = bridge[request.method];
|
|
347
|
-
if (typeof operation !== "function") throw new Error(`Bridge operation '${request.method}' is unavailable.`);
|
|
348
|
-
const result = await operation.call(bridge, request.argument);
|
|
349
|
-
if (result !== void 0) assertBounded(result);
|
|
350
|
-
return { id: request.id, ...result === void 0 ? {} : { result } };
|
|
351
|
-
} catch (error) {
|
|
352
|
-
return { error: { message: error instanceof Error ? error.message : String(error) }, id: request.id };
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
function dispatchInput(method, argument) {
|
|
356
|
-
const host = globalThis.__THREENATIVE_NATIVE__?.playtestInput;
|
|
357
|
-
if (!isRecord(argument)) throw new Error(`Device ${method} requires an object argument.`);
|
|
358
|
-
if (method === "input.keyDown" || method === "input.keyUp") {
|
|
359
|
-
if (typeof argument.key !== "string") throw new Error(`Device ${method} requires a key.`);
|
|
360
|
-
const code = argument.key;
|
|
361
|
-
const key = /^Key[A-Z]$/u.test(code) ? code.slice(3).toLowerCase() : code;
|
|
362
|
-
if (typeof host?.keyboard !== "function") throw new Error("Native playtest keyboard input is unavailable.");
|
|
363
|
-
host.keyboard(method === "input.keyDown" ? "keydown" : "keyup", key, code);
|
|
364
|
-
return null;
|
|
365
|
-
}
|
|
366
|
-
if (method === "input.pointer") {
|
|
367
|
-
if (typeof argument.x !== "number" || typeof argument.y !== "number" || typeof argument.buttons !== "number") {
|
|
368
|
-
throw new Error("Device input.pointer requires numeric x, y, and buttons.");
|
|
369
|
-
}
|
|
370
|
-
if (typeof host?.pointer !== "function") throw new Error("Native playtest pointer input is unavailable.");
|
|
371
|
-
const type = argument.type === "down" ? "pointerdown" : argument.type === "up" ? "pointerup" : "pointermove";
|
|
372
|
-
host.pointer(type, argument.x, argument.y, argument.buttons);
|
|
373
|
-
return null;
|
|
374
|
-
}
|
|
375
|
-
throw new Error(`Device input operation '${method}' is unavailable.`);
|
|
376
|
-
}
|
|
377
|
-
function parseRequest(value) {
|
|
378
|
-
assertBounded(value);
|
|
379
|
-
if (!isRecord(value) || typeof value.id !== "string" || typeof value.method !== "string") {
|
|
380
|
-
throw new Error("Device request must contain string id and method fields.");
|
|
381
|
-
}
|
|
382
|
-
return value;
|
|
383
|
-
}
|
|
384
|
-
async function postResponse(endpoint, response) {
|
|
385
|
-
const result = await fetch(endpoint, {
|
|
386
|
-
body: JSON.stringify(response),
|
|
387
|
-
headers: { "content-type": "application/json" },
|
|
388
|
-
method: "POST"
|
|
389
|
-
});
|
|
390
|
-
if (!result.ok) throw new Error(`Device bridge response failed with HTTP ${result.status}.`);
|
|
391
|
-
}
|
|
392
|
-
function assertBounded(value) {
|
|
393
|
-
assertJsonSafe2(value);
|
|
394
|
-
if (jsonByteLength(value) > PLAYTEST_PROTOCOL_LIMITS2.maxPayloadBytes) {
|
|
395
|
-
throw new Error(`Device bridge payload exceeds ${PLAYTEST_PROTOCOL_LIMITS2.maxPayloadBytes} bytes.`);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
function isRecord(value) {
|
|
399
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
400
|
-
}
|
|
401
|
-
function installThreePlaytestBridge(options) {
|
|
402
|
-
if (options.fixedStep === void 0 !== (options.tick === void 0))
|
|
403
|
-
throw new Error("fixedStep and its authoritative tick provider must be installed together.");
|
|
404
|
-
if (options.physics !== void 0 && options.tick === void 0)
|
|
405
|
-
throw new Error("A physics provider requires the authoritative tick provider, and therefore fixedStep.");
|
|
406
|
-
const recorder = options.physics === void 0 ? void 0 : new ThreePlaytestPhysicsRecorder(options.physics);
|
|
407
|
-
const host = globalThis;
|
|
408
|
-
const previous = host[PLAYTEST_BRIDGE_GLOBAL];
|
|
409
|
-
const registry = new ThreePlaytestEntityRegistry();
|
|
410
|
-
syncEntities(registry, options.entities);
|
|
411
|
-
const capabilities = () => [
|
|
412
|
-
"camera.observe",
|
|
413
|
-
"entity.bounds",
|
|
414
|
-
"entity.observe",
|
|
415
|
-
"entity.setup",
|
|
416
|
-
...options.fixedStep === void 0 ? [] : ["runtime.fixedStep"],
|
|
417
|
-
...options.resources === void 0 ? [] : ["runtime.resources"],
|
|
418
|
-
...options.diagnostics === void 0 ? [] : ["runtime.diagnostics"],
|
|
419
|
-
...Object.keys(options.components?.() ?? {}).length === 0 ? [] : ["runtime.components"],
|
|
420
|
-
...options.events === void 0 ? [] : ["runtime.events"],
|
|
421
|
-
...options.gameplay === void 0 ? [] : ["runtime.animation", "runtime.state"],
|
|
422
|
-
...options.physics === void 0 ? [] : ["runtime.physics"],
|
|
423
|
-
...options.runtimeDiagnosticsSeries === void 0 ? [] : ["runtime.performance"],
|
|
424
|
-
...options.gameplayChannels?.().includes("runtime.contacts") === true ? ["runtime.contacts"] : [],
|
|
425
|
-
...options.gameplayChannels?.().includes("runtime.tags") === true ? ["runtime.tags"] : [],
|
|
426
|
-
...options.gameplayChannels?.().includes("runtime.world") === true ? ["runtime.world"] : []
|
|
427
|
-
];
|
|
428
|
-
const bridge = {
|
|
429
|
-
...options.fixedStep === void 0 ? {} : {
|
|
430
|
-
advance: async (ticks) => {
|
|
431
|
-
if (!Number.isInteger(ticks) || ticks <= 0) throw new Error("advance ticks must be a positive integer.");
|
|
432
|
-
const startTick = options.tick();
|
|
433
|
-
await options.fixedStep(ticks);
|
|
434
|
-
const tick = options.tick();
|
|
435
|
-
const advanced = tick - startTick;
|
|
436
|
-
if (advanced !== ticks)
|
|
437
|
-
throw new Error(`fixedStep advanced ${advanced} actual ticks; expected ${ticks}.`);
|
|
438
|
-
return { clock: { mode: "fixed-step", tick }, ticks: advanced };
|
|
439
|
-
}
|
|
440
|
-
},
|
|
441
|
-
applySetup: async (request) => applySetup(registry, options.resources, request),
|
|
442
|
-
...options.events === void 0 ? {} : {
|
|
443
|
-
drainEvents: async (limit = PLAYTEST_PROTOCOL_LIMITS2.maxEventsPerDrain) => {
|
|
444
|
-
if (!Number.isInteger(limit) || limit < 0) throw new Error("drainEvents limit must be a non-negative integer.");
|
|
445
|
-
const events = options.events();
|
|
446
|
-
assertJsonSafe2(events);
|
|
447
|
-
return events.slice(0, Math.min(limit, PLAYTEST_PROTOCOL_LIMITS2.maxEventsPerDrain));
|
|
448
|
-
}
|
|
449
|
-
},
|
|
450
|
-
describe: () => ({
|
|
451
|
-
capabilities: capabilities(),
|
|
452
|
-
limits: PLAYTEST_PROTOCOL_LIMITS2,
|
|
453
|
-
name: "@threenative/playtest/three",
|
|
454
|
-
protocolVersion: PLAYTEST_PROTOCOL_VERSION
|
|
455
|
-
}),
|
|
456
|
-
ready: () => ({ ready: true }),
|
|
457
|
-
sample: (request) => {
|
|
458
|
-
syncEntities(registry, options.entities);
|
|
459
|
-
const snapshot = sampleThreeObservations({
|
|
460
|
-
camera: options.camera,
|
|
461
|
-
clockMode: options.fixedStep === void 0 ? "render-frame" : "fixed-step",
|
|
462
|
-
diagnostics: options.diagnostics,
|
|
463
|
-
gameplay: options.gameplay,
|
|
464
|
-
runtimeDiagnosticsSeries: options.runtimeDiagnosticsSeries,
|
|
465
|
-
registry,
|
|
466
|
-
renderer: options.renderer,
|
|
467
|
-
resources: options.resources === void 0 ? void 0 : () => options.resources.read(),
|
|
468
|
-
scene: options.scene,
|
|
469
|
-
tick: options.tick?.()
|
|
470
|
-
}, request);
|
|
471
|
-
const components = options.components?.();
|
|
472
|
-
const withComponents = components === void 0 || Object.keys(components).length === 0 ? snapshot : { ...snapshot, components };
|
|
473
|
-
const result = recorder === void 0 || request.include?.includes("physicsDebugSeries") !== true ? withComponents : { ...withComponents, physicsDebugSeries: recorder.sample(request, options.tick()) };
|
|
474
|
-
assertJsonSafe2(result);
|
|
475
|
-
return result;
|
|
476
|
-
}
|
|
477
|
-
};
|
|
478
|
-
host[PLAYTEST_BRIDGE_GLOBAL] = bridge;
|
|
479
|
-
const endpoint = readPlaytestEndpoint();
|
|
480
|
-
let device;
|
|
481
|
-
if (endpoint !== void 0) device = connectDevicePlaytestBridge(bridge, endpoint);
|
|
482
|
-
return {
|
|
483
|
-
bridge,
|
|
484
|
-
dispose: () => {
|
|
485
|
-
device?.close();
|
|
486
|
-
if (host[PLAYTEST_BRIDGE_GLOBAL] !== bridge) return;
|
|
487
|
-
if (previous === void 0) delete host[PLAYTEST_BRIDGE_GLOBAL];
|
|
488
|
-
else host[PLAYTEST_BRIDGE_GLOBAL] = previous;
|
|
489
|
-
},
|
|
490
|
-
registerEntity: (entry) => registry.register(entry),
|
|
491
|
-
syncEntities: () => syncEntities(registry, options.entities)
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
function syncEntities(registry, entities) {
|
|
495
|
-
if (entities === void 0) return;
|
|
496
|
-
registry.replace(typeof entities === "function" ? entities() : entities);
|
|
497
|
-
}
|
|
498
|
-
function applySetup(registry, resources, request) {
|
|
499
|
-
request.entities?.forEach(({ entity, transform }) => {
|
|
500
|
-
const object = registry.get(entity)?.object;
|
|
501
|
-
if (object === void 0) throw new Error(`Setup entity '${entity}' is not registered.`);
|
|
502
|
-
if (transform.position !== void 0) object.position.fromArray(transform.position);
|
|
503
|
-
if (transform.rotation !== void 0) object.quaternion.fromArray(transform.rotation);
|
|
504
|
-
if (transform.scale !== void 0) object.scale.fromArray(transform.scale);
|
|
505
|
-
object.updateMatrix();
|
|
506
|
-
});
|
|
507
|
-
request.resources?.forEach(({ id, path, value }) => {
|
|
508
|
-
assertJsonSafe2(value);
|
|
509
|
-
if (resources?.write === void 0) throw new Error(`Resource setup '${id}' requires a writable resources provider.`);
|
|
510
|
-
if (!resources.write(id, path, value)) throw new Error(`Resource setup '${id}' was rejected by the application provider.`);
|
|
511
|
-
});
|
|
512
|
-
}
|
|
5
|
+
// src/playtest.ts
|
|
513
6
|
var buses = /* @__PURE__ */ new Set();
|
|
514
7
|
function audioRuntimeSnapshot() {
|
|
515
8
|
let queued = 0;
|
|
516
9
|
let voices = 0;
|
|
10
|
+
let pooled = 0;
|
|
11
|
+
let paused = 0;
|
|
12
|
+
const unsupported = /* @__PURE__ */ new Set();
|
|
517
13
|
for (const bus of buses) {
|
|
518
14
|
queued += bus.queued;
|
|
519
15
|
voices += bus.voices;
|
|
16
|
+
pooled += bus.pooled;
|
|
17
|
+
paused += bus.pausedVoices;
|
|
18
|
+
for (const option of bus.unsupported) unsupported.add(option);
|
|
520
19
|
}
|
|
521
|
-
return { queued, voices };
|
|
20
|
+
return { paused, pooled, queued, unsupported: [...unsupported].sort(), voices };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/version.ts
|
|
24
|
+
var CORE_VERSION = "0.3.1";
|
|
25
|
+
|
|
26
|
+
// src/pipeline-census.ts
|
|
27
|
+
var PIPELINE_CENSUS_CAPABILITY = "runtime.pipelineCensus";
|
|
28
|
+
|
|
29
|
+
// src/render/chain.ts
|
|
30
|
+
var REPORT_KEY = /* @__PURE__ */ Symbol.for("threenative.renderChain.report");
|
|
31
|
+
function storedReport(target) {
|
|
32
|
+
return target[REPORT_KEY];
|
|
33
|
+
}
|
|
34
|
+
function readRenderChainReport(renderer) {
|
|
35
|
+
if (!isObject(renderer)) return void 0;
|
|
36
|
+
const raw = readRawRenderer(renderer);
|
|
37
|
+
return storedReport(renderer) ?? (isObject(raw) ? storedReport(raw) : void 0);
|
|
38
|
+
}
|
|
39
|
+
function readRenderChainObservation(renderer) {
|
|
40
|
+
return readRenderChainReport(renderer)?.applied;
|
|
41
|
+
}
|
|
42
|
+
function readRawRenderer(renderer) {
|
|
43
|
+
return "raw" in renderer ? renderer.raw : renderer;
|
|
44
|
+
}
|
|
45
|
+
function isObject(value) {
|
|
46
|
+
return typeof value === "object" && value !== null;
|
|
522
47
|
}
|
|
523
48
|
|
|
524
49
|
// src/playtest.ts
|
|
525
|
-
var CORE_VERSION = "0.1.0";
|
|
526
50
|
var currentAgent = typeof navigator === "undefined" ? "node" : navigator.userAgent;
|
|
527
51
|
function playtest(options = {}) {
|
|
528
52
|
let dispose;
|
|
529
53
|
let attached;
|
|
54
|
+
let startSceneEntered;
|
|
55
|
+
let disposePipelineCensus;
|
|
530
56
|
let contactHistory = [];
|
|
57
|
+
const watcher = createTransitionWatcher();
|
|
58
|
+
let readTick;
|
|
531
59
|
return {
|
|
60
|
+
sceneExit: () => {
|
|
61
|
+
contactHistory = [];
|
|
62
|
+
watcher.reset();
|
|
63
|
+
},
|
|
64
|
+
update: (ctx) => {
|
|
65
|
+
const tick = readTick?.();
|
|
66
|
+
if (tick === void 0) return;
|
|
67
|
+
drainContacts(ctx, contactHistory, tick);
|
|
68
|
+
watcher.observe(ctx, tick);
|
|
69
|
+
},
|
|
532
70
|
setup: async (ctx, runtime) => {
|
|
71
|
+
readTick = runtime?.tick;
|
|
533
72
|
const seed = runtime?.seed ?? null;
|
|
534
73
|
const replayRuntime = runtime?.seed === null || runtime?.seed === void 0 ? void 0 : {
|
|
535
74
|
agent: currentAgent,
|
|
@@ -545,20 +84,59 @@ function playtest(options = {}) {
|
|
|
545
84
|
...runtime === void 0 ? {} : { fixedStep: runtime.fixedStep, tick: runtime.tick },
|
|
546
85
|
...runtime?.runtimeDiagnosticsSeries === void 0 ? {} : { runtimeDiagnosticsSeries: runtime.runtimeDiagnosticsSeries },
|
|
547
86
|
...options.events === void 0 ? {} : { events: options.events },
|
|
548
|
-
gameplay: () => gameplayObservations(ctx, contactHistory, seed, replayRuntime),
|
|
87
|
+
gameplay: () => gameplayObservations(ctx, contactHistory, seed, replayRuntime, watcher),
|
|
549
88
|
gameplayChannels: () => gameplayChannels(),
|
|
550
89
|
renderer: ctx.renderer.raw,
|
|
90
|
+
renderChain: () => {
|
|
91
|
+
const observation = readRenderChainObservation(ctx.renderer);
|
|
92
|
+
return observation === void 0 ? void 0 : {
|
|
93
|
+
...observation,
|
|
94
|
+
contributions: observation.contributions.map((contribution) => ({
|
|
95
|
+
...contribution
|
|
96
|
+
})),
|
|
97
|
+
dropped: observation.dropped.map((stage) => ({ ...stage })),
|
|
98
|
+
requested: [...observation.requested],
|
|
99
|
+
stages: [...observation.stages],
|
|
100
|
+
velocity: { ...observation.velocity }
|
|
101
|
+
};
|
|
102
|
+
},
|
|
551
103
|
resources: stateResources(ctx),
|
|
552
|
-
scene: ctx.scene
|
|
104
|
+
scene: ctx.scene,
|
|
105
|
+
// A runner advances ticks far faster than a launch completes, and compute dispatch and
|
|
106
|
+
// the first world present are both held behind an opaque loading layer. Published so the
|
|
107
|
+
// runner waits for the world rather than observing a frozen simulation behind a loader.
|
|
108
|
+
startup: () => ({
|
|
109
|
+
phase: ctx.startup.phase,
|
|
110
|
+
progress: ctx.startup.progress,
|
|
111
|
+
...ctx.startup.warmup === void 0 ? {} : { warmup: ctx.startup.warmup },
|
|
112
|
+
...runtime?.startupCompileSettled === void 0 ? {} : { compileSettled: runtime.startupCompileSettled() },
|
|
113
|
+
// Wall-clock milestones, so a scenario asserts startup time as an observation instead
|
|
114
|
+
// of reading it off a console marker after the fact.
|
|
115
|
+
...runtime?.startupTimeline === void 0 ? {} : { timeline: runtime.startupTimeline() }
|
|
116
|
+
})
|
|
553
117
|
});
|
|
118
|
+
const pipelineCensus = runtime?.pipelineCensus;
|
|
119
|
+
if (runtime !== void 0 && pipelineCensus !== void 0) {
|
|
120
|
+
disposePipelineCensus = runtime.observations.contribute({
|
|
121
|
+
capabilities: [PIPELINE_CENSUS_CAPABILITY],
|
|
122
|
+
sample: () => ({ pipelineCensus: pipelineCensus() })
|
|
123
|
+
});
|
|
124
|
+
}
|
|
554
125
|
installRuntimeChannels(installation.bridge, runtime);
|
|
126
|
+
if (globalThis[PLAYTEST_RUNNER_EXPECTED_GLOBAL] === true)
|
|
127
|
+
runtime?.enableRuntimeDiagnostics?.();
|
|
555
128
|
dispose = installation.dispose;
|
|
556
|
-
attached = holdUntilAttached(installation.bridge, options);
|
|
129
|
+
attached = holdUntilAttached(installation.bridge, options, () => startSceneEntered);
|
|
557
130
|
const cleanup = () => {
|
|
131
|
+
disposePipelineCensus?.();
|
|
132
|
+
disposePipelineCensus = void 0;
|
|
558
133
|
dispose?.();
|
|
559
134
|
dispose = void 0;
|
|
560
135
|
attached = void 0;
|
|
136
|
+
startSceneEntered = void 0;
|
|
561
137
|
contactHistory = [];
|
|
138
|
+
readTick = void 0;
|
|
139
|
+
watcher.reset();
|
|
562
140
|
};
|
|
563
141
|
if (attached !== void 0) {
|
|
564
142
|
const gate = attached.catch((error) => {
|
|
@@ -566,7 +144,10 @@ function playtest(options = {}) {
|
|
|
566
144
|
throw error;
|
|
567
145
|
});
|
|
568
146
|
if (runtime?.holdStart === void 0) await gate;
|
|
569
|
-
else
|
|
147
|
+
else {
|
|
148
|
+
startSceneEntered = runtime.holdStart(gate);
|
|
149
|
+
void startSceneEntered.catch(() => void 0);
|
|
150
|
+
}
|
|
570
151
|
}
|
|
571
152
|
return cleanup;
|
|
572
153
|
}
|
|
@@ -576,27 +157,54 @@ var PLAYTEST_ATTACH_TIMEOUT_MS = 3e4;
|
|
|
576
157
|
var PLAYTEST_RUNNER_EXPECTED_GLOBAL = "__THREENATIVE_PLAYTEST_RUNNER_EXPECTED__";
|
|
577
158
|
function shouldHoldUntilAttached(options) {
|
|
578
159
|
if (options.holdUntilAttached !== void 0) return options.holdUntilAttached;
|
|
579
|
-
|
|
160
|
+
const host = globalThis;
|
|
161
|
+
return host[PLAYTEST_RUNNER_EXPECTED_GLOBAL] === true || host.TN_PLAYTEST_ENDPOINT !== void 0;
|
|
580
162
|
}
|
|
581
|
-
function holdUntilAttached(bridge, options) {
|
|
163
|
+
function holdUntilAttached(bridge, options, startSceneEntered) {
|
|
582
164
|
if (!shouldHoldUntilAttached(options)) return void 0;
|
|
583
165
|
const timeoutMs = options.attachTimeoutMs ?? PLAYTEST_ATTACH_TIMEOUT_MS;
|
|
584
166
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
585
167
|
throw new Error(`TN_PLAYTEST_ATTACH_TIMEOUT_INVALID: ${String(options.attachTimeoutMs)}`);
|
|
586
168
|
}
|
|
587
169
|
const describe = bridge.describe;
|
|
170
|
+
const applySetup = bridge.applySetup;
|
|
588
171
|
return new Promise((resolve, reject) => {
|
|
172
|
+
let settled = false;
|
|
589
173
|
const timer = setTimeout(() => {
|
|
174
|
+
settled = true;
|
|
590
175
|
reject(
|
|
591
176
|
new Error(
|
|
592
|
-
`TN_PLAYTEST_ATTACH_TIMEOUT: no playtest runner called describe() within ${timeoutMs}ms.`
|
|
177
|
+
`TN_PLAYTEST_ATTACH_TIMEOUT: no playtest runner applied setup or called describe() within ${timeoutMs}ms.`
|
|
593
178
|
)
|
|
594
179
|
);
|
|
595
180
|
}, timeoutMs);
|
|
596
181
|
timer.unref?.();
|
|
597
|
-
|
|
182
|
+
const release = () => {
|
|
183
|
+
if (settled) return;
|
|
184
|
+
settled = true;
|
|
598
185
|
clearTimeout(timer);
|
|
599
186
|
resolve();
|
|
187
|
+
};
|
|
188
|
+
const fail = (error) => {
|
|
189
|
+
if (settled) return;
|
|
190
|
+
settled = true;
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
reject(error);
|
|
193
|
+
};
|
|
194
|
+
if (applySetup !== void 0) {
|
|
195
|
+
bridge.applySetup = async (request) => {
|
|
196
|
+
try {
|
|
197
|
+
await applySetup(request);
|
|
198
|
+
release();
|
|
199
|
+
} catch (error) {
|
|
200
|
+
fail(error);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
bridge.describe = async () => {
|
|
206
|
+
release();
|
|
207
|
+
await startSceneEntered();
|
|
600
208
|
return describe();
|
|
601
209
|
};
|
|
602
210
|
});
|
|
@@ -614,8 +222,8 @@ function installRuntimeChannels(bridge, runtime) {
|
|
|
614
222
|
function addRuntimeCapabilities(description, contributions) {
|
|
615
223
|
const capabilities = [...description.capabilities];
|
|
616
224
|
const contributed = contributions.flatMap(({ capabilities: capabilities2 }) => capabilities2);
|
|
617
|
-
for (const
|
|
618
|
-
if (!capabilities.includes(
|
|
225
|
+
for (const capability of ["runtime.audio", "runtime.world", ...contributed]) {
|
|
226
|
+
if (!capabilities.includes(capability)) capabilities.push(capability);
|
|
619
227
|
}
|
|
620
228
|
return { ...description, capabilities };
|
|
621
229
|
}
|
|
@@ -661,62 +269,143 @@ function entityObject(entity) {
|
|
|
661
269
|
const candidate = entity?.mesh ?? entity?.object;
|
|
662
270
|
return candidate instanceof Object3D ? candidate : void 0;
|
|
663
271
|
}
|
|
664
|
-
function gameplayObservations(ctx, contactHistory, seed, replayRuntime) {
|
|
272
|
+
function gameplayObservations(ctx, contactHistory, seed, replayRuntime, watcher) {
|
|
665
273
|
const animation = {};
|
|
666
274
|
const states = {};
|
|
667
275
|
const snapshot = ctx.entities.snapshot();
|
|
668
276
|
for (const id of Object.keys(snapshot)) {
|
|
669
277
|
const entity = ctx.entities.get(id);
|
|
278
|
+
const finished = typeof entity?.animation?.finished === "boolean" ? entity.animation.finished : void 0;
|
|
670
279
|
if (typeof entity?.animation?.current === "string" && typeof entity.animation.advancedFrames === "number") {
|
|
280
|
+
const stride = strideObservation(entity.animation.stride);
|
|
671
281
|
animation[id] = {
|
|
672
282
|
advancedFrames: entity.animation.advancedFrames,
|
|
673
|
-
clip: entity.animation.current
|
|
283
|
+
clip: entity.animation.current,
|
|
284
|
+
...finished === void 0 ? {} : { finished },
|
|
285
|
+
...stride === void 0 ? {} : { stride }
|
|
674
286
|
};
|
|
675
287
|
}
|
|
676
288
|
if (typeof entity?.state === "string") states[id] = entity.state;
|
|
677
289
|
}
|
|
678
290
|
const channels = gameplayChannels();
|
|
679
|
-
const contacts = channels.includes("runtime.contacts") ? drainContacts(ctx, contactHistory) : void 0;
|
|
291
|
+
const contacts = channels.includes("runtime.contacts") ? drainContacts(ctx, contactHistory, watcher.lastTick()) : void 0;
|
|
680
292
|
const tags = channels.includes("runtime.tags") ? tagCounts(snapshot) : void 0;
|
|
293
|
+
const transitions = watcher.read();
|
|
681
294
|
return {
|
|
682
295
|
animation,
|
|
683
296
|
...contacts === void 0 ? {} : { contacts },
|
|
684
297
|
states,
|
|
685
298
|
...tags === void 0 ? {} : { tags },
|
|
299
|
+
...transitions === void 0 ? {} : { transitions },
|
|
686
300
|
...runtimeObservation(seed, replayRuntime)
|
|
687
301
|
};
|
|
688
302
|
}
|
|
303
|
+
function strideObservation(value) {
|
|
304
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
305
|
+
const report = value;
|
|
306
|
+
const numbers = ["clipGroundSpeed", "groundSpeed", "rate"];
|
|
307
|
+
const flags = ["overridden", "synced"];
|
|
308
|
+
if (!numbers.every((key) => typeof report[key] === "number" && Number.isFinite(report[key])))
|
|
309
|
+
return void 0;
|
|
310
|
+
if (!flags.every((key) => typeof report[key] === "boolean")) return void 0;
|
|
311
|
+
return {
|
|
312
|
+
clipGroundSpeed: report.clipGroundSpeed,
|
|
313
|
+
groundSpeed: report.groundSpeed,
|
|
314
|
+
overridden: report.overridden,
|
|
315
|
+
rate: report.rate,
|
|
316
|
+
synced: report.synced
|
|
317
|
+
};
|
|
318
|
+
}
|
|
689
319
|
function gameplayChannels(_ctx) {
|
|
690
|
-
return ["runtime.contacts", "runtime.tags"];
|
|
320
|
+
return ["runtime.contacts", "runtime.tags", "runtime.transitions"];
|
|
691
321
|
}
|
|
692
|
-
function drainContacts(ctx, history) {
|
|
693
|
-
|
|
694
|
-
|
|
322
|
+
function drainContacts(ctx, history, tick) {
|
|
323
|
+
const idsByEntity = /* @__PURE__ */ new Map();
|
|
324
|
+
ctx.entities.forEach((id, registered) => {
|
|
325
|
+
for (const value of objectGraphValues(registered))
|
|
326
|
+
if (!idsByEntity.has(value)) idsByEntity.set(value, id);
|
|
327
|
+
});
|
|
328
|
+
ctx.entities.forEach((id, registered) => {
|
|
329
|
+
for (const source of entitySources(registered)) {
|
|
695
330
|
for (const event of source.drainContacts()) {
|
|
696
|
-
const entity =
|
|
697
|
-
|
|
331
|
+
const entity = idsByEntity.get(event.body);
|
|
332
|
+
const other = event.entity ?? (event.area === void 0 ? void 0 : idsByEntity.get(event.area)) ?? idsByEntity.get(source);
|
|
333
|
+
if (entity === void 0 || other === void 0) continue;
|
|
698
334
|
history.push({
|
|
699
335
|
entity,
|
|
700
336
|
kind: event.started ? "trigger" : "trigger.exit",
|
|
701
|
-
|
|
337
|
+
...tick === void 0 ? {} : { tick },
|
|
338
|
+
with: other
|
|
702
339
|
});
|
|
703
340
|
if (history.length > PLAYTEST_PROTOCOL_LIMITS.maxEventsPerDrain) history.shift();
|
|
704
341
|
}
|
|
705
342
|
}
|
|
706
|
-
}
|
|
343
|
+
});
|
|
707
344
|
return [...history];
|
|
708
345
|
}
|
|
346
|
+
var PLAYTEST_TRANSITION_LOG_LIMIT = 512;
|
|
347
|
+
function primitiveOrUndefined(value) {
|
|
348
|
+
if (value === null) return null;
|
|
349
|
+
if (typeof value === "string" || typeof value === "boolean") return value;
|
|
350
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
351
|
+
}
|
|
352
|
+
function createTransitionWatcher() {
|
|
353
|
+
let previous = /* @__PURE__ */ new Map();
|
|
354
|
+
let log = [];
|
|
355
|
+
let seen = false;
|
|
356
|
+
let tickSeen;
|
|
357
|
+
const record = (path, to, tick) => {
|
|
358
|
+
const had = previous.has(path);
|
|
359
|
+
const from = previous.get(path);
|
|
360
|
+
previous.set(path, to);
|
|
361
|
+
if (!had || from === to) return;
|
|
362
|
+
log.push({ from, path, tick, to });
|
|
363
|
+
if (log.length > PLAYTEST_TRANSITION_LOG_LIMIT) log.shift();
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
lastTick: () => tickSeen,
|
|
367
|
+
observe: (ctx, tick) => {
|
|
368
|
+
tickSeen = tick;
|
|
369
|
+
seen = true;
|
|
370
|
+
ctx.entities.forEach((id, entity) => {
|
|
371
|
+
const candidate = entity;
|
|
372
|
+
if (typeof candidate.state === "string") record(`states.${id}`, candidate.state, tick);
|
|
373
|
+
});
|
|
374
|
+
const state = ctx.state.getState();
|
|
375
|
+
for (const key of Object.keys(state)) {
|
|
376
|
+
const value = primitiveOrUndefined(state[key]);
|
|
377
|
+
if (value !== void 0) record(`state.${key}`, value, tick);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
// Absent until the loop has ticked at least once. A run that never ticked has not observed
|
|
381
|
+
// that nothing changed; it has observed nothing, and an assertion against it must say so.
|
|
382
|
+
read: () => seen ? log.map((entry) => ({ ...entry })) : void 0,
|
|
383
|
+
reset: () => {
|
|
384
|
+
previous = /* @__PURE__ */ new Map();
|
|
385
|
+
log = [];
|
|
386
|
+
seen = false;
|
|
387
|
+
tickSeen = void 0;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
}
|
|
709
391
|
function entitySources(entity) {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
(value) => typeof value === "object" && value !== null && "drainContacts" in value && typeof value.drainContacts === "function"
|
|
392
|
+
return objectGraphValues(entity).filter(
|
|
393
|
+
(value) => "drainContacts" in value && typeof value.drainContacts === "function"
|
|
713
394
|
);
|
|
714
395
|
}
|
|
715
|
-
function
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
396
|
+
function objectGraphValues(root) {
|
|
397
|
+
if (root === void 0) return [];
|
|
398
|
+
const values = [];
|
|
399
|
+
const visited = /* @__PURE__ */ new Set();
|
|
400
|
+
const visit = (value, depth) => {
|
|
401
|
+
if (value === null || typeof value !== "object" || visited.has(value)) return;
|
|
402
|
+
visited.add(value);
|
|
403
|
+
values.push(value);
|
|
404
|
+
if (depth >= 4 || value instanceof Object3D) return;
|
|
405
|
+
for (const child of Object.values(value)) visit(child, depth + 1);
|
|
406
|
+
};
|
|
407
|
+
visit(root, 0);
|
|
408
|
+
return values;
|
|
720
409
|
}
|
|
721
410
|
function tagCounts(snapshot) {
|
|
722
411
|
const counts = {};
|
|
@@ -740,13 +429,24 @@ function stateResources(ctx) {
|
|
|
740
429
|
ctx.state.flush();
|
|
741
430
|
const state = ctx.state.getState();
|
|
742
431
|
assertJsonSafe(state);
|
|
743
|
-
const value = state;
|
|
432
|
+
const value = cloneJsonValue(state);
|
|
744
433
|
return Object.fromEntries([
|
|
745
|
-
|
|
746
|
-
|
|
434
|
+
// `state` is canonical. Keep `GameState` as a compatibility alias until published
|
|
435
|
+
// scenarios have migrated, then remove the alias in a future breaking release.
|
|
436
|
+
["state", value],
|
|
437
|
+
["GameState", value]
|
|
747
438
|
]);
|
|
748
439
|
}
|
|
749
440
|
};
|
|
750
441
|
}
|
|
442
|
+
function cloneJsonValue(value) {
|
|
443
|
+
if (Array.isArray(value)) return value.map((item) => cloneJsonValue(item));
|
|
444
|
+
if (typeof value === "object" && value !== null) {
|
|
445
|
+
return Object.fromEntries(
|
|
446
|
+
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)])
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
return value;
|
|
450
|
+
}
|
|
751
451
|
|
|
752
|
-
export { PLAYTEST_ATTACH_TIMEOUT_MS, PLAYTEST_RUNNER_EXPECTED_GLOBAL, playtest };
|
|
452
|
+
export { PLAYTEST_ATTACH_TIMEOUT_MS, PLAYTEST_RUNNER_EXPECTED_GLOBAL, PLAYTEST_TRANSITION_LOG_LIMIT, playtest };
|