@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/LICENSE +21 -0
- package/README.md +45 -0
- package/capabilities.json +3542 -0
- package/dist/audio-Dp2mXpD3.d.ts +94 -0
- package/dist/canvas-layer-CtrZHgIh.d.ts +608 -0
- package/dist/game-CYIaKhgl.d.ts +980 -0
- package/dist/hot.d.ts +33 -5
- package/dist/hot.js +10 -2
- package/dist/index.d.ts +1480 -41
- package/dist/index.js +12604 -1104
- package/dist/playtest.d.ts +36 -5
- package/dist/playtest.js +160 -468
- package/dist/react.d.ts +175 -0
- package/dist/react.js +635 -0
- package/dist/ui-layer.d.ts +306 -0
- package/dist/ui-layer.js +425 -0
- package/dist/world.d.ts +55 -0
- package/dist/world.js +175 -0
- package/mcp/assets.mjs +5 -0
- package/mcp/engine-server.mjs +253 -0
- package/mcp/engine.mjs +23 -0
- package/mcp/install.mjs +77 -0
- package/mcp/launch.mjs +72 -0
- package/mcp/sculpt.mjs +5 -0
- package/mcp/servers.mjs +59 -0
- package/package.json +49 -6
- package/patches/three@0.185.1.patch +287 -0
- package/scripts/apply-three-patch.mjs +297 -0
- package/scripts/bundle-engine-mcp.mjs +15 -0
- package/scripts/ensure-mcp.mjs +36 -0
- package/scripts/generate-version.mjs +13 -0
- package/scripts/postinstall.mjs +6 -0
- package/dist/audio-3vkjtiuo.d.ts +0 -35
- package/dist/game-DRPs3M7r.d.ts +0 -269
package/dist/playtest.js
CHANGED
|
@@ -1,463 +1,52 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { assertJsonSafe, PLAYTEST_PROTOCOL_LIMITS } from '@threenative/playtest/protocol';
|
|
2
|
+
import { installThreePlaytestBridge } from '@threenative/playtest/three';
|
|
3
|
+
import { Object3D } from 'three';
|
|
2
4
|
|
|
3
|
-
//
|
|
4
|
-
var PLAYTEST_CAPABILITY_REGISTRY = [
|
|
5
|
-
capability("browser.canvas", "Samples canvas pixels and health."),
|
|
6
|
-
capability("browser.console", "Captures browser console and page errors."),
|
|
7
|
-
capability("browser.dom", "Reads bounded DOM observations."),
|
|
8
|
-
capability("browser.input", "Delivers keyboard and pointer input."),
|
|
9
|
-
capability("browser.network", "Captures failed browser requests."),
|
|
10
|
-
capability("browser.screenshot", "Captures viewport screenshots."),
|
|
11
|
-
capability("browser.trace", "Captures a bounded Playwright trace."),
|
|
12
|
-
capability("camera.observe", "Samples the active camera transform and projection."),
|
|
13
|
-
capability("entity.bounds", "Projects registered entity bounds into the viewport."),
|
|
14
|
-
capability("entity.observe", "Samples registered entity transforms and visibility."),
|
|
15
|
-
capability("entity.setup", "Applies bounded transforms to registered entities."),
|
|
16
|
-
capability("runtime.animation", "Samples application-owned animation state."),
|
|
17
|
-
capability("runtime.audio", "Samples application-owned audio runtime state."),
|
|
18
|
-
capability("runtime.components", "Samples registered JSON-safe component state."),
|
|
19
|
-
capability("runtime.contacts", "Samples bounded collision and trigger contacts."),
|
|
20
|
-
capability("runtime.diagnostics", "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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 };
|