@series-inc/rundot-syncplay 5.26.0-beta.9 → 5.27.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/core/native/physics/physics.cpp +129 -3
- package/core/native/physics/physics.hpp +1 -1
- package/core/physics.d.ts +1 -0
- package/core/scripts/build-wasm-physics.mjs +1 -1
- package/core/src/physics/index.mjs +20 -9
- package/core/src/physics/physics-wasm-module.generated.mjs +4 -4
- package/dist/authority-room.d.ts +10 -0
- package/dist/authority-room.js +11 -0
- package/dist/browser.d.ts +5 -5
- package/dist/browser.js +3 -3
- package/dist/creator.d.ts +4 -4
- package/dist/creator.js +2 -2
- package/dist/dev-authority-room.js +8 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/input-authority.d.ts +8 -0
- package/dist/input-authority.js +13 -0
- package/dist/networked-client.d.ts +8 -0
- package/dist/networked-client.js +8 -0
- package/dist/node.d.ts +2 -0
- package/dist/node.js +1 -0
- package/dist/physics3d-voxel.d.ts +23 -1
- package/dist/physics3d-voxel.js +138 -0
- package/dist/physics3d.d.ts +26 -0
- package/dist/physics3d.js +187 -1
- package/dist/runtime-room-metadata.d.ts +47 -0
- package/dist/runtime-room-metadata.js +84 -0
- package/dist/session-snapshot.d.ts +24 -0
- package/dist/session-snapshot.js +24 -0
- package/package.json +4 -3
package/dist/physics3d.js
CHANGED
|
@@ -281,6 +281,104 @@ export function applyDeterministicWorldEdit3D(world, edit) {
|
|
|
281
281
|
: world.jointCache.filter((entry) => !removeJointIds.has(entry.jointId)),
|
|
282
282
|
};
|
|
283
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Fast batch update of kinematic body transforms and velocities without
|
|
286
|
+
* allocating Set instances or re-sorting world bodies. Wakes touching dynamic
|
|
287
|
+
* bodies across active contact pairs. If linear velocity (vx, vy, vz) is omitted
|
|
288
|
+
* and dtTicks (> 0) is supplied, linear velocity is derived as (newPos - oldPos) / dtTicks.
|
|
289
|
+
*/
|
|
290
|
+
export function setDeterministicKinematicPoses3D(world, poses, options = {}) {
|
|
291
|
+
if (poses.length === 0) {
|
|
292
|
+
return world;
|
|
293
|
+
}
|
|
294
|
+
const dt = options.dtTicks !== undefined && options.dtTicks > 0 ? options.dtTicks : undefined;
|
|
295
|
+
const poseById = new Map();
|
|
296
|
+
for (const pose of poses) {
|
|
297
|
+
if (!Number.isFinite(pose.x) || !Number.isFinite(pose.y) || !Number.isFinite(pose.z)) {
|
|
298
|
+
throw new Error(`setDeterministicKinematicPoses3D: body ${pose.id} position must be finite numbers`);
|
|
299
|
+
}
|
|
300
|
+
poseById.set(pose.id, pose);
|
|
301
|
+
}
|
|
302
|
+
const updatedIds = new Set();
|
|
303
|
+
const movedBodies = new Map();
|
|
304
|
+
const newBodies = world.bodies.map((body) => {
|
|
305
|
+
const pose = poseById.get(body.id);
|
|
306
|
+
if (pose === undefined) {
|
|
307
|
+
return body;
|
|
308
|
+
}
|
|
309
|
+
if (body.kind !== 'kinematic') {
|
|
310
|
+
throw new Error(`setDeterministicKinematicPoses3D: body ${body.id} is ${body.kind}, expected kinematic`);
|
|
311
|
+
}
|
|
312
|
+
updatedIds.add(body.id);
|
|
313
|
+
let vx = pose.vx;
|
|
314
|
+
let vy = pose.vy;
|
|
315
|
+
let vz = pose.vz;
|
|
316
|
+
if (vx === undefined || vy === undefined || vz === undefined) {
|
|
317
|
+
if (dt !== undefined) {
|
|
318
|
+
vx = vx ?? (pose.x - body.x) / dt;
|
|
319
|
+
vy = vy ?? (pose.y - body.y) / dt;
|
|
320
|
+
vz = vz ?? (pose.z - body.z) / dt;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
vx = vx ?? body.vx;
|
|
324
|
+
vy = vy ?? body.vy;
|
|
325
|
+
vz = vz ?? body.vz;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
let angularVel = body.angularVel;
|
|
329
|
+
if (pose.angularVelX !== undefined || pose.angularVelY !== undefined || pose.angularVelZ !== undefined) {
|
|
330
|
+
angularVel = {
|
|
331
|
+
x: pose.angularVelX ?? body.angularVel?.x ?? 0,
|
|
332
|
+
y: pose.angularVelY ?? body.angularVel?.y ?? 0,
|
|
333
|
+
z: pose.angularVelZ ?? body.angularVel?.z ?? 0,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const updated = {
|
|
337
|
+
...body,
|
|
338
|
+
x: pose.x,
|
|
339
|
+
y: pose.y,
|
|
340
|
+
z: pose.z,
|
|
341
|
+
vx,
|
|
342
|
+
vy,
|
|
343
|
+
vz,
|
|
344
|
+
rx: pose.rx ?? body.rx,
|
|
345
|
+
ry: pose.ry ?? body.ry,
|
|
346
|
+
rz: pose.rz ?? body.rz,
|
|
347
|
+
orientation: pose.orientation ?? body.orientation,
|
|
348
|
+
angularVel,
|
|
349
|
+
};
|
|
350
|
+
movedBodies.set(body.id, updated);
|
|
351
|
+
return updated;
|
|
352
|
+
});
|
|
353
|
+
for (const pose of poses) {
|
|
354
|
+
if (!updatedIds.has(pose.id)) {
|
|
355
|
+
throw new Error(`setDeterministicKinematicPoses3D: cannot update unknown kinematic body ${pose.id}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Wake any dynamic bodies that share active pairs with updated kinematics
|
|
359
|
+
const wokenDynamicIds = new Set();
|
|
360
|
+
for (const pair of world.activePairMetadata) {
|
|
361
|
+
if (movedBodies.has(pair.a)) {
|
|
362
|
+
for (const member of contactIslandFor(world, pair.b)) {
|
|
363
|
+
wokenDynamicIds.add(member);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
else if (movedBodies.has(pair.b)) {
|
|
367
|
+
for (const member of contactIslandFor(world, pair.a)) {
|
|
368
|
+
wokenDynamicIds.add(member);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const finalBodies = wokenDynamicIds.size === 0
|
|
373
|
+
? newBodies
|
|
374
|
+
: newBodies.map((body) => (wokenDynamicIds.has(body.id) && body.kind === 'dynamic' && body.sleeping
|
|
375
|
+
? { ...body, sleeping: false, lowVelFrames: 0 }
|
|
376
|
+
: body));
|
|
377
|
+
return {
|
|
378
|
+
...world,
|
|
379
|
+
bodies: finalBodies,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
284
382
|
/**
|
|
285
383
|
* Apply a deterministic impulse to a body (linear + angular via lever arm),
|
|
286
384
|
* waking the body and its contact island. Returns a new canonical world.
|
|
@@ -3112,6 +3210,27 @@ function rayBodyDistance(body, query) {
|
|
|
3112
3210
|
if (body.shape.type === 'hull') {
|
|
3113
3211
|
return rayHullBodyDistance(body, body.shape, query);
|
|
3114
3212
|
}
|
|
3213
|
+
if (body.shape.type === 'compound') {
|
|
3214
|
+
let closestDistance;
|
|
3215
|
+
for (let index = 0; index < body.shape.children.length; index += 1) {
|
|
3216
|
+
const child = body.shape.children[index];
|
|
3217
|
+
const childBody = {
|
|
3218
|
+
...body,
|
|
3219
|
+
x: (body.x ?? 0) + (child.offset?.x ?? 0),
|
|
3220
|
+
y: (body.y ?? 0) + (child.offset?.y ?? 0),
|
|
3221
|
+
z: (body.z ?? 0) + (child.offset?.z ?? 0),
|
|
3222
|
+
shape: child.shape,
|
|
3223
|
+
rx: (body.rx ?? 0) + canonicalQuarterTurns(child.quarterTurns?.x ?? 0) / 4,
|
|
3224
|
+
ry: (body.ry ?? 0) + canonicalQuarterTurns(child.quarterTurns?.y ?? 0) / 4,
|
|
3225
|
+
rz: (body.rz ?? 0) + canonicalQuarterTurns(child.quarterTurns?.z ?? 0) / 4,
|
|
3226
|
+
};
|
|
3227
|
+
const childDistance = rayBodyDistance(childBody, query);
|
|
3228
|
+
if (childDistance !== undefined && (closestDistance === undefined || childDistance < closestDistance)) {
|
|
3229
|
+
closestDistance = childDistance;
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
return closestDistance;
|
|
3233
|
+
}
|
|
3115
3234
|
return rayAabbDistance(toBounds(body), query);
|
|
3116
3235
|
}
|
|
3117
3236
|
function rayHullBodyDistance(body, shape, query) {
|
|
@@ -3425,13 +3544,61 @@ function countCandidatePairs(bodies) {
|
|
|
3425
3544
|
countCandidatePairsCalls += 1;
|
|
3426
3545
|
return broadphaseCandidatePairs(bodies).length;
|
|
3427
3546
|
}
|
|
3547
|
+
function compoundChildDetailedHit(body, query, distance) {
|
|
3548
|
+
if (body.shape.type !== 'compound') {
|
|
3549
|
+
return undefined;
|
|
3550
|
+
}
|
|
3551
|
+
let bestChildIndex;
|
|
3552
|
+
let bestNormal;
|
|
3553
|
+
let bestDistDiff = Infinity;
|
|
3554
|
+
for (let index = 0; index < body.shape.children.length; index += 1) {
|
|
3555
|
+
const child = body.shape.children[index];
|
|
3556
|
+
const childBody = {
|
|
3557
|
+
...body,
|
|
3558
|
+
x: (body.x ?? 0) + (child.offset?.x ?? 0),
|
|
3559
|
+
y: (body.y ?? 0) + (child.offset?.y ?? 0),
|
|
3560
|
+
z: (body.z ?? 0) + (child.offset?.z ?? 0),
|
|
3561
|
+
shape: child.shape,
|
|
3562
|
+
rx: (body.rx ?? 0) + canonicalQuarterTurns(child.quarterTurns?.x ?? 0) / 4,
|
|
3563
|
+
ry: (body.ry ?? 0) + canonicalQuarterTurns(child.quarterTurns?.y ?? 0) / 4,
|
|
3564
|
+
rz: (body.rz ?? 0) + canonicalQuarterTurns(child.quarterTurns?.z ?? 0) / 4,
|
|
3565
|
+
};
|
|
3566
|
+
const childDist = 'dx' in query ? rayBodyDistance(childBody, query) : undefined;
|
|
3567
|
+
if (childDist !== undefined) {
|
|
3568
|
+
const diff = Math.abs(childDist - distance);
|
|
3569
|
+
if (diff < bestDistDiff) {
|
|
3570
|
+
bestDistDiff = diff;
|
|
3571
|
+
bestChildIndex = index;
|
|
3572
|
+
const center = quantizePoint({
|
|
3573
|
+
x: query.x + query.dx * childDist,
|
|
3574
|
+
y: query.y + query.dy * childDist,
|
|
3575
|
+
z: query.z + query.dz * childDist,
|
|
3576
|
+
});
|
|
3577
|
+
bestNormal = hitNormalForBody(childBody, center);
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
if (bestChildIndex !== undefined && bestNormal !== undefined) {
|
|
3582
|
+
return { childIndex: bestChildIndex, normal: bestNormal };
|
|
3583
|
+
}
|
|
3584
|
+
return undefined;
|
|
3585
|
+
}
|
|
3428
3586
|
function detailedHitForBody(body, query, distance, castRadius) {
|
|
3429
3587
|
const center = quantizePoint({
|
|
3430
3588
|
x: query.x + query.dx * distance,
|
|
3431
3589
|
y: query.y + query.dy * distance,
|
|
3432
3590
|
z: query.z + query.dz * distance,
|
|
3433
3591
|
});
|
|
3434
|
-
|
|
3592
|
+
let normal;
|
|
3593
|
+
let childIndex;
|
|
3594
|
+
if (body.shape.type === 'compound') {
|
|
3595
|
+
const compoundHit = compoundChildDetailedHit(body, query, distance);
|
|
3596
|
+
childIndex = compoundHit?.childIndex;
|
|
3597
|
+
normal = compoundHit?.normal ?? hitNormalForBody(body, center);
|
|
3598
|
+
}
|
|
3599
|
+
else {
|
|
3600
|
+
normal = hitNormalForBody(body, center);
|
|
3601
|
+
}
|
|
3435
3602
|
const point = quantizePoint({
|
|
3436
3603
|
x: center.x - normal.x * castRadius,
|
|
3437
3604
|
y: center.y - normal.y * castRadius,
|
|
@@ -3443,6 +3610,7 @@ function detailedHitForBody(body, query, distance, castRadius) {
|
|
|
3443
3610
|
point,
|
|
3444
3611
|
normal,
|
|
3445
3612
|
penetration: distance === 0 ? penetrationAtPoint(body, center, castRadius) : 0,
|
|
3613
|
+
childIndex,
|
|
3446
3614
|
};
|
|
3447
3615
|
}
|
|
3448
3616
|
function hitNormalForBody(body, point) {
|
|
@@ -3453,6 +3621,24 @@ function hitNormalForBody(body, point) {
|
|
|
3453
3621
|
z: point.z - body.z,
|
|
3454
3622
|
});
|
|
3455
3623
|
}
|
|
3624
|
+
if (body.shape.type === 'capsule') {
|
|
3625
|
+
const halfHeight = body.shape.halfHeight;
|
|
3626
|
+
let localPoint = { x: point.x - body.x, y: point.y - body.y, z: point.z - body.z };
|
|
3627
|
+
if (body.orientation !== undefined) {
|
|
3628
|
+
localPoint = rotatePointByQuatInverse(body.orientation, localPoint);
|
|
3629
|
+
}
|
|
3630
|
+
const clampedY = Math.max(-halfHeight, Math.min(halfHeight, localPoint.y));
|
|
3631
|
+
const localDelta = {
|
|
3632
|
+
x: localPoint.x,
|
|
3633
|
+
y: localPoint.y - clampedY,
|
|
3634
|
+
z: localPoint.z,
|
|
3635
|
+
};
|
|
3636
|
+
const localNormal = normalizePoint(localDelta);
|
|
3637
|
+
if (body.orientation !== undefined) {
|
|
3638
|
+
return normalizePoint(rotatePointByQuat(body.orientation, localNormal));
|
|
3639
|
+
}
|
|
3640
|
+
return localNormal;
|
|
3641
|
+
}
|
|
3456
3642
|
const bounds = toBounds(body);
|
|
3457
3643
|
const candidates = [
|
|
3458
3644
|
{ normal: { x: -1, y: 0, z: 0 }, distance: Math.abs(point.x - bounds.minX), axis: 'x-' },
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime a deterministic room simulates: engine identity plus the
|
|
3
|
+
* canonical session config (the game's rules) it was created with.
|
|
4
|
+
*
|
|
5
|
+
* This is the ONE implementation. The build mints a pin with
|
|
6
|
+
* `createSyncplayRuntimePin`, the publish path validates it, and the room
|
|
7
|
+
* server validates it again on room create — all through
|
|
8
|
+
* `parseSyncplayRuntimeRoomMetadata` below. A second copy of these rules is how
|
|
9
|
+
* a pin could pass one gate and fail another.
|
|
10
|
+
*/
|
|
11
|
+
export declare const MAX_SYNCPLAY_SESSION_CONFIG_BYTES: 65536;
|
|
12
|
+
export interface SyncplayRuntimeIdentity {
|
|
13
|
+
readonly abiVersion: 1;
|
|
14
|
+
readonly tickRate: 10 | 20 | 30 | 60;
|
|
15
|
+
readonly inputSchemaId: string;
|
|
16
|
+
readonly stateSchemaId: string;
|
|
17
|
+
readonly deterministicVersion: string;
|
|
18
|
+
readonly engineIdentityHash: string;
|
|
19
|
+
}
|
|
20
|
+
/** Wire shape: `sessionConfigBytes` is canonical base64. */
|
|
21
|
+
export interface SyncplayRuntimeRoomMetadata {
|
|
22
|
+
readonly runtimeIdentity: SyncplayRuntimeIdentity;
|
|
23
|
+
readonly sessionConfigBytes: string;
|
|
24
|
+
readonly sessionConfigDigest: string;
|
|
25
|
+
}
|
|
26
|
+
/** Parsed shape: `sessionConfigBytes` decoded. */
|
|
27
|
+
export interface ParsedSyncplayRuntimeRoomMetadata {
|
|
28
|
+
readonly runtimeIdentity: SyncplayRuntimeIdentity;
|
|
29
|
+
readonly sessionConfigBytes: Uint8Array;
|
|
30
|
+
readonly sessionConfigDigest: string;
|
|
31
|
+
}
|
|
32
|
+
/** A game's runtime config, as its simulation module exports it. */
|
|
33
|
+
export interface SyncplayRuntimeConfigInput {
|
|
34
|
+
readonly runtimeIdentity: SyncplayRuntimeIdentity;
|
|
35
|
+
readonly sessionConfigBytes: Uint8Array;
|
|
36
|
+
readonly sessionConfigDigest: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function parseSyncplayRuntimeRoomMetadata(value: unknown): ParsedSyncplayRuntimeRoomMetadata;
|
|
39
|
+
/**
|
|
40
|
+
* Mint the wire-shaped runtime pin a build stamps into its rooms config.
|
|
41
|
+
*
|
|
42
|
+
* Validates through `parseSyncplayRuntimeRoomMetadata`, so a pin that mints is
|
|
43
|
+
* a pin that publishes and loads. Throws rather than returning a bad pin: the
|
|
44
|
+
* room server treats the pin as authoritative over player-supplied ticket
|
|
45
|
+
* metadata, so a wrong pin is worse than no pin.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createSyncplayRuntimePin(input: SyncplayRuntimeConfigInput): SyncplayRuntimeRoomMetadata;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime a deterministic room simulates: engine identity plus the
|
|
3
|
+
* canonical session config (the game's rules) it was created with.
|
|
4
|
+
*
|
|
5
|
+
* This is the ONE implementation. The build mints a pin with
|
|
6
|
+
* `createSyncplayRuntimePin`, the publish path validates it, and the room
|
|
7
|
+
* server validates it again on room create — all through
|
|
8
|
+
* `parseSyncplayRuntimeRoomMetadata` below. A second copy of these rules is how
|
|
9
|
+
* a pin could pass one gate and fail another.
|
|
10
|
+
*/
|
|
11
|
+
import { kinetixSessionConfigDigest, MAX_KINETIX_SESSION_CONFIG_BYTES, } from '@series-inc/rundot-syncplay/core/runtime';
|
|
12
|
+
export const MAX_SYNCPLAY_SESSION_CONFIG_BYTES = MAX_KINETIX_SESSION_CONFIG_BYTES;
|
|
13
|
+
export function parseSyncplayRuntimeRoomMetadata(value) {
|
|
14
|
+
if (!isRecord(value) || !hasExactKeys(value, ['runtimeIdentity', 'sessionConfigBytes', 'sessionConfigDigest'])) {
|
|
15
|
+
throw new Error('syncplay runtime metadata is missing or malformed');
|
|
16
|
+
}
|
|
17
|
+
const identity = value.runtimeIdentity;
|
|
18
|
+
if (!isRecord(identity)
|
|
19
|
+
|| !hasExactKeys(identity, ['abiVersion', 'tickRate', 'inputSchemaId', 'stateSchemaId', 'deterministicVersion', 'engineIdentityHash'])
|
|
20
|
+
|| identity.abiVersion !== 1
|
|
21
|
+
|| ![10, 20, 30, 60].includes(identity.tickRate)
|
|
22
|
+
|| !isNonEmptyString(identity.inputSchemaId)
|
|
23
|
+
|| !isNonEmptyString(identity.stateSchemaId)
|
|
24
|
+
|| !isNonEmptyString(identity.deterministicVersion)
|
|
25
|
+
|| !isNonEmptyString(identity.engineIdentityHash)) {
|
|
26
|
+
throw new Error('syncplay runtime identity is malformed');
|
|
27
|
+
}
|
|
28
|
+
if (typeof value.sessionConfigBytes !== 'string' || !isCanonicalBase64(value.sessionConfigBytes)) {
|
|
29
|
+
throw new Error('syncplay session config bytes are not canonical base64');
|
|
30
|
+
}
|
|
31
|
+
const bytes = Uint8Array.from(Buffer.from(value.sessionConfigBytes, 'base64'));
|
|
32
|
+
if (bytes.byteLength < 1 || bytes.byteLength > MAX_SYNCPLAY_SESSION_CONFIG_BYTES) {
|
|
33
|
+
throw new Error('syncplay session config bytes are empty or oversized');
|
|
34
|
+
}
|
|
35
|
+
if (typeof value.sessionConfigDigest !== 'string'
|
|
36
|
+
|| value.sessionConfigDigest !== kinetixSessionConfigDigest(bytes)) {
|
|
37
|
+
throw new Error('syncplay session config digest mismatch');
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
runtimeIdentity: identity,
|
|
41
|
+
sessionConfigBytes: bytes,
|
|
42
|
+
sessionConfigDigest: value.sessionConfigDigest,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Mint the wire-shaped runtime pin a build stamps into its rooms config.
|
|
47
|
+
*
|
|
48
|
+
* Validates through `parseSyncplayRuntimeRoomMetadata`, so a pin that mints is
|
|
49
|
+
* a pin that publishes and loads. Throws rather than returning a bad pin: the
|
|
50
|
+
* room server treats the pin as authoritative over player-supplied ticket
|
|
51
|
+
* metadata, so a wrong pin is worse than no pin.
|
|
52
|
+
*/
|
|
53
|
+
export function createSyncplayRuntimePin(input) {
|
|
54
|
+
// Check the caller's own keys before re-shaping: building a fresh object
|
|
55
|
+
// would silently drop a stray top-level key instead of rejecting it.
|
|
56
|
+
if (!isRecord(input)
|
|
57
|
+
|| !hasExactKeys(input, ['runtimeIdentity', 'sessionConfigBytes', 'sessionConfigDigest'])) {
|
|
58
|
+
throw new Error('syncplay runtime config must carry exactly runtimeIdentity, sessionConfigBytes, sessionConfigDigest');
|
|
59
|
+
}
|
|
60
|
+
if (!(input.sessionConfigBytes instanceof Uint8Array)) {
|
|
61
|
+
throw new Error('syncplay runtime config sessionConfigBytes must be a Uint8Array');
|
|
62
|
+
}
|
|
63
|
+
const pin = {
|
|
64
|
+
runtimeIdentity: input.runtimeIdentity,
|
|
65
|
+
sessionConfigBytes: Buffer.from(input.sessionConfigBytes).toString('base64'),
|
|
66
|
+
sessionConfigDigest: input.sessionConfigDigest,
|
|
67
|
+
};
|
|
68
|
+
parseSyncplayRuntimeRoomMetadata(pin);
|
|
69
|
+
return pin;
|
|
70
|
+
}
|
|
71
|
+
function hasExactKeys(value, expected) {
|
|
72
|
+
return Object.keys(value).sort().join('\0') === [...expected].sort().join('\0');
|
|
73
|
+
}
|
|
74
|
+
function isNonEmptyString(value) {
|
|
75
|
+
return typeof value === 'string' && value.length > 0;
|
|
76
|
+
}
|
|
77
|
+
function isCanonicalBase64(value) {
|
|
78
|
+
if (value.length === 0 || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(value))
|
|
79
|
+
return false;
|
|
80
|
+
return Buffer.from(value, 'base64').toString('base64') === value;
|
|
81
|
+
}
|
|
82
|
+
function isRecord(value) {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
84
|
+
}
|
|
@@ -51,6 +51,30 @@ export declare function encodeDeterministicStateSnapshot(snapshot: Deterministic
|
|
|
51
51
|
export declare function decodeDeterministicStateSnapshot(text: string): DeterministicStateSnapshotDecodeResult;
|
|
52
52
|
export declare function bytesToBase64(bytes: Uint8Array): string;
|
|
53
53
|
export declare function base64ToBytes(text: string): Uint8Array;
|
|
54
|
+
/**
|
|
55
|
+
* Fixed bytes the wire framing adds around a chunk: 225 B for the
|
|
56
|
+
* `snapshot-chunk` envelope plus 76 B for the SDK transport's outer
|
|
57
|
+
* {type,msgType,data} wrapper. Held at 512 B for headroom on transferId and
|
|
58
|
+
* checksum length changes.
|
|
59
|
+
*/
|
|
60
|
+
export declare const SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES = 512;
|
|
61
|
+
/**
|
|
62
|
+
* Worst-case multiplicative growth of a chunk string on its way to the wire.
|
|
63
|
+
* `snapshotBytes` is JSON (`encodeDeterministicStateSnapshot` -> canonicalStringify),
|
|
64
|
+
* NOT base64, so it is quote-dense — and it is JSON-escaped TWICE: into the
|
|
65
|
+
* `snapshot-chunk` envelope, then again when the SDK transport stringifies the
|
|
66
|
+
* outer message (run-room-transport `sendRaw` -> RundotServerRoom
|
|
67
|
+
* {type,msgType,data} -> ServerWebSocket `JSON.stringify`). A `"` becomes `\"`
|
|
68
|
+
* becomes `\\\"`. Measured at exactly 4.001x for all-quote and all-backslash
|
|
69
|
+
* payloads; a realistic base64-dominated snapshot lands at ~1.001x.
|
|
70
|
+
*
|
|
71
|
+
* The room server's boot guard requires
|
|
72
|
+
* WS_MAX_MESSAGE_SIZE >= DEFAULT_SNAPSHOT_CHUNK_SIZE * this + the fixed overhead.
|
|
73
|
+
* A cap below that makes `ws` raise "Max payload size exceeded" and close the
|
|
74
|
+
* socket 1006, failing every snapshot transfer for that room — the 2026-08-25
|
|
75
|
+
* lattice incident. See .plans/syncplay-snapshot-cap-and-observability.md.
|
|
76
|
+
*/
|
|
77
|
+
export declare const MAX_JSON_ESCAPE_EXPANSION = 4;
|
|
54
78
|
export declare const DEFAULT_SNAPSHOT_CHUNK_SIZE = 262144;
|
|
55
79
|
export declare const DEFAULT_MAX_SNAPSHOT_BYTES = 4194304;
|
|
56
80
|
export interface CreateSnapshotTransferOptions {
|
package/dist/session-snapshot.js
CHANGED
|
@@ -155,6 +155,30 @@ export function base64ToBytes(text) {
|
|
|
155
155
|
return bytes;
|
|
156
156
|
}
|
|
157
157
|
// ── transfer builder (raw bytes → frozen wire transfer shape) ──
|
|
158
|
+
/**
|
|
159
|
+
* Fixed bytes the wire framing adds around a chunk: 225 B for the
|
|
160
|
+
* `snapshot-chunk` envelope plus 76 B for the SDK transport's outer
|
|
161
|
+
* {type,msgType,data} wrapper. Held at 512 B for headroom on transferId and
|
|
162
|
+
* checksum length changes.
|
|
163
|
+
*/
|
|
164
|
+
export const SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES = 512;
|
|
165
|
+
/**
|
|
166
|
+
* Worst-case multiplicative growth of a chunk string on its way to the wire.
|
|
167
|
+
* `snapshotBytes` is JSON (`encodeDeterministicStateSnapshot` -> canonicalStringify),
|
|
168
|
+
* NOT base64, so it is quote-dense — and it is JSON-escaped TWICE: into the
|
|
169
|
+
* `snapshot-chunk` envelope, then again when the SDK transport stringifies the
|
|
170
|
+
* outer message (run-room-transport `sendRaw` -> RundotServerRoom
|
|
171
|
+
* {type,msgType,data} -> ServerWebSocket `JSON.stringify`). A `"` becomes `\"`
|
|
172
|
+
* becomes `\\\"`. Measured at exactly 4.001x for all-quote and all-backslash
|
|
173
|
+
* payloads; a realistic base64-dominated snapshot lands at ~1.001x.
|
|
174
|
+
*
|
|
175
|
+
* The room server's boot guard requires
|
|
176
|
+
* WS_MAX_MESSAGE_SIZE >= DEFAULT_SNAPSHOT_CHUNK_SIZE * this + the fixed overhead.
|
|
177
|
+
* A cap below that makes `ws` raise "Max payload size exceeded" and close the
|
|
178
|
+
* socket 1006, failing every snapshot transfer for that room — the 2026-08-25
|
|
179
|
+
* lattice incident. See .plans/syncplay-snapshot-cap-and-observability.md.
|
|
180
|
+
*/
|
|
181
|
+
export const MAX_JSON_ESCAPE_EXPANSION = 4;
|
|
158
182
|
export const DEFAULT_SNAPSHOT_CHUNK_SIZE = 262_144;
|
|
159
183
|
export const DEFAULT_MAX_SNAPSHOT_BYTES = 4_194_304;
|
|
160
184
|
export function createSnapshotTransferFromBytes(snapshotBytes, options) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@series-inc/rundot-syncplay",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.27.0",
|
|
4
4
|
"description": "Kinetix orchestration, rollback, replay, late-join, and deterministic networking for RUN.game",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -160,9 +160,10 @@
|
|
|
160
160
|
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
161
161
|
"test": "npm run test:core && npm run test:unit-control && npm run test:entity-registry",
|
|
162
162
|
"test:module-promotion": "node --expose-gc --import tsx --test tests/module-promotion.test.ts tests/module-docs.test.ts tests/module-bundle-budget.test.ts scripts/prove-module-promotion.test.mjs",
|
|
163
|
-
"test:core": "node --expose-gc --import tsx --test --test-concurrency=1 tests/canonical.test.ts tests/canonical-portability.test.ts tests/runtime-session.test.ts tests/kinetix-runtime-session.test.ts tests/runtime-support.test.ts tests/runtime-guards.test.ts tests/r3f-render-adapter.test.ts tests/snapshot-cost.test.ts tests/sdkSession.test.ts tests/input-authority.test.ts tests/session-wire.test.ts tests/session-snapshot.test.ts tests/authority-room.test.ts tests/networked-client-snapshot.test.ts tests/runner.test.ts tests/testing.test.ts tests/browser-session.test.ts tests/secret-config.test.ts tests/secret-protocol.test.ts tests/secret-authority.test.ts tests/secret-client.test.ts tests/late-join-checksum.test.ts tests/late-join-input-backfill.test.ts tests/replay-scoring.test.ts tests/bot-backfill.test.ts tests/latency-simulation.test.ts tests/production-input-budget.test.ts tests/time-sync.test.ts tests/match-log.test.ts tests/spectator.test.ts tests/synctest.test.ts tests/instant-replay.test.ts tests/session-promotion.test.ts tests/physics3d-broadphase-stats.test.ts tests/entrypoints.test.ts tests/export-surface.test.ts tests/module-seam.test.ts tests/module-contract-portability.test.ts tests/module-identity.test.ts tests/module-corpus.test.ts tests/component-model.test.ts tests/component-model-rollback.test.ts tests/component-model-integration.test.ts tests/actors-ai.test.ts tests/character-3d.test.ts tests/character-3d-certification.test.ts tests/combat-modules.test.ts tests/combat-modules-integration.test.ts tests/firearm.test.ts tests/gameplay-effects.test.ts tests/spatial-interaction.test.ts tests/stats-abilities.test.ts tests/inventory-equipment.test.ts tests/inventory-equipment-certification.test.ts tests/platformer-2d.test.ts tests/platformer-2d-certification.test.ts tests/module-promotion.test.ts tests/module-docs.test.ts tests/module-bundle-budget.test.ts tests/published-side-effects.test.ts tests/host-timing.test.ts tests/test-manifest.test.ts tests/coarse-region.test.ts tests/input-codec.test.ts tests/command-ingress.test.ts tests/session-reconfigure.test.ts tests/prediction-mode.test.ts tests/ephemeral-channel.test.ts tests/runner-surface.test.ts tests/runner-harness.test.ts tests/effects-adapter-bounds.test.ts tests/simulated-match-secrets.test.ts tests/run-glue-surface.test.mjs core/tests/core-boundary.test.mjs tests/math-core-parity.test.ts tests/math-profile.test.ts tests/movement3d-kcc-unit-contract.test.ts tests/movement3d-static-ground.test.ts",
|
|
163
|
+
"test:core": "node --expose-gc --import tsx --test --test-concurrency=1 tests/canonical.test.ts tests/canonical-portability.test.ts tests/runtime-session.test.ts tests/kinetix-runtime-session.test.ts tests/runtime-support.test.ts tests/runtime-guards.test.ts tests/r3f-render-adapter.test.ts tests/snapshot-cost.test.ts tests/sdkSession.test.ts tests/input-authority.test.ts tests/session-wire.test.ts tests/runtime-room-metadata.test.ts tests/session-snapshot.test.ts tests/authority-room.test.ts tests/networked-client-snapshot.test.ts tests/runner.test.ts tests/testing.test.ts tests/browser-session.test.ts tests/secret-config.test.ts tests/secret-protocol.test.ts tests/secret-authority.test.ts tests/secret-client.test.ts tests/late-join-checksum.test.ts tests/late-join-input-backfill.test.ts tests/replay-scoring.test.ts tests/bot-backfill.test.ts tests/latency-simulation.test.ts tests/production-input-budget.test.ts tests/time-sync.test.ts tests/match-log.test.ts tests/spectator.test.ts tests/synctest.test.ts tests/instant-replay.test.ts tests/session-promotion.test.ts tests/physics3d-broadphase-stats.test.ts tests/entrypoints.test.ts tests/export-surface.test.ts tests/module-seam.test.ts tests/module-contract-portability.test.ts tests/module-identity.test.ts tests/module-corpus.test.ts tests/component-model.test.ts tests/component-model-rollback.test.ts tests/component-model-integration.test.ts tests/actors-ai.test.ts tests/character-3d.test.ts tests/character-3d-certification.test.ts tests/combat-modules.test.ts tests/combat-modules-integration.test.ts tests/firearm.test.ts tests/gameplay-effects.test.ts tests/spatial-interaction.test.ts tests/stats-abilities.test.ts tests/inventory-equipment.test.ts tests/inventory-equipment-certification.test.ts tests/platformer-2d.test.ts tests/platformer-2d-certification.test.ts tests/module-promotion.test.ts tests/module-docs.test.ts tests/module-bundle-budget.test.ts tests/published-side-effects.test.ts tests/host-timing.test.ts tests/test-manifest.test.ts tests/coarse-region.test.ts tests/input-codec.test.ts tests/command-ingress.test.ts tests/session-reconfigure.test.ts tests/prediction-mode.test.ts tests/ephemeral-channel.test.ts tests/runner-surface.test.ts tests/runner-harness.test.ts tests/effects-adapter-bounds.test.ts tests/simulated-match-secrets.test.ts tests/run-glue-surface.test.mjs core/tests/core-boundary.test.mjs tests/math-core-parity.test.ts tests/math-profile.test.ts tests/movement3d-kcc-unit-contract.test.ts tests/movement3d-static-ground.test.ts",
|
|
164
|
+
"test:sdk-contract": "node --import tsx --test tests/sdk-runtime-contract.test.ts",
|
|
164
165
|
"test:soak": "tsx --test tests/runtime-soak.test.ts",
|
|
165
|
-
"test:extended": "tsx --test tests/kcc-vehicle-characterization.test.ts tests/animation.test.ts tests/asset-cooking.test.ts tests/backend-proof-checksum-vectors.test.ts tests/bot-documents.test.ts tests/bot-primitives.test.ts tests/collider-cooking.test.ts tests/collision.test.ts tests/command-timeline.test.ts tests/config-bundle.test.ts tests/cosmetic-physics3d.test.ts tests/deterministic-primitives.test.ts tests/ecs-lite.test.ts tests/effects-adapter.test.ts tests/engine-runtime.test.ts tests/errors.test.ts tests/events.test.ts tests/input-button.test.ts tests/load-128-players.test.ts tests/math.test.ts tests/movement.test.ts tests/movement3d.test.ts tests/multiclient-authority.test.ts tests/navigation.test.ts tests/noise.test.ts tests/physics-cert.test.ts tests/physics2d.test.ts tests/physics3d-ccd.test.ts tests/physics3d-coverage.test.ts tests/physics3d-destruction-cert.test.ts tests/physics3d-geometry-limits.test.ts tests/physics3d-heightfield.test.ts tests/physics3d-hull.test.ts tests/physics3d-joints.test.ts tests/physics3d-kinematic-mesh.test.ts tests/physics3d-mass.test.ts tests/physics3d-materials.test.ts tests/physics3d-mesh-onesided.test.ts tests/physics3d-query-index.test.ts tests/physics3d-recipes.test.ts tests/physics3d-rotation-convention.test.ts tests/physics3d-shared.test.ts tests/physics3d-sleeping-manifold-reuse.test.ts tests/physics3d-solver.test.ts tests/physics3d-static-memoization.test.ts tests/physics3d-static-overlap-memo.test.ts tests/physics3d-cylinder-hull.test.ts tests/physics3d-stacking.test.ts tests/physics3d-toi.test.ts tests/physics3d-vehicle.test.ts tests/physics3d-voxel.test.ts tests/physics3d-world-edit.test.ts tests/physics3d.test.ts tests/physics-vector-generator.test.ts tests/random.test.ts tests/rollback-history.test.ts tests/schema-artifacts.test.ts tests/schema-codegen.test.ts tests/schema-dsl-binary.test.ts tests/schema.test.ts tests/signals.test.ts tests/sparse-set.test.ts tests/static-checker.test.ts tests/system-lifecycle.test.ts tests/wire-canonical-sentinels.test.ts tests/ws-frame.test.ts",
|
|
166
|
+
"test:extended": "tsx --test tests/kcc-vehicle-characterization.test.ts tests/animation.test.ts tests/asset-cooking.test.ts tests/backend-proof-checksum-vectors.test.ts tests/bot-documents.test.ts tests/bot-primitives.test.ts tests/collider-cooking.test.ts tests/collision.test.ts tests/command-timeline.test.ts tests/config-bundle.test.ts tests/cosmetic-physics3d.test.ts tests/deterministic-primitives.test.ts tests/ecs-lite.test.ts tests/effects-adapter.test.ts tests/engine-runtime.test.ts tests/errors.test.ts tests/events.test.ts tests/input-button.test.ts tests/load-128-players.test.ts tests/math.test.ts tests/movement.test.ts tests/movement3d.test.ts tests/multiclient-authority.test.ts tests/navigation.test.ts tests/noise.test.ts tests/physics-cert.test.ts tests/physics2d.test.ts tests/physics3d-ccd.test.ts tests/physics3d-coverage.test.ts tests/physics3d-destruction-cert.test.ts tests/physics3d-geometry-limits.test.ts tests/physics3d-heightfield.test.ts tests/physics3d-hull.test.ts tests/physics3d-joints.test.ts tests/physics3d-kinematic-mesh.test.ts tests/physics3d-kinematic-poses.test.ts tests/physics3d-mass.test.ts tests/physics3d-materials.test.ts tests/physics3d-mesh-onesided.test.ts tests/physics3d-queries-capsule-childindex.test.ts tests/physics3d-query-index.test.ts tests/physics3d-recipes.test.ts tests/physics3d-rotation-convention.test.ts tests/physics3d-shared.test.ts tests/physics3d-sleeping-manifold-reuse.test.ts tests/physics3d-solver.test.ts tests/physics3d-static-memoization.test.ts tests/physics3d-static-overlap-memo.test.ts tests/physics3d-cylinder-hull.test.ts tests/physics3d-stacking.test.ts tests/physics3d-toi.test.ts tests/physics3d-vehicle.test.ts tests/physics3d-voxel-dda.test.ts tests/physics3d-voxel.test.ts tests/physics3d-world-edit.test.ts tests/physics3d.test.ts tests/physics-vector-generator.test.ts tests/random.test.ts tests/rollback-history.test.ts tests/schema-artifacts.test.ts tests/schema-codegen.test.ts tests/schema-dsl-binary.test.ts tests/schema.test.ts tests/signals.test.ts tests/sparse-set.test.ts tests/static-checker.test.ts tests/system-lifecycle.test.ts tests/wire-canonical-sentinels.test.ts tests/ws-frame.test.ts",
|
|
166
167
|
"test:ci": "npm run build && npm run test",
|
|
167
168
|
"build:native": "node core/scripts/build-native.mjs",
|
|
168
169
|
"build:mobile-native": "node core/scripts/build-mobile-native.mjs",
|