@threenative/core 0.1.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,463 +1,52 @@
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", "Samples application-owned runtime diagnostics."),
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.resources", "Reads and writes registered JSON-safe application state."),
25
- capability("runtime.state", "Samples application-owned state-machine state."),
26
- capability("runtime.tags", "Samples bounded application-owned entity tags."),
27
- capability("runtime.ui", "Samples registered JSON-safe UI and HUD state."),
28
- capability("runtime.world", "Samples bounded runtime world metadata.")
29
- ];
30
- new Set(PLAYTEST_CAPABILITY_REGISTRY.map(({ name }) => name));
31
- function capability(name, description) {
32
- return { description, name, protocolVersion: 1 };
33
- }
34
- var PLAYTEST_PROTOCOL_LIMITS = {
35
- maxEventsPerDrain: 1e3};
36
- function assertJsonSafe(value, path = "$") {
37
- if (value === null || typeof value === "string" || typeof value === "boolean") {
38
- return;
39
- }
40
- if (typeof value === "number") {
41
- if (Number.isFinite(value)) {
42
- return;
43
- }
44
- throw new TypeError(`${path} must contain only finite JSON numbers.`);
45
- }
46
- if (Array.isArray(value)) {
47
- value.forEach((item, index) => assertJsonSafe(item, `${path}[${index}]`));
48
- return;
49
- }
50
- if (typeof value === "object") {
51
- for (const [key, item] of Object.entries(value)) {
52
- assertJsonSafe(item, `${path}.${key}`);
53
- }
54
- return;
55
- }
56
- throw new TypeError(`${path} must be JSON-safe.`);
57
- }
58
- var PLAYTEST_BRIDGE_GLOBAL = "__THREENATIVE_PLAYTEST_BRIDGE__";
59
- var PLAYTEST_PROTOCOL_VERSION = 1;
60
- var PLAYTEST_PROTOCOL_LIMITS2 = {
61
- maxEntitiesPerSample: 100,
62
- maxEventsPerDrain: 1e3,
63
- maxPayloadBytes: 1e6,
64
- operationTimeoutMs: 5e3
65
- };
66
- function jsonByteLength(value) {
67
- return new TextEncoder().encode(JSON.stringify(value)).byteLength;
68
- }
69
- function assertJsonSafe2(value, path = "$") {
70
- if (value === null || typeof value === "string" || typeof value === "boolean") {
71
- return;
72
- }
73
- if (typeof value === "number") {
74
- if (Number.isFinite(value)) {
75
- return;
76
- }
77
- throw new TypeError(`${path} must contain only finite JSON numbers.`);
78
- }
79
- if (Array.isArray(value)) {
80
- value.forEach((item, index) => assertJsonSafe2(item, `${path}[${index}]`));
81
- return;
82
- }
83
- if (typeof value === "object") {
84
- for (const [key, item] of Object.entries(value)) {
85
- assertJsonSafe2(item, `${path}.${key}`);
86
- }
87
- return;
88
- }
89
- throw new TypeError(`${path} must be JSON-safe.`);
90
- }
91
- var ThreePlaytestEntityRegistry = class {
92
- #entries = /* @__PURE__ */ new Map();
93
- register(entry) {
94
- const path = entry.path ?? objectPath(entry.object);
95
- const existing = this.#entries.get(entry.id);
96
- if (existing !== void 0) {
97
- throw new Error(
98
- `Duplicate playtest entity id '${entry.id}' conflicts between '${existing.path}' and '${path}'. Register a unique stable id for each observed object.`
99
- );
100
- }
101
- this.#entries.set(entry.id, { ...entry, path });
102
- }
103
- get(id) {
104
- return this.#entries.get(id);
105
- }
106
- replace(entries) {
107
- this.#entries.clear();
108
- entries.forEach((entry) => this.register(entry));
109
- }
110
- select(ids) {
111
- if (ids === void 0) return [...this.#entries.values()];
112
- return ids.flatMap((id) => {
113
- const entry = this.#entries.get(id);
114
- return entry === void 0 ? [] : [entry];
115
- });
116
- }
117
- };
118
- function objectPath(object) {
119
- const parts = [];
120
- let current = object;
121
- while (current !== null) {
122
- parts.unshift(current.name || `${current.type}[${current.uuid.slice(0, 8)}]`);
123
- current = current.parent;
124
- }
125
- return parts.join("/");
126
- }
127
- function sampleThreeObservations(input, request) {
128
- input.scene.updateMatrixWorld(true);
129
- input.camera.updateMatrixWorld(true);
130
- const rendererSize = input.renderer.getDrawingBufferSize(new Vector2());
131
- const entities = input.registry.select(request.entities).map(({ id, object }) => observeEntity(id, object, input.camera, rendererSize.x, rendererSize.y));
132
- return {
133
- clock: {
134
- mode: input.clockMode,
135
- ...input.tick === void 0 ? { timeMs: performance.now() } : { tick: input.tick }
136
- },
137
- ...input.diagnostics === void 0 ? {} : { diagnostics: input.diagnostics() },
138
- entities,
139
- ...input.gameplay === void 0 ? {} : { gameplay: input.gameplay() },
140
- ...input.resources === void 0 ? {} : { resources: input.resources() }
141
- };
142
- }
143
- function observeEntity(id, object, camera, viewportWidth, viewportHeight) {
144
- const position = object.getWorldPosition(new Vector3());
145
- const bounds = projectedBounds(object, camera, viewportWidth, viewportHeight);
146
- return {
147
- ...bounds === void 0 ? {} : { bounds },
148
- id,
149
- transform: {
150
- position: [position.x, position.y, position.z],
151
- rotation: [object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w],
152
- scale: [object.scale.x, object.scale.y, object.scale.z]
153
- },
154
- visible: object.visible && bounds !== void 0 && bounds.width > 0 && bounds.height > 0
155
- };
156
- }
157
- function projectedBounds(object, camera, viewportWidth, viewportHeight) {
158
- const worldBounds = new Box3().setFromObject(object);
159
- if (worldBounds.isEmpty()) return void 0;
160
- const projection = new Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
161
- if (!new Frustum().setFromProjectionMatrix(projection).intersectsBox(worldBounds)) return void 0;
162
- const min = worldBounds.min;
163
- const max = worldBounds.max;
164
- const points = [
165
- new Vector3(min.x, min.y, min.z),
166
- new Vector3(min.x, min.y, max.z),
167
- new Vector3(min.x, max.y, min.z),
168
- new Vector3(min.x, max.y, max.z),
169
- new Vector3(max.x, min.y, min.z),
170
- new Vector3(max.x, min.y, max.z),
171
- new Vector3(max.x, max.y, min.z),
172
- new Vector3(max.x, max.y, max.z)
173
- ].map((point) => point.project(camera));
174
- const minX = Math.min(...points.map(({ x }) => x));
175
- const maxX = Math.max(...points.map(({ x }) => x));
176
- const minY = Math.min(...points.map(({ y }) => y));
177
- const maxY = Math.max(...points.map(({ y }) => y));
178
- return {
179
- height: Math.max(0, (maxY - minY) * 0.5 * viewportHeight),
180
- width: Math.max(0, (maxX - minX) * 0.5 * viewportWidth),
181
- x: (minX + 1) * 0.5 * viewportWidth,
182
- y: (1 - maxY) * 0.5 * viewportHeight
183
- };
184
- }
185
- var BRIDGE_METHODS = /* @__PURE__ */ new Set([
186
- "advance",
187
- "applySetup",
188
- "describe",
189
- "drainEvents",
190
- "focus",
191
- "ready",
192
- "sample"
193
- ]);
194
- function connectDevicePlaytestBridge(bridge, endpoint) {
195
- const nativeInstallation = connectNativeMailbox(bridge);
196
- if (nativeInstallation !== void 0) return nativeInstallation;
197
- let closed = false;
198
- let reportedError = false;
199
- let scheduled;
200
- const schedule = () => {
201
- if (closed) return;
202
- if (typeof globalThis.requestAnimationFrame === "function") {
203
- scheduled = globalThis.requestAnimationFrame(() => void poll());
204
- } else {
205
- scheduled = setTimeout(() => void poll(), 50);
206
- }
207
- };
208
- const poll = async () => {
209
- if (closed) return;
210
- try {
211
- const response = await fetch(endpoint);
212
- if (response.status !== 204) {
213
- if (!response.ok) throw new Error(`Device bridge poll failed with HTTP ${response.status}.`);
214
- const request = parseRequest(await response.json());
215
- await postResponse(endpoint, await dispatch(bridge, request));
216
- }
217
- } catch (error) {
218
- if (!reportedError) {
219
- reportedError = true;
220
- console.error(`TN_PLAYTEST_DEVICE_TRANSPORT: ${error instanceof Error ? error.message : String(error)}`);
221
- }
222
- } finally {
223
- schedule();
224
- }
225
- };
226
- schedule();
227
- return {
228
- close: () => {
229
- closed = true;
230
- if (scheduled === void 0) return;
231
- if (typeof globalThis.cancelAnimationFrame === "function" && typeof scheduled === "number") {
232
- globalThis.cancelAnimationFrame(scheduled);
233
- } else clearTimeout(scheduled);
234
- }
235
- };
236
- }
237
- function connectNativeMailbox(bridge) {
238
- const globals = globalThis;
239
- const mailbox = globals.TN_PLAYTEST_MAILBOX;
240
- const host = globals.__THREENATIVE_NATIVE__?.playtest;
241
- if (typeof mailbox?.request !== "string" || typeof mailbox.response !== "string" || typeof host?.receive !== "function" || typeof host.respond !== "function") return void 0;
242
- let closed = false;
243
- let frame;
244
- const respond = (response) => {
245
- if (!host.respond(mailbox.response, JSON.stringify(response))) {
246
- console.error("TN_PLAYTEST_DEVICE_TRANSPORT: native mailbox response failed");
247
- }
248
- };
249
- const poll = () => {
250
- if (closed) return;
251
- const raw = host.receive(mailbox.request);
252
- if (raw !== void 0) {
253
- try {
254
- const request = parseRequest(JSON.parse(raw));
255
- void dispatch(bridge, request).then(respond);
256
- } catch (error) {
257
- respond({
258
- error: { message: error instanceof Error ? error.message : String(error) },
259
- id: "invalid"
260
- });
261
- }
262
- }
263
- frame = globalThis.requestAnimationFrame(poll);
264
- };
265
- respond({ id: "ready", result: null });
266
- frame = globalThis.requestAnimationFrame(poll);
267
- return {
268
- close: () => {
269
- closed = true;
270
- if (frame !== void 0) globalThis.cancelAnimationFrame(frame);
271
- }
272
- };
273
- }
274
- function readPlaytestEndpoint() {
275
- const value = globalThis.TN_PLAYTEST_ENDPOINT;
276
- return typeof value === "string" && value.length > 0 ? value : void 0;
277
- }
278
- async function dispatch(bridge, request) {
279
- try {
280
- if (request.method.startsWith("input.")) {
281
- return { id: request.id, result: dispatchInput(request.method, request.argument) };
282
- }
283
- if (!BRIDGE_METHODS.has(request.method)) {
284
- throw new Error(`Bridge operation '${request.method}' is unavailable.`);
285
- }
286
- const operation = bridge[request.method];
287
- if (typeof operation !== "function") throw new Error(`Bridge operation '${request.method}' is unavailable.`);
288
- const result = await operation.call(bridge, request.argument);
289
- if (result !== void 0) assertBounded(result);
290
- return { id: request.id, ...result === void 0 ? {} : { result } };
291
- } catch (error) {
292
- return { error: { message: error instanceof Error ? error.message : String(error) }, id: request.id };
293
- }
294
- }
295
- function dispatchInput(method, argument) {
296
- const host = globalThis.__THREENATIVE_NATIVE__?.playtestInput;
297
- if (!isRecord(argument)) throw new Error(`Device ${method} requires an object argument.`);
298
- if (method === "input.keyDown" || method === "input.keyUp") {
299
- if (typeof argument.key !== "string") throw new Error(`Device ${method} requires a key.`);
300
- const code = argument.key;
301
- const key = /^Key[A-Z]$/u.test(code) ? code.slice(3).toLowerCase() : code;
302
- if (typeof host?.keyboard !== "function") throw new Error("Native playtest keyboard input is unavailable.");
303
- host.keyboard(method === "input.keyDown" ? "keydown" : "keyup", key, code);
304
- return null;
305
- }
306
- if (method === "input.pointer") {
307
- if (typeof argument.x !== "number" || typeof argument.y !== "number" || typeof argument.buttons !== "number") {
308
- throw new Error("Device input.pointer requires numeric x, y, and buttons.");
309
- }
310
- if (typeof host?.pointer !== "function") throw new Error("Native playtest pointer input is unavailable.");
311
- const type = argument.type === "down" ? "pointerdown" : argument.type === "up" ? "pointerup" : "pointermove";
312
- host.pointer(type, argument.x, argument.y, argument.buttons);
313
- return null;
314
- }
315
- throw new Error(`Device input operation '${method}' is unavailable.`);
316
- }
317
- function parseRequest(value) {
318
- assertBounded(value);
319
- if (!isRecord(value) || typeof value.id !== "string" || typeof value.method !== "string") {
320
- throw new Error("Device request must contain string id and method fields.");
321
- }
322
- return value;
323
- }
324
- async function postResponse(endpoint, response) {
325
- const result = await fetch(endpoint, {
326
- body: JSON.stringify(response),
327
- headers: { "content-type": "application/json" },
328
- method: "POST"
329
- });
330
- if (!result.ok) throw new Error(`Device bridge response failed with HTTP ${result.status}.`);
331
- }
332
- function assertBounded(value) {
333
- assertJsonSafe2(value);
334
- if (jsonByteLength(value) > PLAYTEST_PROTOCOL_LIMITS2.maxPayloadBytes) {
335
- throw new Error(`Device bridge payload exceeds ${PLAYTEST_PROTOCOL_LIMITS2.maxPayloadBytes} bytes.`);
336
- }
337
- }
338
- function isRecord(value) {
339
- return typeof value === "object" && value !== null && !Array.isArray(value);
340
- }
341
- function installThreePlaytestBridge(options) {
342
- const host = globalThis;
343
- const previous = host[PLAYTEST_BRIDGE_GLOBAL];
344
- const registry = new ThreePlaytestEntityRegistry();
345
- syncEntities(registry, options.entities);
346
- let tick = 0;
347
- const capabilities = () => [
348
- "camera.observe",
349
- "entity.bounds",
350
- "entity.observe",
351
- "entity.setup",
352
- ...options.fixedStep === void 0 ? [] : ["runtime.fixedStep"],
353
- ...options.resources === void 0 ? [] : ["runtime.resources"],
354
- ...options.diagnostics === void 0 ? [] : ["runtime.diagnostics"],
355
- ...Object.keys(options.components?.() ?? {}).length === 0 ? [] : ["runtime.components"],
356
- ...options.events === void 0 ? [] : ["runtime.events"],
357
- ...options.gameplay === void 0 ? [] : ["runtime.animation", "runtime.state"],
358
- ...options.gameplayChannels?.().includes("runtime.contacts") === true ? ["runtime.contacts"] : [],
359
- ...options.gameplayChannels?.().includes("runtime.tags") === true ? ["runtime.tags"] : []
360
- ];
361
- const bridge = {
362
- ...options.fixedStep === void 0 ? {} : {
363
- advance: async (ticks) => {
364
- if (!Number.isInteger(ticks) || ticks <= 0) throw new Error("advance ticks must be a positive integer.");
365
- await options.fixedStep(ticks);
366
- const startTick = tick;
367
- tick += ticks;
368
- return { clock: { mode: "fixed-step", tick }, ticks: tick - startTick };
369
- }
370
- },
371
- applySetup: async (request) => applySetup(registry, options.resources, request),
372
- ...options.events === void 0 ? {} : {
373
- drainEvents: async (limit = PLAYTEST_PROTOCOL_LIMITS2.maxEventsPerDrain) => {
374
- if (!Number.isInteger(limit) || limit < 0) throw new Error("drainEvents limit must be a non-negative integer.");
375
- const events = options.events();
376
- assertJsonSafe2(events);
377
- return events.slice(0, Math.min(limit, PLAYTEST_PROTOCOL_LIMITS2.maxEventsPerDrain));
378
- }
379
- },
380
- describe: () => ({
381
- capabilities: capabilities(),
382
- limits: PLAYTEST_PROTOCOL_LIMITS2,
383
- name: "@threenative/playtest/three",
384
- protocolVersion: PLAYTEST_PROTOCOL_VERSION
385
- }),
386
- ready: () => ({ ready: true }),
387
- sample: (request) => {
388
- syncEntities(registry, options.entities);
389
- const snapshot = sampleThreeObservations({
390
- camera: options.camera,
391
- clockMode: options.fixedStep === void 0 ? "render-frame" : "fixed-step",
392
- diagnostics: options.diagnostics,
393
- gameplay: options.gameplay,
394
- registry,
395
- renderer: options.renderer,
396
- resources: options.resources === void 0 ? void 0 : () => options.resources.read(),
397
- scene: options.scene,
398
- tick: options.fixedStep === void 0 ? void 0 : tick
399
- }, request);
400
- const components = options.components?.();
401
- const result = components === void 0 || Object.keys(components).length === 0 ? snapshot : { ...snapshot, components };
402
- assertJsonSafe2(result);
403
- return result;
404
- }
405
- };
406
- host[PLAYTEST_BRIDGE_GLOBAL] = bridge;
407
- const endpoint = readPlaytestEndpoint();
408
- let device;
409
- if (endpoint !== void 0) device = connectDevicePlaytestBridge(bridge, endpoint);
410
- return {
411
- bridge,
412
- dispose: () => {
413
- device?.close();
414
- if (host[PLAYTEST_BRIDGE_GLOBAL] !== bridge) return;
415
- if (previous === void 0) delete host[PLAYTEST_BRIDGE_GLOBAL];
416
- else host[PLAYTEST_BRIDGE_GLOBAL] = previous;
417
- },
418
- registerEntity: (entry) => registry.register(entry),
419
- syncEntities: () => syncEntities(registry, options.entities)
420
- };
421
- }
422
- function syncEntities(registry, entities) {
423
- if (entities === void 0) return;
424
- registry.replace(typeof entities === "function" ? entities() : entities);
425
- }
426
- function applySetup(registry, resources, request) {
427
- request.entities?.forEach(({ entity, transform }) => {
428
- const object = registry.get(entity)?.object;
429
- if (object === void 0) throw new Error(`Setup entity '${entity}' is not registered.`);
430
- if (transform.position !== void 0) object.position.fromArray(transform.position);
431
- if (transform.rotation !== void 0) object.quaternion.fromArray(transform.rotation);
432
- if (transform.scale !== void 0) object.scale.fromArray(transform.scale);
433
- object.updateMatrix();
434
- });
435
- request.resources?.forEach(({ id, path, value }) => {
436
- assertJsonSafe2(value);
437
- if (resources?.write === void 0) throw new Error(`Resource setup '${id}' requires a writable resources provider.`);
438
- if (!resources.write(id, path, value)) throw new Error(`Resource setup '${id}' was rejected by the application provider.`);
439
- });
440
- }
5
+ // src/playtest.ts
441
6
  var buses = /* @__PURE__ */ new Set();
442
7
  function audioRuntimeSnapshot() {
443
8
  let queued = 0;
444
9
  let voices = 0;
10
+ let pooled = 0;
445
11
  for (const bus of buses) {
446
12
  queued += bus.queued;
447
13
  voices += bus.voices;
14
+ pooled += bus.pooled;
448
15
  }
449
- return { queued, voices };
16
+ return { pooled, queued, voices };
17
+ }
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;
450
37
  }
451
38
 
39
+ // src/version.ts
40
+ var CORE_VERSION = "0.3.0";
41
+
452
42
  // src/playtest.ts
453
- var CORE_VERSION = "0.1.0";
454
43
  var currentAgent = typeof navigator === "undefined" ? "node" : navigator.userAgent;
455
44
  function playtest(options = {}) {
456
45
  let dispose;
46
+ let attached;
457
47
  let contactHistory = [];
458
48
  return {
459
- setup: (ctx, runtime) => {
460
- const diagnostics = [];
49
+ setup: async (ctx, runtime) => {
461
50
  const seed = runtime?.seed ?? null;
462
51
  const replayRuntime = runtime?.seed === null || runtime?.seed === void 0 ? void 0 : {
463
52
  agent: currentAgent,
@@ -468,38 +57,124 @@ function playtest(options = {}) {
468
57
  };
469
58
  const installation = installThreePlaytestBridge({
470
59
  camera: ctx.camera,
471
- diagnostics: () => [...diagnostics],
472
60
  entities: () => bridgeEntities(ctx),
473
61
  components: () => componentObservations(ctx.entities.snapshot()),
474
- ...runtime === void 0 ? {} : { fixedStep: (ticks) => advance(runtime, ticks) },
62
+ ...runtime === void 0 ? {} : { fixedStep: runtime.fixedStep, tick: runtime.tick },
63
+ ...runtime?.runtimeDiagnosticsSeries === void 0 ? {} : { runtimeDiagnosticsSeries: runtime.runtimeDiagnosticsSeries },
475
64
  ...options.events === void 0 ? {} : { events: options.events },
476
65
  gameplay: () => gameplayObservations(ctx, contactHistory, seed, replayRuntime),
477
66
  gameplayChannels: () => gameplayChannels(),
478
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
+ },
479
78
  resources: stateResources(ctx),
480
- 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
+ })
481
88
  });
482
- installRuntimeChannels(installation.bridge);
89
+ installRuntimeChannels(installation.bridge, runtime);
90
+ if (globalThis[PLAYTEST_RUNNER_EXPECTED_GLOBAL] === true)
91
+ runtime?.enableRuntimeDiagnostics?.();
483
92
  dispose = installation.dispose;
484
- return () => {
93
+ attached = holdUntilAttached(installation.bridge, options);
94
+ const cleanup = () => {
485
95
  dispose?.();
486
96
  dispose = void 0;
97
+ attached = void 0;
487
98
  contactHistory = [];
488
99
  };
100
+ if (attached !== void 0) {
101
+ const gate = attached.catch((error) => {
102
+ cleanup();
103
+ throw error;
104
+ });
105
+ if (runtime?.holdStart === void 0) await gate;
106
+ else runtime.holdStart(gate);
107
+ }
108
+ return cleanup;
489
109
  }
490
110
  };
491
111
  }
492
- function installRuntimeChannels(bridge) {
112
+ var PLAYTEST_ATTACH_TIMEOUT_MS = 3e4;
113
+ var PLAYTEST_RUNNER_EXPECTED_GLOBAL = "__THREENATIVE_PLAYTEST_RUNNER_EXPECTED__";
114
+ function shouldHoldUntilAttached(options) {
115
+ if (options.holdUntilAttached !== void 0) return options.holdUntilAttached;
116
+ return globalThis[PLAYTEST_RUNNER_EXPECTED_GLOBAL] === true;
117
+ }
118
+ function holdUntilAttached(bridge, options) {
119
+ if (!shouldHoldUntilAttached(options)) return void 0;
120
+ const timeoutMs = options.attachTimeoutMs ?? PLAYTEST_ATTACH_TIMEOUT_MS;
121
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
122
+ throw new Error(`TN_PLAYTEST_ATTACH_TIMEOUT_INVALID: ${String(options.attachTimeoutMs)}`);
123
+ }
493
124
  const describe = bridge.describe;
494
- bridge.describe = () => Promise.resolve(describe()).then((description) => addRuntimeCapabilities(description));
125
+ return new Promise((resolve, reject) => {
126
+ const timer = setTimeout(() => {
127
+ reject(
128
+ new Error(
129
+ `TN_PLAYTEST_ATTACH_TIMEOUT: no playtest runner called describe() within ${timeoutMs}ms.`
130
+ )
131
+ );
132
+ }, timeoutMs);
133
+ timer.unref?.();
134
+ bridge.describe = () => {
135
+ clearTimeout(timer);
136
+ resolve();
137
+ return describe();
138
+ };
139
+ });
495
140
  }
496
- function addRuntimeCapabilities(description) {
141
+ function installRuntimeChannels(bridge, runtime) {
142
+ const describe = bridge.describe;
143
+ bridge.describe = () => Promise.resolve(describe()).then(
144
+ (description) => addRuntimeCapabilities(description, runtime?.observations.contributions() ?? [])
145
+ );
146
+ const sample = bridge.sample;
147
+ bridge.sample = (request) => Promise.resolve(sample(request)).then(
148
+ (snapshot) => addRuntimeObservations(snapshot, request, runtime?.observations.contributions() ?? [])
149
+ );
150
+ }
151
+ function addRuntimeCapabilities(description, contributions) {
497
152
  const capabilities = [...description.capabilities];
498
- for (const capability2 of ["runtime.audio", "runtime.world"]) {
499
- if (!capabilities.includes(capability2)) capabilities.push(capability2);
153
+ const contributed = contributions.flatMap(({ capabilities: capabilities2 }) => capabilities2);
154
+ for (const capability of ["runtime.audio", "runtime.world", ...contributed]) {
155
+ if (!capabilities.includes(capability)) capabilities.push(capability);
500
156
  }
501
157
  return { ...description, capabilities };
502
158
  }
159
+ function addRuntimeObservations(snapshot, request, contributions) {
160
+ const result = { ...snapshot };
161
+ for (const contribution of contributions) {
162
+ const slice = contribution.sample(request);
163
+ if (typeof slice !== "object" || slice === null || Array.isArray(slice)) {
164
+ throw new TypeError("A runtime observation contribution must return a top-level object.");
165
+ }
166
+ assertJsonSafe(slice, "$.runtimeContribution");
167
+ for (const [key, value] of Object.entries(slice)) {
168
+ if (Object.hasOwn(result, key)) {
169
+ throw new Error(
170
+ `TN_PLAYTEST_OBSERVATION_COLLISION: top-level key '${key}' already exists.`
171
+ );
172
+ }
173
+ result[key] = value;
174
+ }
175
+ }
176
+ return result;
177
+ }
503
178
  function runtimeObservation(seed, replayRuntime) {
504
179
  return {
505
180
  audio: audioRuntimeSnapshot(),
@@ -509,9 +184,6 @@ function runtimeObservation(seed, replayRuntime) {
509
184
  }
510
185
  };
511
186
  }
512
- function advance(runtime, ticks) {
513
- runtime.fixedStep(ticks);
514
- }
515
187
  function bridgeEntities(ctx) {
516
188
  return [
517
189
  { id: "camera.main", object: ctx.camera },
@@ -532,10 +204,12 @@ function gameplayObservations(ctx, contactHistory, seed, replayRuntime) {
532
204
  const snapshot = ctx.entities.snapshot();
533
205
  for (const id of Object.keys(snapshot)) {
534
206
  const entity = ctx.entities.get(id);
207
+ const finished = typeof entity?.animation?.finished === "boolean" ? entity.animation.finished : void 0;
535
208
  if (typeof entity?.animation?.current === "string" && typeof entity.animation.advancedFrames === "number") {
536
209
  animation[id] = {
537
210
  advancedFrames: entity.animation.advancedFrames,
538
- clip: entity.animation.current
211
+ clip: entity.animation.current,
212
+ ...finished === void 0 ? {} : { finished }
539
213
  };
540
214
  }
541
215
  if (typeof entity?.state === "string") states[id] = entity.state;
@@ -555,10 +229,20 @@ function gameplayChannels(_ctx) {
555
229
  return ["runtime.contacts", "runtime.tags"];
556
230
  }
557
231
  function drainContacts(ctx, history) {
558
- 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)) {
559
243
  for (const source of entitySources(ctx.entities.get(id))) {
560
244
  for (const event of source.drainContacts()) {
561
- const entity = findEntityId(ctx, event.body);
245
+ const entity = idsByEntity.get(event.body);
562
246
  if (entity === void 0 || event.entity === void 0) continue;
563
247
  history.push({
564
248
  entity,
@@ -577,12 +261,6 @@ function entitySources(entity) {
577
261
  (value) => typeof value === "object" && value !== null && "drainContacts" in value && typeof value.drainContacts === "function"
578
262
  );
579
263
  }
580
- function findEntityId(ctx, target) {
581
- return Object.keys(ctx.entities.snapshot()).find((id) => {
582
- const entity = ctx.entities.get(id);
583
- return entity !== void 0 && Object.values(entity).some((value) => value === target);
584
- });
585
- }
586
264
  function tagCounts(snapshot) {
587
265
  const counts = {};
588
266
  for (const fields of Object.values(snapshot)) {
@@ -605,10 +283,24 @@ function stateResources(ctx) {
605
283
  ctx.state.flush();
606
284
  const state = ctx.state.getState();
607
285
  assertJsonSafe(state);
608
- const value = state;
609
- return { GameState: value, state: value };
286
+ const value = cloneJsonValue(state);
287
+ return Object.fromEntries([
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]
292
+ ]);
610
293
  }
611
294
  };
612
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
+ }
613
305
 
614
- export { playtest };
306
+ export { PLAYTEST_ATTACH_TIMEOUT_MS, PLAYTEST_RUNNER_EXPECTED_GLOBAL, playtest };