@threenative/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/playtest.js CHANGED
@@ -1,528 +1,45 @@
1
- import { Vector2, Vector3, Box3, Matrix4, Frustum, Object3D } from 'three';
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
- // ../playtest/dist/index.js
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;
517
11
  for (const bus of buses) {
518
12
  queued += bus.queued;
519
13
  voices += bus.voices;
14
+ pooled += bus.pooled;
520
15
  }
521
- return { queued, voices };
16
+ return { pooled, queued, voices };
522
17
  }
523
18
 
19
+ // src/render/chain.ts
20
+ var REPORT_KEY = /* @__PURE__ */ Symbol.for("threenative.renderChain.report");
21
+ function storedReport(target) {
22
+ return target[REPORT_KEY];
23
+ }
24
+ function readRenderChainReport(renderer) {
25
+ if (!isObject(renderer)) return void 0;
26
+ const raw = readRawRenderer(renderer);
27
+ return storedReport(renderer) ?? (isObject(raw) ? storedReport(raw) : void 0);
28
+ }
29
+ function readRenderChainObservation(renderer) {
30
+ return readRenderChainReport(renderer)?.applied;
31
+ }
32
+ function readRawRenderer(renderer) {
33
+ return "raw" in renderer ? renderer.raw : renderer;
34
+ }
35
+ function isObject(value) {
36
+ return typeof value === "object" && value !== null;
37
+ }
38
+
39
+ // src/version.ts
40
+ var CORE_VERSION = "0.3.0";
41
+
524
42
  // src/playtest.ts
525
- var CORE_VERSION = "0.1.0";
526
43
  var currentAgent = typeof navigator === "undefined" ? "node" : navigator.userAgent;
527
44
  function playtest(options = {}) {
528
45
  let dispose;
@@ -548,10 +65,30 @@ function playtest(options = {}) {
548
65
  gameplay: () => gameplayObservations(ctx, contactHistory, seed, replayRuntime),
549
66
  gameplayChannels: () => gameplayChannels(),
550
67
  renderer: ctx.renderer.raw,
68
+ renderChain: () => {
69
+ const observation = readRenderChainObservation(ctx.renderer);
70
+ return observation === void 0 ? void 0 : {
71
+ ...observation,
72
+ dropped: observation.dropped.map((stage) => ({ ...stage })),
73
+ requested: [...observation.requested],
74
+ stages: [...observation.stages],
75
+ velocity: { ...observation.velocity }
76
+ };
77
+ },
551
78
  resources: stateResources(ctx),
552
- scene: ctx.scene
79
+ scene: ctx.scene,
80
+ // A runner advances ticks far faster than a launch completes, and compute dispatch and
81
+ // the first world present are both held behind an opaque loading layer. Published so the
82
+ // runner waits for the world rather than observing a frozen simulation behind a loader.
83
+ startup: () => ({
84
+ phase: ctx.startup.phase,
85
+ progress: ctx.startup.progress,
86
+ ...runtime?.startupCompileSettled === void 0 ? {} : { compileSettled: runtime.startupCompileSettled() }
87
+ })
553
88
  });
554
89
  installRuntimeChannels(installation.bridge, runtime);
90
+ if (globalThis[PLAYTEST_RUNNER_EXPECTED_GLOBAL] === true)
91
+ runtime?.enableRuntimeDiagnostics?.();
555
92
  dispose = installation.dispose;
556
93
  attached = holdUntilAttached(installation.bridge, options);
557
94
  const cleanup = () => {
@@ -614,8 +151,8 @@ function installRuntimeChannels(bridge, runtime) {
614
151
  function addRuntimeCapabilities(description, contributions) {
615
152
  const capabilities = [...description.capabilities];
616
153
  const contributed = contributions.flatMap(({ capabilities: capabilities2 }) => capabilities2);
617
- for (const capability2 of ["runtime.audio", "runtime.world", ...contributed]) {
618
- if (!capabilities.includes(capability2)) capabilities.push(capability2);
154
+ for (const capability of ["runtime.audio", "runtime.world", ...contributed]) {
155
+ if (!capabilities.includes(capability)) capabilities.push(capability);
619
156
  }
620
157
  return { ...description, capabilities };
621
158
  }
@@ -667,10 +204,12 @@ function gameplayObservations(ctx, contactHistory, seed, replayRuntime) {
667
204
  const snapshot = ctx.entities.snapshot();
668
205
  for (const id of Object.keys(snapshot)) {
669
206
  const entity = ctx.entities.get(id);
207
+ const finished = typeof entity?.animation?.finished === "boolean" ? entity.animation.finished : void 0;
670
208
  if (typeof entity?.animation?.current === "string" && typeof entity.animation.advancedFrames === "number") {
671
209
  animation[id] = {
672
210
  advancedFrames: entity.animation.advancedFrames,
673
- clip: entity.animation.current
211
+ clip: entity.animation.current,
212
+ ...finished === void 0 ? {} : { finished }
674
213
  };
675
214
  }
676
215
  if (typeof entity?.state === "string") states[id] = entity.state;
@@ -690,10 +229,20 @@ function gameplayChannels(_ctx) {
690
229
  return ["runtime.contacts", "runtime.tags"];
691
230
  }
692
231
  function drainContacts(ctx, history) {
693
- for (const id of Object.keys(ctx.entities.snapshot())) {
232
+ const snapshot = ctx.entities.snapshot();
233
+ const idsByEntity = /* @__PURE__ */ new Map();
234
+ for (const id of Object.keys(snapshot)) {
235
+ const registered = ctx.entities.get(id);
236
+ if (registered === void 0) continue;
237
+ for (const value of Object.values(registered)) {
238
+ if (value !== null && typeof value === "object" && !idsByEntity.has(value))
239
+ idsByEntity.set(value, id);
240
+ }
241
+ }
242
+ for (const id of Object.keys(snapshot)) {
694
243
  for (const source of entitySources(ctx.entities.get(id))) {
695
244
  for (const event of source.drainContacts()) {
696
- const entity = findEntityId(ctx, event.body);
245
+ const entity = idsByEntity.get(event.body);
697
246
  if (entity === void 0 || event.entity === void 0) continue;
698
247
  history.push({
699
248
  entity,
@@ -712,12 +261,6 @@ function entitySources(entity) {
712
261
  (value) => typeof value === "object" && value !== null && "drainContacts" in value && typeof value.drainContacts === "function"
713
262
  );
714
263
  }
715
- function findEntityId(ctx, target) {
716
- return Object.keys(ctx.entities.snapshot()).find((id) => {
717
- const entity = ctx.entities.get(id);
718
- return entity !== void 0 && Object.values(entity).some((value) => value === target);
719
- });
720
- }
721
264
  function tagCounts(snapshot) {
722
265
  const counts = {};
723
266
  for (const fields of Object.values(snapshot)) {
@@ -740,13 +283,24 @@ function stateResources(ctx) {
740
283
  ctx.state.flush();
741
284
  const state = ctx.state.getState();
742
285
  assertJsonSafe(state);
743
- const value = state;
286
+ const value = cloneJsonValue(state);
744
287
  return Object.fromEntries([
745
- ["GameState", value],
746
- ["state", value]
288
+ // `state` is canonical. Keep `GameState` as a compatibility alias until published
289
+ // scenarios have migrated, then remove the alias in a future breaking release.
290
+ ["state", value],
291
+ ["GameState", value]
747
292
  ]);
748
293
  }
749
294
  };
750
295
  }
296
+ function cloneJsonValue(value) {
297
+ if (Array.isArray(value)) return value.map((item) => cloneJsonValue(item));
298
+ if (typeof value === "object" && value !== null) {
299
+ return Object.fromEntries(
300
+ Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)])
301
+ );
302
+ }
303
+ return value;
304
+ }
751
305
 
752
306
  export { PLAYTEST_ATTACH_TIMEOUT_MS, PLAYTEST_RUNNER_EXPECTED_GLOBAL, playtest };