@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -0
  3. package/capabilities.json +5292 -0
  4. package/dist/assets-kyoF7JlJ.d.ts +103 -0
  5. package/dist/audio-BFiGneTL.d.ts +156 -0
  6. package/dist/canvas-layer-BLVijiUJ.d.ts +62 -0
  7. package/dist/game-XGrTzapq.d.ts +1166 -0
  8. package/dist/gpu-readback-D2iRvoe9.d.ts +112 -0
  9. package/dist/hot.d.ts +20 -2
  10. package/dist/hot.js +14 -2
  11. package/dist/index.d.ts +2108 -55
  12. package/dist/index.js +15645 -2222
  13. package/dist/net.d.ts +65 -0
  14. package/dist/net.js +643 -0
  15. package/dist/playtest.d.ts +37 -4
  16. package/dist/playtest.js +246 -546
  17. package/dist/react.d.ts +177 -0
  18. package/dist/react.js +635 -0
  19. package/dist/renderer-C6hqZpoG.d.ts +770 -0
  20. package/dist/ui-layer.d.ts +306 -0
  21. package/dist/ui-layer.js +425 -0
  22. package/dist/world.d.ts +254 -0
  23. package/dist/world.js +2686 -0
  24. package/gpl/LICENSE.GPL +117 -0
  25. package/gpl/convert.py +192 -0
  26. package/gpl/recipes/_common.py +169 -0
  27. package/gpl/recipes/bake_ao.py +111 -0
  28. package/gpl/recipes/decimate.py +64 -0
  29. package/gpl/recipes/retarget.py +131 -0
  30. package/gpl/recipes/unwrap.py +71 -0
  31. package/mcp/assets.mjs +5 -0
  32. package/mcp/blender-server.mjs +632 -0
  33. package/mcp/blender.mjs +27 -0
  34. package/mcp/engine-server.mjs +501 -0
  35. package/mcp/engine.mjs +31 -0
  36. package/mcp/install.d.mts +37 -0
  37. package/mcp/install.mjs +145 -0
  38. package/mcp/launch.mjs +72 -0
  39. package/mcp/sculpt.mjs +5 -0
  40. package/mcp/servers.d.mts +34 -0
  41. package/mcp/servers.mjs +160 -0
  42. package/package.json +76 -6
  43. package/patches/three@0.185.1.patch +522 -0
  44. package/scripts/apply-three-patch.mjs +297 -0
  45. package/scripts/ensure-mcp.mjs +43 -0
  46. package/scripts/postinstall.mjs +6 -0
  47. package/dist/audio-CEAw0w5y.d.ts +0 -35
  48. package/dist/game-DRt1Qhq3.d.ts +0 -429
package/dist/playtest.js CHANGED
@@ -1,535 +1,74 @@
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;
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 runtime.holdStart(gate);
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
- return globalThis[PLAYTEST_RUNNER_EXPECTED_GLOBAL] === true;
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
- bridge.describe = () => {
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 capability2 of ["runtime.audio", "runtime.world", ...contributed]) {
618
- if (!capabilities.includes(capability2)) capabilities.push(capability2);
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
- for (const id of Object.keys(ctx.entities.snapshot())) {
694
- for (const source of entitySources(ctx.entities.get(id))) {
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 = findEntityId(ctx, event.body);
697
- if (entity === void 0 || event.entity === void 0) continue;
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
- with: event.entity
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
- if (entity === void 0) return [];
711
- return Object.values(entity).filter(
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 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
- });
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
- ["GameState", value],
746
- ["state", value]
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 };