@threenative/core 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/capabilities.json +5292 -0
- package/dist/assets-kyoF7JlJ.d.ts +103 -0
- package/dist/audio-BFiGneTL.d.ts +156 -0
- package/dist/canvas-layer-BLVijiUJ.d.ts +62 -0
- package/dist/game-XGrTzapq.d.ts +1166 -0
- package/dist/gpu-readback-D2iRvoe9.d.ts +112 -0
- package/dist/hot.d.ts +20 -2
- package/dist/hot.js +14 -2
- package/dist/index.d.ts +2108 -55
- package/dist/index.js +15645 -2222
- package/dist/net.d.ts +65 -0
- package/dist/net.js +643 -0
- package/dist/playtest.d.ts +37 -4
- package/dist/playtest.js +246 -546
- package/dist/react.d.ts +177 -0
- package/dist/react.js +635 -0
- package/dist/renderer-C6hqZpoG.d.ts +770 -0
- package/dist/ui-layer.d.ts +306 -0
- package/dist/ui-layer.js +425 -0
- package/dist/world.d.ts +254 -0
- package/dist/world.js +2686 -0
- package/gpl/LICENSE.GPL +117 -0
- package/gpl/convert.py +192 -0
- package/gpl/recipes/_common.py +169 -0
- package/gpl/recipes/bake_ao.py +111 -0
- package/gpl/recipes/decimate.py +64 -0
- package/gpl/recipes/retarget.py +131 -0
- package/gpl/recipes/unwrap.py +71 -0
- package/mcp/assets.mjs +5 -0
- package/mcp/blender-server.mjs +632 -0
- package/mcp/blender.mjs +27 -0
- package/mcp/engine-server.mjs +501 -0
- package/mcp/engine.mjs +31 -0
- package/mcp/install.d.mts +37 -0
- package/mcp/install.mjs +145 -0
- package/mcp/launch.mjs +72 -0
- package/mcp/sculpt.mjs +5 -0
- package/mcp/servers.d.mts +34 -0
- package/mcp/servers.mjs +160 -0
- package/package.json +76 -6
- package/patches/three@0.185.1.patch +522 -0
- package/scripts/apply-three-patch.mjs +297 -0
- package/scripts/ensure-mcp.mjs +43 -0
- package/scripts/postinstall.mjs +6 -0
- package/dist/audio-CEAw0w5y.d.ts +0 -35
- package/dist/game-DRt1Qhq3.d.ts +0 -429
package/dist/net.js
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
// src/net-protocol.ts
|
|
2
|
+
var WIRE_VERSION = 1;
|
|
3
|
+
var KIND_HELLO = 1;
|
|
4
|
+
var KIND_WELCOME = 2;
|
|
5
|
+
var KIND_BIND = 3;
|
|
6
|
+
var KIND_BOUND = 4;
|
|
7
|
+
var KIND_DATA = 16;
|
|
8
|
+
var HEADER_BYTES = 8;
|
|
9
|
+
var MAX_HELLO_BYTES = 4096;
|
|
10
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
11
|
+
var DEFAULT_MAX_RELIABLE_MESSAGE_BYTES = 65536;
|
|
12
|
+
var DEFAULT_MAX_QUEUED_RELIABLE_BYTES = 1048576;
|
|
13
|
+
var DEFAULT_MAX_QUEUED_DATAGRAMS = 256;
|
|
14
|
+
function invalidArgument(message) {
|
|
15
|
+
return new Error(`TN_NET_INVALID_ARGUMENT: ${message}`);
|
|
16
|
+
}
|
|
17
|
+
function isPositiveSafeInteger(value) {
|
|
18
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
19
|
+
}
|
|
20
|
+
function normalizeOptions(url, options) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = new URL(url);
|
|
24
|
+
} catch {
|
|
25
|
+
throw invalidArgument("url must be a valid URL");
|
|
26
|
+
}
|
|
27
|
+
if (parsed.protocol !== "https:") throw invalidArgument("url must use https");
|
|
28
|
+
if (options === null || typeof options !== "object")
|
|
29
|
+
throw invalidArgument("options are required");
|
|
30
|
+
if (typeof options.applicationProtocol !== "string")
|
|
31
|
+
throw invalidArgument("applicationProtocol is required");
|
|
32
|
+
if (options.applicationProtocol.length < 1 || options.applicationProtocol.length > 64 || !/^[\x20-\x7e]+$/u.test(options.applicationProtocol))
|
|
33
|
+
throw invalidArgument("applicationProtocol must be 1-64 printable ASCII characters");
|
|
34
|
+
if (typeof options.credential !== "string" || options.credential.length === 0)
|
|
35
|
+
throw invalidArgument("credential must be nonempty");
|
|
36
|
+
if (!Array.isArray(options.channels) || options.channels.length < 1 || options.channels.length > 32)
|
|
37
|
+
throw invalidArgument("channels must list 1-32 entries");
|
|
38
|
+
const seen = /* @__PURE__ */ new Set();
|
|
39
|
+
const channels = options.channels.map((channel) => {
|
|
40
|
+
if (channel === null || typeof channel !== "object")
|
|
41
|
+
throw invalidArgument("channel entries are required");
|
|
42
|
+
if (!Number.isInteger(channel.id) || channel.id < 1 || channel.id > 65535)
|
|
43
|
+
throw invalidArgument("channel ids must be integers 1-65535");
|
|
44
|
+
if (channel.delivery !== "unreliable" && channel.delivery !== "reliable-ordered")
|
|
45
|
+
throw invalidArgument("channel delivery is unknown");
|
|
46
|
+
if (seen.has(channel.id)) throw invalidArgument("channel ids must be unique");
|
|
47
|
+
seen.add(channel.id);
|
|
48
|
+
return { id: channel.id, delivery: channel.delivery };
|
|
49
|
+
});
|
|
50
|
+
if (options.signal !== void 0 && !(options.signal instanceof AbortSignal))
|
|
51
|
+
throw invalidArgument("signal must be an AbortSignal");
|
|
52
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
53
|
+
const maxReliableMessageBytes = options.maxReliableMessageBytes ?? DEFAULT_MAX_RELIABLE_MESSAGE_BYTES;
|
|
54
|
+
const maxQueuedReliableBytes = options.maxQueuedReliableBytes ?? DEFAULT_MAX_QUEUED_RELIABLE_BYTES;
|
|
55
|
+
const maxQueuedDatagrams = options.maxQueuedDatagrams ?? DEFAULT_MAX_QUEUED_DATAGRAMS;
|
|
56
|
+
if (!isPositiveSafeInteger(connectTimeoutMs))
|
|
57
|
+
throw invalidArgument("connectTimeoutMs must be positive");
|
|
58
|
+
if (!isPositiveSafeInteger(maxReliableMessageBytes))
|
|
59
|
+
throw invalidArgument("maxReliableMessageBytes must be positive");
|
|
60
|
+
if (!isPositiveSafeInteger(maxQueuedReliableBytes))
|
|
61
|
+
throw invalidArgument("maxQueuedReliableBytes must be positive");
|
|
62
|
+
if (!isPositiveSafeInteger(maxQueuedDatagrams))
|
|
63
|
+
throw invalidArgument("maxQueuedDatagrams must be positive");
|
|
64
|
+
if (maxReliableMessageBytes > maxQueuedReliableBytes)
|
|
65
|
+
throw invalidArgument("message limit cannot exceed queue limit");
|
|
66
|
+
return {
|
|
67
|
+
applicationProtocol: options.applicationProtocol,
|
|
68
|
+
credential: options.credential,
|
|
69
|
+
channels,
|
|
70
|
+
signal: options.signal,
|
|
71
|
+
connectTimeoutMs,
|
|
72
|
+
maxReliableMessageBytes,
|
|
73
|
+
maxQueuedReliableBytes,
|
|
74
|
+
maxQueuedDatagrams
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function encodeFrame(kind, channel, payload) {
|
|
78
|
+
const out = new Uint8Array(HEADER_BYTES + payload.length);
|
|
79
|
+
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
|
80
|
+
out[0] = WIRE_VERSION;
|
|
81
|
+
out[1] = kind;
|
|
82
|
+
view.setUint16(2, channel);
|
|
83
|
+
view.setUint32(4, payload.length);
|
|
84
|
+
out.set(payload, HEADER_BYTES);
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function checkFrameHead(version, kind, channel) {
|
|
88
|
+
if (version !== WIRE_VERSION) throw new Error("TN_NET_PROTOCOL: unknown wire version");
|
|
89
|
+
if (kind !== KIND_HELLO && kind !== KIND_WELCOME && kind !== KIND_BIND && kind !== KIND_BOUND && kind !== KIND_DATA)
|
|
90
|
+
throw new Error("TN_NET_PROTOCOL: unknown frame kind");
|
|
91
|
+
if (kind === KIND_DATA && channel === 0) throw new Error("TN_NET_PROTOCOL: DATA on channel 0");
|
|
92
|
+
}
|
|
93
|
+
function decodeStandaloneFrame(bytes) {
|
|
94
|
+
if (bytes.length < HEADER_BYTES) throw new Error("TN_NET_PROTOCOL: truncated header");
|
|
95
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
96
|
+
const version = bytes[0] ?? 0;
|
|
97
|
+
const kind = bytes[1] ?? 0;
|
|
98
|
+
const channel = view.getUint16(2);
|
|
99
|
+
const length = view.getUint32(4);
|
|
100
|
+
checkFrameHead(version, kind, channel);
|
|
101
|
+
if (bytes.length !== HEADER_BYTES + length)
|
|
102
|
+
throw new Error("TN_NET_PROTOCOL: bad datagram length");
|
|
103
|
+
return { kind, channel, payload: bytes.slice(HEADER_BYTES) };
|
|
104
|
+
}
|
|
105
|
+
function readStreamFrame(buffer, limit) {
|
|
106
|
+
if (buffer.length < HEADER_BYTES) return void 0;
|
|
107
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
108
|
+
const version = buffer[0] ?? 0;
|
|
109
|
+
const kind = buffer[1] ?? 0;
|
|
110
|
+
const channel = view.getUint16(2);
|
|
111
|
+
const length = view.getUint32(4);
|
|
112
|
+
checkFrameHead(version, kind, channel);
|
|
113
|
+
if (length > limit) throw new Error("TN_NET_PROTOCOL: frame exceeds limit");
|
|
114
|
+
const frameBytes = HEADER_BYTES + length;
|
|
115
|
+
if (frameBytes > buffer.length) return void 0;
|
|
116
|
+
return {
|
|
117
|
+
frame: { kind, channel, payload: buffer.slice(HEADER_BYTES, frameBytes) },
|
|
118
|
+
rest: buffer.subarray(frameBytes)
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function webTransportConstructor() {
|
|
122
|
+
const value = globalThis.WebTransport;
|
|
123
|
+
if (typeof value !== "function") return void 0;
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
function parseWelcome(payload, expectedProtocol) {
|
|
127
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(payload);
|
|
128
|
+
const parsed = JSON.parse(text);
|
|
129
|
+
if (Object.keys(parsed).sort().join(",") !== "applicationProtocol,channels,maxQueuedDatagrams,maxQueuedReliableBytes,maxReliableMessageBytes,sessionId")
|
|
130
|
+
throw new Error("TN_NET_PROTOCOL: WELCOME has unexpected keys");
|
|
131
|
+
if (parsed.applicationProtocol !== expectedProtocol)
|
|
132
|
+
throw new Error("TN_NET_PROTOCOL: WELCOME protocol mismatch");
|
|
133
|
+
if (typeof parsed.sessionId !== "string" || !/^[0-9a-f]{32}$/u.test(parsed.sessionId))
|
|
134
|
+
throw new Error("TN_NET_PROTOCOL: WELCOME session id is invalid");
|
|
135
|
+
if (!Array.isArray(parsed.channels))
|
|
136
|
+
throw new Error("TN_NET_PROTOCOL: WELCOME channels are invalid");
|
|
137
|
+
const channels = parsed.channels.map((entry) => {
|
|
138
|
+
if (entry === null || typeof entry !== "object")
|
|
139
|
+
throw new Error("TN_NET_PROTOCOL: bad channel");
|
|
140
|
+
const record = entry;
|
|
141
|
+
if (Object.keys(record).sort().join(",") !== "delivery,id")
|
|
142
|
+
throw new Error("TN_NET_PROTOCOL: bad channel shape");
|
|
143
|
+
if (!Number.isInteger(record.id) || record.id < 1 || record.id > 65535)
|
|
144
|
+
throw new Error("TN_NET_PROTOCOL: bad channel id");
|
|
145
|
+
if (record.delivery !== "unreliable" && record.delivery !== "reliable-ordered")
|
|
146
|
+
throw new Error("TN_NET_PROTOCOL: bad delivery");
|
|
147
|
+
return {
|
|
148
|
+
id: record.id,
|
|
149
|
+
delivery: record.delivery
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
for (const key of [
|
|
153
|
+
"maxReliableMessageBytes",
|
|
154
|
+
"maxQueuedReliableBytes",
|
|
155
|
+
"maxQueuedDatagrams"
|
|
156
|
+
]) {
|
|
157
|
+
const value = parsed[key];
|
|
158
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
|
|
159
|
+
throw new Error("TN_NET_PROTOCOL: bad WELCOME limit");
|
|
160
|
+
}
|
|
161
|
+
const messageLimit = parsed.maxReliableMessageBytes;
|
|
162
|
+
const queueLimit = parsed.maxQueuedReliableBytes;
|
|
163
|
+
if (messageLimit > queueLimit) throw new Error("TN_NET_PROTOCOL: invalid negotiated limits");
|
|
164
|
+
return {
|
|
165
|
+
sessionId: parsed.sessionId,
|
|
166
|
+
channels,
|
|
167
|
+
maxReliableMessageBytes: messageLimit,
|
|
168
|
+
maxQueuedReliableBytes: queueLimit,
|
|
169
|
+
maxQueuedDatagrams: parsed.maxQueuedDatagrams
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async function writeAll(writable, bytes) {
|
|
173
|
+
const writer = writable.getWriter();
|
|
174
|
+
try {
|
|
175
|
+
await writer.write(bytes);
|
|
176
|
+
} finally {
|
|
177
|
+
writer.releaseLock();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function concatBytes(left, right) {
|
|
181
|
+
const out = new Uint8Array(left.length + right.length);
|
|
182
|
+
out.set(left, 0);
|
|
183
|
+
out.set(right, left.length);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
function asBytes(chunk) {
|
|
187
|
+
if (chunk instanceof Uint8Array) return Uint8Array.from(chunk);
|
|
188
|
+
if (chunk instanceof ArrayBuffer) return Uint8Array.from(new Uint8Array(chunk));
|
|
189
|
+
return Uint8Array.from(new Uint8Array(chunk));
|
|
190
|
+
}
|
|
191
|
+
var FrameReader = class {
|
|
192
|
+
#reader;
|
|
193
|
+
#buffer = new Uint8Array(0);
|
|
194
|
+
#limit;
|
|
195
|
+
#released = false;
|
|
196
|
+
constructor(readable, limit) {
|
|
197
|
+
this.#reader = readable.getReader();
|
|
198
|
+
this.#limit = limit;
|
|
199
|
+
}
|
|
200
|
+
async nextFrame(signal) {
|
|
201
|
+
for (; ; ) {
|
|
202
|
+
if (signal?.aborted) throw new Error("TN_NET_CANCELLED: aborted");
|
|
203
|
+
const decoded = readStreamFrame(this.#buffer, this.#limit);
|
|
204
|
+
if (decoded !== void 0) {
|
|
205
|
+
this.#buffer = decoded.rest.length === 0 ? new Uint8Array(0) : decoded.rest;
|
|
206
|
+
return decoded.frame;
|
|
207
|
+
}
|
|
208
|
+
if (this.#buffer.length > this.#limit + HEADER_BYTES)
|
|
209
|
+
throw new Error("TN_NET_PROTOCOL: frame exceeds limit");
|
|
210
|
+
const abort = new Promise((_, reject) => {
|
|
211
|
+
signal?.addEventListener("abort", () => reject(new Error("TN_NET_CANCELLED: aborted")), {
|
|
212
|
+
once: true
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
const read = signal === void 0 ? await this.#reader.read() : await Promise.race([this.#reader.read(), abort]);
|
|
216
|
+
if (read.done) throw new Error("TN_NET_PROTOCOL: stream ended");
|
|
217
|
+
const chunk = asBytes(read.value);
|
|
218
|
+
this.#buffer = this.#buffer.length === 0 ? chunk : concatBytes(this.#buffer, chunk);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
release() {
|
|
222
|
+
if (this.#released) return;
|
|
223
|
+
this.#released = true;
|
|
224
|
+
try {
|
|
225
|
+
this.#reader.releaseLock();
|
|
226
|
+
} catch {
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
cancel(reason) {
|
|
230
|
+
try {
|
|
231
|
+
return this.#reader.cancel(reason);
|
|
232
|
+
} catch {
|
|
233
|
+
return Promise.resolve();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// src/net-session.ts
|
|
239
|
+
async function openConnection(url, normalized) {
|
|
240
|
+
if (normalized.signal?.aborted) throw new Error("TN_NET_CANCELLED: aborted before connect");
|
|
241
|
+
const Constructor = webTransportConstructor();
|
|
242
|
+
if (Constructor === void 0) throw new Error("TN_NET_UNAVAILABLE: WebTransport is missing");
|
|
243
|
+
const transport = new Constructor(url, {});
|
|
244
|
+
let timeoutId;
|
|
245
|
+
const onAbort = () => {
|
|
246
|
+
try {
|
|
247
|
+
transport.close();
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
normalized.signal?.addEventListener("abort", onAbort, { once: true });
|
|
252
|
+
const deadline = new Promise((_, reject) => {
|
|
253
|
+
timeoutId = setTimeout(
|
|
254
|
+
() => reject(new Error("TN_NET_TIMEOUT: connect timed out")),
|
|
255
|
+
normalized.connectTimeoutMs
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
const cancelled = new Promise((_, reject) => {
|
|
259
|
+
if (normalized.signal === void 0) return;
|
|
260
|
+
if (normalized.signal.aborted) reject(new Error("TN_NET_CANCELLED: aborted"));
|
|
261
|
+
else
|
|
262
|
+
normalized.signal.addEventListener(
|
|
263
|
+
"abort",
|
|
264
|
+
() => reject(new Error("TN_NET_CANCELLED: aborted")),
|
|
265
|
+
{ once: true }
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
try {
|
|
269
|
+
await Promise.race([Promise.resolve(transport.ready), deadline, cancelled]);
|
|
270
|
+
} finally {
|
|
271
|
+
if (timeoutId !== void 0) clearTimeout(timeoutId);
|
|
272
|
+
normalized.signal?.removeEventListener("abort", onAbort);
|
|
273
|
+
}
|
|
274
|
+
if (normalized.signal?.aborted) throw new Error("TN_NET_CANCELLED: aborted");
|
|
275
|
+
return establishSession(transport, normalized);
|
|
276
|
+
}
|
|
277
|
+
async function establishSession(transport, normalized) {
|
|
278
|
+
const abort = new AbortController();
|
|
279
|
+
const timer = setTimeout(() => abort.abort(), normalized.connectTimeoutMs);
|
|
280
|
+
const readers = [];
|
|
281
|
+
const writers = [];
|
|
282
|
+
try {
|
|
283
|
+
const controlStream = await Promise.resolve(transport.createBidirectionalStream());
|
|
284
|
+
writers.push(controlStream.writable);
|
|
285
|
+
const controlReader = new FrameReader(controlStream.readable, MAX_HELLO_BYTES);
|
|
286
|
+
readers.push(controlReader);
|
|
287
|
+
const helloPayload = new TextEncoder().encode(
|
|
288
|
+
JSON.stringify({
|
|
289
|
+
applicationProtocol: normalized.applicationProtocol,
|
|
290
|
+
credential: normalized.credential,
|
|
291
|
+
channels: normalized.channels.map((channel) => ({
|
|
292
|
+
id: channel.id,
|
|
293
|
+
delivery: channel.delivery
|
|
294
|
+
})),
|
|
295
|
+
maxReliableMessageBytes: normalized.maxReliableMessageBytes,
|
|
296
|
+
maxQueuedReliableBytes: normalized.maxQueuedReliableBytes,
|
|
297
|
+
maxQueuedDatagrams: normalized.maxQueuedDatagrams
|
|
298
|
+
})
|
|
299
|
+
);
|
|
300
|
+
if (helloPayload.length > MAX_HELLO_BYTES)
|
|
301
|
+
throw invalidArgument("handshake payload exceeds 4096 bytes");
|
|
302
|
+
await writeAll(controlStream.writable, encodeFrame(KIND_HELLO, 0, helloPayload));
|
|
303
|
+
const welcomeFrame = await controlReader.nextFrame(abort.signal);
|
|
304
|
+
if (welcomeFrame.kind !== KIND_WELCOME || welcomeFrame.channel !== 0)
|
|
305
|
+
throw new Error("TN_NET_PROTOCOL: expected WELCOME");
|
|
306
|
+
const welcome = parseWelcome(welcomeFrame.payload, normalized.applicationProtocol);
|
|
307
|
+
const expected = [...normalized.channels].sort((left, right) => left.id - right.id);
|
|
308
|
+
const offered = [...welcome.channels].sort((left, right) => left.id - right.id);
|
|
309
|
+
if (JSON.stringify(expected) !== JSON.stringify(offered))
|
|
310
|
+
throw new Error("TN_NET_PROTOCOL: WELCOME channels mismatch");
|
|
311
|
+
const messageLimit = Math.min(
|
|
312
|
+
normalized.maxReliableMessageBytes,
|
|
313
|
+
welcome.maxReliableMessageBytes
|
|
314
|
+
);
|
|
315
|
+
const queueLimit = Math.min(normalized.maxQueuedReliableBytes, welcome.maxQueuedReliableBytes);
|
|
316
|
+
const datagramLimit = Math.min(normalized.maxQueuedDatagrams, welcome.maxQueuedDatagrams);
|
|
317
|
+
if (messageLimit > queueLimit) throw new Error("TN_NET_PROTOCOL: invalid negotiated limits");
|
|
318
|
+
const maxDatagramSize = transport.datagrams.maxDatagramSize;
|
|
319
|
+
const maxDatagramPayload = typeof maxDatagramSize === "number" && Number.isFinite(maxDatagramSize) ? maxDatagramSize - HEADER_BYTES : Number.NaN;
|
|
320
|
+
if (!Number.isSafeInteger(maxDatagramPayload) || maxDatagramPayload <= 0)
|
|
321
|
+
throw new Error("TN_NET_UNAVAILABLE: datagram support is missing");
|
|
322
|
+
const reliableIds = expected.filter((channel) => channel.delivery === "reliable-ordered").map((channel) => channel.id);
|
|
323
|
+
const reliableReaders = /* @__PURE__ */ new Map();
|
|
324
|
+
const reliableWriters = /* @__PURE__ */ new Map();
|
|
325
|
+
for (const channelId of reliableIds) {
|
|
326
|
+
const stream = await Promise.resolve(transport.createBidirectionalStream());
|
|
327
|
+
writers.push(stream.writable);
|
|
328
|
+
const reader = new FrameReader(stream.readable, messageLimit);
|
|
329
|
+
readers.push(reader);
|
|
330
|
+
await writeAll(stream.writable, encodeFrame(KIND_BIND, channelId, new Uint8Array(0)));
|
|
331
|
+
const bound = await reader.nextFrame(abort.signal);
|
|
332
|
+
if (bound.kind !== KIND_BOUND || bound.channel !== channelId)
|
|
333
|
+
throw new Error("TN_NET_PROTOCOL: expected BOUND");
|
|
334
|
+
if (bound.payload.length !== 0) throw new Error("TN_NET_PROTOCOL: BOUND carries payload");
|
|
335
|
+
reliableReaders.set(channelId, reader);
|
|
336
|
+
reliableWriters.set(channelId, stream.writable);
|
|
337
|
+
}
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
return createConnection({
|
|
340
|
+
transport,
|
|
341
|
+
channelById: new Map(normalized.channels.map((channel) => [channel.id, channel.delivery])),
|
|
342
|
+
sessionId: welcome.sessionId,
|
|
343
|
+
maxDatagramPayload,
|
|
344
|
+
maxReliableMessageBytes: messageLimit,
|
|
345
|
+
maxQueuedReliableBytes: queueLimit,
|
|
346
|
+
maxQueuedDatagrams: datagramLimit,
|
|
347
|
+
readers,
|
|
348
|
+
reliableReaders,
|
|
349
|
+
reliableWriters
|
|
350
|
+
});
|
|
351
|
+
} catch (error) {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
abort.abort();
|
|
354
|
+
for (const reader of readers) {
|
|
355
|
+
await reader.cancel(error);
|
|
356
|
+
reader.release();
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
transport.close();
|
|
360
|
+
} catch {
|
|
361
|
+
}
|
|
362
|
+
if (normalized.signal?.aborted) throw new Error("TN_NET_CANCELLED: aborted");
|
|
363
|
+
if (error instanceof Error && error.message.startsWith("TN_NET_")) throw error;
|
|
364
|
+
if (error instanceof Error) throw new Error(`TN_NET_PROTOCOL: ${error.message}`);
|
|
365
|
+
throw new Error("TN_NET_UNAVAILABLE: handshake failed");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function createConnection(established) {
|
|
369
|
+
let state = "connected";
|
|
370
|
+
let disconnectReason = null;
|
|
371
|
+
let disconnectReported = false;
|
|
372
|
+
let closePromise;
|
|
373
|
+
let releasePromise;
|
|
374
|
+
let droppedDatagrams = 0;
|
|
375
|
+
let queuedReliableBytes = 0;
|
|
376
|
+
let queuedReliableBytesReceived = 0;
|
|
377
|
+
const pendingReliable = /* @__PURE__ */ new Map();
|
|
378
|
+
const pendingDatagrams = [];
|
|
379
|
+
const receiveQueue = [];
|
|
380
|
+
let flushScheduled = false;
|
|
381
|
+
for (const channelId of established.reliableReaders.keys()) pendingReliable.set(channelId, []);
|
|
382
|
+
const statsBase = {
|
|
383
|
+
transport: "webtransport",
|
|
384
|
+
sessionId: established.sessionId,
|
|
385
|
+
maxDatagramPayload: established.maxDatagramPayload,
|
|
386
|
+
maxReliableMessageBytes: established.maxReliableMessageBytes,
|
|
387
|
+
maxQueuedReliableBytes: established.maxQueuedReliableBytes,
|
|
388
|
+
maxQueuedDatagrams: established.maxQueuedDatagrams
|
|
389
|
+
};
|
|
390
|
+
const markDisconnected = (reason) => {
|
|
391
|
+
if (state === "closed") return;
|
|
392
|
+
state = "closed";
|
|
393
|
+
disconnectReason = reason;
|
|
394
|
+
void releaseAll();
|
|
395
|
+
};
|
|
396
|
+
const closedReason = (result) => {
|
|
397
|
+
if (typeof result === "object" && result !== null && "reason" in result) {
|
|
398
|
+
const reason = result.reason;
|
|
399
|
+
if (reason === null || reason === void 0) return null;
|
|
400
|
+
if (typeof reason === "string" && reason.length > 0) return reason;
|
|
401
|
+
}
|
|
402
|
+
return "transport closed";
|
|
403
|
+
};
|
|
404
|
+
void Promise.resolve(established.transport.closed).then(
|
|
405
|
+
(result) => markDisconnected(closedReason(result)),
|
|
406
|
+
() => markDisconnected("transport error")
|
|
407
|
+
);
|
|
408
|
+
const enqueueReceived = (channel, data) => {
|
|
409
|
+
const delivery = established.channelById.get(channel);
|
|
410
|
+
if (delivery === "reliable-ordered") {
|
|
411
|
+
if (queuedReliableBytesReceived + data.length > established.maxQueuedReliableBytes) return;
|
|
412
|
+
queuedReliableBytesReceived += data.length;
|
|
413
|
+
receiveQueue.push({ channel, data });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (delivery !== "unreliable") {
|
|
417
|
+
droppedDatagrams += 1;
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const unreliableCount = receiveQueue.filter(
|
|
421
|
+
(message) => established.channelById.get(message.channel) === "unreliable"
|
|
422
|
+
).length;
|
|
423
|
+
if (unreliableCount >= established.maxQueuedDatagrams) {
|
|
424
|
+
const index = receiveQueue.findIndex(
|
|
425
|
+
(message) => established.channelById.get(message.channel) === "unreliable"
|
|
426
|
+
);
|
|
427
|
+
if (index >= 0) receiveQueue.splice(index, 1);
|
|
428
|
+
droppedDatagrams += 1;
|
|
429
|
+
}
|
|
430
|
+
receiveQueue.push({ channel, data });
|
|
431
|
+
};
|
|
432
|
+
const pumpReliable = (channelId, reader) => {
|
|
433
|
+
void (async () => {
|
|
434
|
+
try {
|
|
435
|
+
for (; ; ) {
|
|
436
|
+
if (state !== "connected") return;
|
|
437
|
+
if (queuedReliableBytesReceived >= established.maxQueuedReliableBytes) {
|
|
438
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const frame = await reader.nextFrame();
|
|
442
|
+
if (frame.kind !== KIND_DATA || frame.channel !== channelId)
|
|
443
|
+
throw new Error("TN_NET_PROTOCOL: wrong stream frame");
|
|
444
|
+
enqueueReceived(frame.channel, frame.payload.slice());
|
|
445
|
+
}
|
|
446
|
+
} catch {
|
|
447
|
+
if (state === "connected") markDisconnected("transport error");
|
|
448
|
+
}
|
|
449
|
+
})();
|
|
450
|
+
};
|
|
451
|
+
let datagramReader;
|
|
452
|
+
const pumpDatagrams = () => {
|
|
453
|
+
const reader = established.transport.datagrams.readable.getReader();
|
|
454
|
+
datagramReader = reader;
|
|
455
|
+
void (async () => {
|
|
456
|
+
try {
|
|
457
|
+
for (; ; ) {
|
|
458
|
+
if (state !== "connected") {
|
|
459
|
+
try {
|
|
460
|
+
reader.releaseLock();
|
|
461
|
+
} catch {
|
|
462
|
+
}
|
|
463
|
+
datagramReader = void 0;
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const read = await reader.read();
|
|
467
|
+
if (read.done) {
|
|
468
|
+
markDisconnected("transport closed");
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
let frame;
|
|
472
|
+
try {
|
|
473
|
+
frame = decodeStandaloneFrame(asBytes(read.value).slice());
|
|
474
|
+
} catch {
|
|
475
|
+
droppedDatagrams += 1;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (frame.kind !== KIND_DATA || established.channelById.get(frame.channel) !== "unreliable") {
|
|
479
|
+
droppedDatagrams += 1;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
enqueueReceived(frame.channel, frame.payload.slice());
|
|
483
|
+
}
|
|
484
|
+
} catch {
|
|
485
|
+
if (state === "connected") markDisconnected("transport error");
|
|
486
|
+
}
|
|
487
|
+
})();
|
|
488
|
+
};
|
|
489
|
+
for (const [channelId, reader] of established.reliableReaders) pumpReliable(channelId, reader);
|
|
490
|
+
pumpDatagrams();
|
|
491
|
+
const flushQueues = () => {
|
|
492
|
+
if (flushScheduled) return;
|
|
493
|
+
flushScheduled = true;
|
|
494
|
+
void Promise.resolve().then(async () => {
|
|
495
|
+
try {
|
|
496
|
+
if (state !== "connected") return;
|
|
497
|
+
while (pendingDatagrams.length > 0) {
|
|
498
|
+
if (state !== "connected") return;
|
|
499
|
+
const next = pendingDatagrams.shift();
|
|
500
|
+
if (next === void 0) return;
|
|
501
|
+
try {
|
|
502
|
+
await writeAll(
|
|
503
|
+
established.transport.datagrams.writable,
|
|
504
|
+
encodeFrame(KIND_DATA, next.channel, next.bytes)
|
|
505
|
+
);
|
|
506
|
+
} catch {
|
|
507
|
+
markDisconnected("transport error");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
for (const [channelId, queue] of pendingReliable) {
|
|
512
|
+
while (queue.length > 0) {
|
|
513
|
+
if (state !== "connected") return;
|
|
514
|
+
const writer = established.reliableWriters.get(channelId);
|
|
515
|
+
const next = queue.shift();
|
|
516
|
+
if (writer === void 0 || next === void 0) break;
|
|
517
|
+
try {
|
|
518
|
+
await writeAll(writer, encodeFrame(KIND_DATA, channelId, next));
|
|
519
|
+
queuedReliableBytes -= next.length;
|
|
520
|
+
} catch {
|
|
521
|
+
markDisconnected("transport error");
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
} finally {
|
|
527
|
+
flushScheduled = false;
|
|
528
|
+
if (state === "connected" && (pendingDatagrams.length > 0 || [...pendingReliable.values()].some((queue) => queue.length > 0)))
|
|
529
|
+
flushQueues();
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
};
|
|
533
|
+
async function releaseAll() {
|
|
534
|
+
if (releasePromise !== void 0) return releasePromise;
|
|
535
|
+
releasePromise = (async () => {
|
|
536
|
+
for (const reader of established.readers) {
|
|
537
|
+
try {
|
|
538
|
+
await reader.cancel(new Error("TN_NET_CLOSED: connection closed"));
|
|
539
|
+
} catch {
|
|
540
|
+
}
|
|
541
|
+
reader.release();
|
|
542
|
+
}
|
|
543
|
+
if (datagramReader !== void 0) {
|
|
544
|
+
try {
|
|
545
|
+
await datagramReader.cancel(new Error("TN_NET_CLOSED: connection closed"));
|
|
546
|
+
} catch {
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
datagramReader.releaseLock();
|
|
550
|
+
} catch {
|
|
551
|
+
}
|
|
552
|
+
datagramReader = void 0;
|
|
553
|
+
} else {
|
|
554
|
+
try {
|
|
555
|
+
await established.transport.datagrams.readable.cancel(
|
|
556
|
+
new Error("TN_NET_CLOSED: connection closed")
|
|
557
|
+
);
|
|
558
|
+
} catch {
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
established.transport.close();
|
|
563
|
+
} catch {
|
|
564
|
+
}
|
|
565
|
+
})();
|
|
566
|
+
return releasePromise;
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
send(channel, data) {
|
|
570
|
+
const delivery = established.channelById.get(channel);
|
|
571
|
+
if (delivery === void 0) throw new Error("TN_NET_INVALID_ARGUMENT: unknown channel");
|
|
572
|
+
if (!(data instanceof Uint8Array))
|
|
573
|
+
throw new Error("TN_NET_INVALID_ARGUMENT: data must be bytes");
|
|
574
|
+
if (state !== "connected") throw new Error("TN_NET_CLOSED: connection is closed");
|
|
575
|
+
const bytes = Uint8Array.from(data);
|
|
576
|
+
if (delivery === "reliable-ordered") {
|
|
577
|
+
if (bytes.length > established.maxReliableMessageBytes)
|
|
578
|
+
throw new Error("TN_NET_MESSAGE_TOO_LARGE: reliable message exceeds limit");
|
|
579
|
+
if (queuedReliableBytes + bytes.length > established.maxQueuedReliableBytes) return false;
|
|
580
|
+
pendingReliable.get(channel)?.push(bytes);
|
|
581
|
+
queuedReliableBytes += bytes.length;
|
|
582
|
+
flushQueues();
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
const payloadLimit = established.maxDatagramPayload;
|
|
586
|
+
if (bytes.length > payloadLimit)
|
|
587
|
+
throw new Error("TN_NET_MESSAGE_TOO_LARGE: datagram exceeds limit");
|
|
588
|
+
if (pendingDatagrams.length >= established.maxQueuedDatagrams) {
|
|
589
|
+
pendingDatagrams.shift();
|
|
590
|
+
droppedDatagrams += 1;
|
|
591
|
+
}
|
|
592
|
+
pendingDatagrams.push({ channel, bytes });
|
|
593
|
+
flushQueues();
|
|
594
|
+
return true;
|
|
595
|
+
},
|
|
596
|
+
poll() {
|
|
597
|
+
const room = established.maxQueuedDatagrams * 2;
|
|
598
|
+
const messages = receiveQueue.splice(0, Math.max(0, room));
|
|
599
|
+
queuedReliableBytesReceived = receiveQueue.filter((message) => established.channelById.get(message.channel) === "reliable-ordered").reduce((sum, message) => sum + message.data.length, 0);
|
|
600
|
+
if (state === "closed" && !disconnectReported) {
|
|
601
|
+
disconnectReported = true;
|
|
602
|
+
return { messages, disconnected: true, reason: disconnectReason };
|
|
603
|
+
}
|
|
604
|
+
return { messages, disconnected: false, reason: null };
|
|
605
|
+
},
|
|
606
|
+
getStats() {
|
|
607
|
+
return {
|
|
608
|
+
...statsBase,
|
|
609
|
+
state,
|
|
610
|
+
queuedReliableBytes,
|
|
611
|
+
queuedDatagrams: pendingDatagrams.length,
|
|
612
|
+
droppedDatagrams,
|
|
613
|
+
rttMs: null
|
|
614
|
+
};
|
|
615
|
+
},
|
|
616
|
+
close() {
|
|
617
|
+
if (closePromise !== void 0) return closePromise;
|
|
618
|
+
if (state === "connected") state = "closing";
|
|
619
|
+
closePromise = (async () => {
|
|
620
|
+
receiveQueue.length = 0;
|
|
621
|
+
pendingDatagrams.length = 0;
|
|
622
|
+
for (const queue of pendingReliable.values()) queue.length = 0;
|
|
623
|
+
queuedReliableBytes = 0;
|
|
624
|
+
queuedReliableBytesReceived = 0;
|
|
625
|
+
await releaseAll();
|
|
626
|
+
state = "closed";
|
|
627
|
+
})();
|
|
628
|
+
return closePromise;
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/net.ts
|
|
634
|
+
function connect(url, options) {
|
|
635
|
+
try {
|
|
636
|
+
const normalized = normalizeOptions(url, options);
|
|
637
|
+
return openConnection(url, normalized);
|
|
638
|
+
} catch (error) {
|
|
639
|
+
return Promise.reject(error);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export { connect };
|
package/dist/playtest.d.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { JsonValue } from '@threenative/playtest';
|
|
2
|
-
import {
|
|
1
|
+
import { JsonValue, JsonPrimitive } from '@threenative/playtest/protocol';
|
|
2
|
+
import { b as IGamePluginHooks } from './game-XGrTzapq.js';
|
|
3
|
+
import './assets-kyoF7JlJ.js';
|
|
3
4
|
import 'three';
|
|
5
|
+
import './renderer-C6hqZpoG.js';
|
|
6
|
+
import 'three/webgpu';
|
|
7
|
+
import './canvas-layer-BLVijiUJ.js';
|
|
4
8
|
import 'zustand/vanilla';
|
|
5
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Install the playtest bridge into a portable game.
|
|
12
|
+
* @situation expose movement and world observations to a playtest
|
|
13
|
+
* @situation connect a game to the ThreeNative scenario runner
|
|
14
|
+
* @constraint install once in the game's plugin list
|
|
15
|
+
* @example const game = defineGame({ plugins: [playtest()] });
|
|
16
|
+
*/
|
|
6
17
|
declare function playtest<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined>(options?: IPlaytestOptions): IGamePluginHooks<TState, TPhysics>;
|
|
7
18
|
declare const PLAYTEST_ATTACH_TIMEOUT_MS = 30000;
|
|
8
19
|
/**
|
|
@@ -23,11 +34,33 @@ declare const PLAYTEST_RUNNER_EXPECTED_GLOBAL = "__THREENATIVE_PLAYTEST_RUNNER_E
|
|
|
23
34
|
interface IPlaytestOptions {
|
|
24
35
|
readonly events?: () => JsonValue[];
|
|
25
36
|
/**
|
|
26
|
-
* Hold
|
|
37
|
+
* Hold start-scene entry until a runner attaches. Defaults on for an announced runner.
|
|
27
38
|
*/
|
|
28
39
|
readonly holdUntilAttached?: boolean;
|
|
29
40
|
/** Milliseconds to wait when `holdUntilAttached` is set. Default {@link PLAYTEST_ATTACH_TIMEOUT_MS}. */
|
|
30
41
|
readonly attachTimeoutMs?: number;
|
|
31
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* When a published value changed, and to what — the other half of "because".
|
|
45
|
+
*
|
|
46
|
+
* A run has always been able to say *a contact happened* and *the state reads `won`*. Nothing
|
|
47
|
+
* related the two, and a scenario could only compare at step boundaries, where a win arriving one
|
|
48
|
+
* tick after the contact and a win arriving 199 ticks later inside the same step are the same
|
|
49
|
+
* observation. That is exactly the shape of a game whose terminal state comes from a timer or a
|
|
50
|
+
* distance check and merely happens to land near a contact.
|
|
51
|
+
*
|
|
52
|
+
* The watcher diffs the published surface once per tick and records every change with the tick it
|
|
53
|
+
* happened on. It watches what a scenario can name: each registered entity's `state`, and the
|
|
54
|
+
* top-level primitive fields of the game's own published state. A deeper path is not watched, and
|
|
55
|
+
* an assertion about one fails closed rather than being answered from a value nobody diffed.
|
|
56
|
+
*/
|
|
57
|
+
declare const PLAYTEST_TRANSITION_LOG_LIMIT = 512;
|
|
58
|
+
interface IPlaytestTransition {
|
|
59
|
+
from: JsonPrimitive;
|
|
60
|
+
/** `states.<entity>` for a registered entity, or `state.<field>` for a published field. */
|
|
61
|
+
path: string;
|
|
62
|
+
tick: number;
|
|
63
|
+
to: JsonPrimitive;
|
|
64
|
+
}
|
|
32
65
|
|
|
33
|
-
export { type IPlaytestOptions, PLAYTEST_ATTACH_TIMEOUT_MS, PLAYTEST_RUNNER_EXPECTED_GLOBAL, playtest };
|
|
66
|
+
export { type IPlaytestOptions, type IPlaytestTransition, PLAYTEST_ATTACH_TIMEOUT_MS, PLAYTEST_RUNNER_EXPECTED_GLOBAL, PLAYTEST_TRANSITION_LOG_LIMIT, playtest };
|