@series-inc/rundot-syncplay 5.26.0-beta.9 → 5.26.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.
@@ -179,6 +179,8 @@ export interface AuthorityRoomNetcodeStats {
179
179
  readonly confirmedThrough: number;
180
180
  /** Ticks in which each slot's input had to be substituted (cumulative). */
181
181
  readonly substitutedTicksBySlot: Readonly<Record<number, number>>;
182
+ /** Slots with a seated player. Empty slots substitute every tick by design. */
183
+ readonly occupiedSlots: readonly number[];
182
184
  readonly clientStats: Readonly<Record<number, AuthorityRoomClientNetStats>>;
183
185
  /** Command requests dropped for seat, size, rate, sequence, or stale generation. */
184
186
  readonly rejectedCommands: number;
@@ -239,6 +241,14 @@ export interface DeterministicAuthorityRoom {
239
241
  * ticks, so this is idempotent.
240
242
  */
241
243
  onPlayerReconnected(playerId: string): void;
244
+ /**
245
+ * A player's transport dropped and their seat entered the host's grace
246
+ * window. The slot stays reserved and keeps being substituted, so release
247
+ * the input they were holding — otherwise a player who drops mid-movement
248
+ * keeps moving until the grace window expires. Safe to call for a player
249
+ * with no slot (spectator, unknown id).
250
+ */
251
+ onPlayerDisconnected(playerId: string): void;
242
252
  /** Advance the authority by one server tick and broadcast newly-confirmed frames. */
243
253
  onTick(): void;
244
254
  /**
@@ -351,6 +351,11 @@ export function createDeterministicAuthorityRoom(transport, config) {
351
351
  sendCatchUpTo(playerId, plan, 'join');
352
352
  return true;
353
353
  }
354
+ function onPlayerDisconnected(playerId) {
355
+ const slot = slotByPlayer.get(playerId);
356
+ if (slot !== undefined)
357
+ authority.neutralizeSlot(slot);
358
+ }
354
359
  function onPlayerReconnected(playerId) {
355
360
  // A spectator reconnect is a full re-greet: its timeline may have forked
356
361
  // arbitrarily far and it holds no slot the redundancy window protects.
@@ -779,6 +784,10 @@ export function createDeterministicAuthorityRoom(transport, config) {
779
784
  // departed player's input; their slot stays reserved because `assignedSlots` never decreases,
780
785
  // so the authority substitutes for it rather than handing the number to a new joiner.
781
786
  const slot = slotByPlayer.get(playerId);
787
+ // The slot stays reserved and keeps being substituted (see above), so
788
+ // release the departing player's last held input.
789
+ if (slot !== undefined)
790
+ authority.neutralizeSlot(slot);
782
791
  slotByPlayer.delete(playerId);
783
792
  if (slot !== undefined)
784
793
  secretAuthority?.onPlayerLeft(playerId, slot);
@@ -853,6 +862,7 @@ export function createDeterministicAuthorityRoom(transport, config) {
853
862
  onPlayerSecretMessage,
854
863
  onPlayerLeave,
855
864
  onPlayerReconnected,
865
+ onPlayerDisconnected,
856
866
  onTick,
857
867
  snapshot() {
858
868
  return {
@@ -899,6 +909,7 @@ export function createDeterministicAuthorityRoom(transport, config) {
899
909
  return {
900
910
  confirmedThrough: authority.confirmedThrough,
901
911
  substitutedTicksBySlot: Object.fromEntries(substitutedTicksBySlot),
912
+ occupiedSlots: [...slotByPlayer.values()].sort((a, b) => a - b),
902
913
  clientStats: Object.fromEntries(clientNetStatsBySlot),
903
914
  rejectedCommands,
904
915
  rejectedReconfigures,
package/dist/browser.d.ts CHANGED
@@ -35,7 +35,7 @@ export { createDeterministicDescriptorBinarySchema, generateSchemaBinarySource,
35
35
  export { createDeterministicBinarySerializer, deterministicDslMaxPlayers, parseDeterministicSchemaDsl, stableDeterministicDslSchemaJson } from './schema-dsl-binary.js';
36
36
  export { createSyncplaySession } from './sdk-session.js';
37
37
  export { createSessionFromPromotionSnapshot, exportPromotionSnapshot } from './session-promotion.js';
38
- export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot } from './session-snapshot.js';
38
+ export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot, MAX_JSON_ESCAPE_EXPANSION, SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES } from './session-snapshot.js';
39
39
  export { decodeDeterministicSessionMessage, deterministicSessionWireVersion, encodeDeterministicSessionMessage } from './session-wire.js';
40
40
  export { createDeterministicSignalBus } from './signals.js';
41
41
  export { createDeterministicSparseSet } from './sparse-set.js';
package/dist/browser.js CHANGED
@@ -36,7 +36,7 @@ export { createDeterministicDescriptorBinarySchema, generateSchemaBinarySource,
36
36
  export { createDeterministicBinarySerializer, deterministicDslMaxPlayers, parseDeterministicSchemaDsl, stableDeterministicDslSchemaJson } from './schema-dsl-binary.js';
37
37
  export { createSyncplaySession } from './sdk-session.js';
38
38
  export { createSessionFromPromotionSnapshot, exportPromotionSnapshot } from './session-promotion.js';
39
- export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot } from './session-snapshot.js';
39
+ export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot, MAX_JSON_ESCAPE_EXPANSION, SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES } from './session-snapshot.js';
40
40
  export { decodeDeterministicSessionMessage, deterministicSessionWireVersion, encodeDeterministicSessionMessage } from './session-wire.js';
41
41
  export { createDeterministicSignalBus } from './signals.js';
42
42
  export { createDeterministicSparseSet } from './sparse-set.js';
@@ -1,5 +1,5 @@
1
1
  import { createDeterministicAuthorityRoom, } from './authority-room.js';
2
- import { SYSTEM_MSG_RECONNECTED } from '@series-inc/rundot-game-sdk/mp-server';
2
+ import { SYSTEM_MSG_DISCONNECTED, SYSTEM_MSG_RECONNECTED } from '@series-inc/rundot-game-sdk/mp-server';
3
3
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
4
4
  import { validateSyncplaySecretSystemsConfig } from './secret-config.js';
5
5
  import { assertKinetixRuntimeIdentity, kinetixSessionConfigDigest, } from '@series-inc/rundot-syncplay/core/runtime';
@@ -138,6 +138,13 @@ export class DevSyncplayAuthorityRoom {
138
138
  this.room.onPlayerReconnected(playerId);
139
139
  return;
140
140
  }
141
+ // The dev server already emits this on socket loss. Handle it so local
142
+ // play matches production: a player who drops mid-movement stops instead
143
+ // of having their held input repeated through the grace window.
144
+ if (msgType === SYSTEM_MSG_DISCONNECTED) {
145
+ this.room.onPlayerDisconnected(playerId);
146
+ return;
147
+ }
141
148
  if (msgType === SECRET_MSG_TYPE) {
142
149
  const envelope = typeof data === 'string' ? data : JSON.stringify(data);
143
150
  this.room.onPlayerSecretMessage(playerId, envelope);
package/dist/index.d.ts CHANGED
@@ -44,7 +44,7 @@ export { withRuntimeDeterminismGuards } from './runtime-guards.js';
44
44
  export { createDeterministicDescriptorBinarySchema, generateSchemaBinarySource, generateSchemaTypes, proveSchemaBinaryRoundTrip } from './schema-codegen.js';
45
45
  export { createDeterministicBinarySerializer, deterministicDslMaxPlayers, parseDeterministicSchemaDsl, stableDeterministicDslSchemaJson } from './schema-dsl-binary.js';
46
46
  export { createSyncplaySession } from './sdk-session.js';
47
- export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot } from './session-snapshot.js';
47
+ export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot, MAX_JSON_ESCAPE_EXPANSION, SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES } from './session-snapshot.js';
48
48
  export { decodeDeterministicSessionMessage, deterministicSessionWireVersion, encodeDeterministicSessionMessage } from './session-wire.js';
49
49
  export { createDeterministicSignalBus } from './signals.js';
50
50
  export { createDeterministicSparseSet } from './sparse-set.js';
package/dist/index.js CHANGED
@@ -45,7 +45,7 @@ export { withRuntimeDeterminismGuards } from './runtime-guards.js';
45
45
  export { createDeterministicDescriptorBinarySchema, generateSchemaBinarySource, generateSchemaTypes, proveSchemaBinaryRoundTrip } from './schema-codegen.js';
46
46
  export { createDeterministicBinarySerializer, deterministicDslMaxPlayers, parseDeterministicSchemaDsl, stableDeterministicDslSchemaJson } from './schema-dsl-binary.js';
47
47
  export { createSyncplaySession } from './sdk-session.js';
48
- export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot } from './session-snapshot.js';
48
+ export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot, MAX_JSON_ESCAPE_EXPANSION, SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES } from './session-snapshot.js';
49
49
  export { decodeDeterministicSessionMessage, deterministicSessionWireVersion, encodeDeterministicSessionMessage } from './session-wire.js';
50
50
  export { createDeterministicSignalBus } from './signals.js';
51
51
  export { createDeterministicSparseSet } from './sparse-set.js';
@@ -83,6 +83,14 @@ export interface InputAuthority {
83
83
  * frontier; a no-op at or below the current earliest retained tick.
84
84
  */
85
85
  pruneConfirmedBefore(tick: number): void;
86
+ /**
87
+ * Forget a slot's last confirmed input so later substitutions fall back to
88
+ * `neutralInput`. Call this when a slot's player disconnects or leaves: the
89
+ * slot stays reserved and keeps being substituted, so without this the
90
+ * authority repeats whatever they were holding when they dropped for the
91
+ * rest of the match. Out-of-roster slots are ignored.
92
+ */
93
+ neutralizeSlot(slot: number): void;
86
94
  stats(): InputAuthorityStats;
87
95
  }
88
96
  export declare function createInputAuthority(config: InputAuthorityConfig): InputAuthority;
@@ -185,6 +185,19 @@ export function createInputAuthority(config) {
185
185
  }
186
186
  earliestRetained = tick;
187
187
  },
188
+ neutralizeSlot(slot) {
189
+ if (!Number.isInteger(slot) || slot < 0 || slot >= playerCount)
190
+ return;
191
+ lastConfirmedInput[slot] = config.neutralInput;
192
+ // Resetting the last-confirmed value is not enough on its own: clients
193
+ // queue ahead by `inputDelayTicks`, so a dropping player usually has
194
+ // future ticks already buffered. `confirmTick` reassigns
195
+ // `lastConfirmedInput` from any submitted input, so leaving those in
196
+ // place would resurrect the held input on the very next confirm.
197
+ for (const slots of pending.values()) {
198
+ slots[slot] = undefined;
199
+ }
200
+ },
188
201
  stats() {
189
202
  return { confirmedTicks, substitutedInputs, droppedLateInputs, rejectedFutureInputs };
190
203
  },
@@ -142,6 +142,14 @@ export interface NetworkedSyncplayClientNetStats {
142
142
  readonly droppedConfirmedPresentationFrames: number;
143
143
  /** Commands this client refused to send (oversized payload). */
144
144
  readonly rejectedCommands: number;
145
+ /**
146
+ * Confirmed ticks in which THIS client's own input was substituted because it
147
+ * did not reach the authority in time. Monotonic within a session and reset on
148
+ * session-start. Counted per applied tick, so a batch of confirmations that
149
+ * substitutes some ticks and not the last one still reports every one — a
150
+ * consumer reading `confirmedPlayerPresence` alone sees only the final tick.
151
+ */
152
+ readonly ownInputSubstitutions: number;
145
153
  }
146
154
  export type NetworkedSyncplaySlotPresence = 'human' | 'substituted' | 'empty' | 'unknown';
147
155
  export interface ConfirmedSyncplayPresentationFrame<Projection> {
@@ -282,6 +282,7 @@ export function createNetworkedSyncplayClient(options) {
282
282
  let sessionGeneration = 0;
283
283
  let commandSequence = 0;
284
284
  let droppedConfirmedPresentationFrames = 0;
285
+ let ownInputSubstitutions = 0;
285
286
  let rejectedCommands = 0;
286
287
  const ephemeralHandlers = new Set();
287
288
  let pendingEphemeral;
@@ -427,6 +428,7 @@ export function createNetworkedSyncplayClient(options) {
427
428
  confirmed.clear();
428
429
  reportedTicks.length = 0;
429
430
  lastAutoReportedTick = -1;
431
+ ownInputSubstitutions = 0;
430
432
  transferCollector = createSnapshotTransferCollector(snapshotCollectorOptions);
431
433
  outgoingTransferQueue.length = 0;
432
434
  tickRateHz = message.tickRateHz;
@@ -912,6 +914,11 @@ export function createNetworkedSyncplayClient(options) {
912
914
  for (let playerSlot = 0; playerSlot < playerCount; playerSlot += 1) {
913
915
  lastConfirmedBySlot[playerSlot] = confInputs[playerSlot];
914
916
  }
917
+ // Per APPLIED tick, not per drained batch: `confirmedPlayerPresence`
918
+ // latches only the last tick of a batch, so a burst that substitutes
919
+ // several ticks and ends on a delivered one would otherwise vanish.
920
+ if (slot >= 0 && confFrame.substitutedSlots.includes(slot))
921
+ ownInputSubstitutions += 1;
915
922
  appliedThrough = tick;
916
923
  confirmedPlayerPresence = classifyPresence(confFrame);
917
924
  const presentationEligible = presentationWasLive || (freshRoomFirstFrame && tick === 0);
@@ -1232,6 +1239,7 @@ export function createNetworkedSyncplayClient(options) {
1232
1239
  maxRollbackDepth,
1233
1240
  droppedConfirmedPresentationFrames,
1234
1241
  rejectedCommands,
1242
+ ownInputSubstitutions,
1235
1243
  };
1236
1244
  },
1237
1245
  get generation() {
package/dist/node.d.ts CHANGED
@@ -19,6 +19,8 @@ export { buildMerkleTree, bytesToHex, concatBytes, constantTimeEqual, createBrow
19
19
  export type { SyncplaySecretAuthorityCrypto, SyncplaySecretBrowserCrypto } from './secret-crypto.js';
20
20
  export * from './ws-frame.js';
21
21
  export { SYNCPLAY_DETERMINISTIC_ROOM_OPTION_KEYS } from './rooms-config-keys.js';
22
+ export { createSyncplayRuntimePin, parseSyncplayRuntimeRoomMetadata, MAX_SYNCPLAY_SESSION_CONFIG_BYTES, } from './runtime-room-metadata.js';
23
+ export type { SyncplayRuntimeIdentity, SyncplayRuntimeRoomMetadata, ParsedSyncplayRuntimeRoomMetadata, SyncplayRuntimeConfigInput, } from './runtime-room-metadata.js';
22
24
  export type { SyncplayDeterministicRoomOptionKey } from './rooms-config-keys.js';
23
25
  export { assertCookedColliderIsRuntimeValid, cookConvexDecomposition, } from './collider-cooking.js';
24
26
  export type { ColliderCookOptions, ColliderSourceMesh, CookedCollider, CookedColliderHull, } from './collider-cooking.js';
package/dist/node.js CHANGED
@@ -16,6 +16,7 @@ export * from './secret-authority.js';
16
16
  export { buildMerkleTree, bytesToHex, concatBytes, constantTimeEqual, createBrowserSyncplaySecretCrypto, hexToBytes, merkleLeafHash, merkleNodeHash, merkleProof, sha256Bytes, signedReceiptBytes, verifyHashChainReveal, verifyMerkleProof, verifySyncplaySignedReceipt, } from './secret-crypto.js';
17
17
  export * from './ws-frame.js';
18
18
  export { SYNCPLAY_DETERMINISTIC_ROOM_OPTION_KEYS } from './rooms-config-keys.js';
19
+ export { createSyncplayRuntimePin, parseSyncplayRuntimeRoomMetadata, MAX_SYNCPLAY_SESSION_CONFIG_BYTES, } from './runtime-room-metadata.js';
19
20
  export { assertCookedColliderIsRuntimeValid, cookConvexDecomposition, } from './collider-cooking.js';
20
21
  // Dev-server deterministic room class (relocated from the game SDK). A game's
21
22
  // vite.config passes this to rundotMultiplayerPlugin({ deterministicRoomClass }).
@@ -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 {
@@ -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.26.0-beta.9",
3
+ "version": "5.26.0",
4
4
  "description": "Kinetix orchestration, rollback, replay, late-join, and deterministic networking for RUN.game",
5
5
  "repository": {
6
6
  "type": "git",
@@ -160,7 +160,8 @@
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
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-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
167
  "test:ci": "npm run build && npm run test",