@series-inc/rundot-syncplay 5.25.0-beta.2 → 5.25.0-beta.4

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/README.md CHANGED
@@ -110,3 +110,87 @@ The core suite covers checkpoint ownership/release, frame-zero rollback, v2
110
110
  snapshot hydration, replay verification, authority persistence, latency/loss
111
111
  convergence, late join, spectators, bots, secrets, and a real Kinetix world
112
112
  driven through `createSyncplaySession`.
113
+
114
+ ## Unified session runner
115
+
116
+ `createSyncplayRunner` owns the offline / co-op / networked lifecycle behind one
117
+ runtime factory and one presentation contract, so a game does not re-implement
118
+ create/join resolution, the local/network tick branch, bot filling, projection
119
+ extraction, and stale-connection invalidation:
120
+
121
+ ```ts
122
+ const runner = createSyncplayRunner({
123
+ runtimeFactory,
124
+ prepareNetworkRuntime: (descriptor, signal) => prepareLevelFromUgc(descriptor, signal),
125
+ defaultInput,
126
+ encodeInput,
127
+ decodeInput,
128
+ presentation: {
129
+ project: (frame) => frame,
130
+ interpolate: interpolateRenderState,
131
+ events: (frame) => frame.events,
132
+ },
133
+ })
134
+ runner.startOffline({ identity, sessionConfigBytes, playerCount: 2, botInputForTick })
135
+ await runner.startNetworked(() => createSyncplayRoom(api.realtime, roomOptions))
136
+ runner.applyInput(readInput())
137
+ runner.update(deltaMs)
138
+ render(runner.getRenderState(), runner.renderAlpha)
139
+ ```
140
+
141
+ **Status** is exactly `offline | connecting | syncing | live | stopped | error`:
142
+
143
+ - `connecting` covers the room-open promise.
144
+ - `syncing` covers session-descriptor/asset preparation, reconnect, and catch-up.
145
+ - `live` requires `client.caughtUp` plus at least one applied authority frame.
146
+ - `offline` owns the local fixed-step accumulator and bot filling.
147
+ - `error` carries connection, preparation, and offline-construction failures on
148
+ the runner snapshot (and the throw is rethrown to the caller).
149
+ - `stop` aborts preparation, closes the transport, and disposes the active
150
+ client/session — idempotently.
151
+
152
+ Presentation stays **predicted** for render motion, while side-effect events are
153
+ delivered only from authority-confirmed frames received after the first live
154
+ boundary. Initial and reconnect catch-up history is silent; a post-live
155
+ authority-leading frame still emits its event exactly once. Interpolation is
156
+ presentation-only and never feeds simulation. Starting a new mode always tears
157
+ down the old one, and a stale async connection/preparation closes without
158
+ replacing the current session.
159
+
160
+ Direct `NetworkedSyncplayClient` users get the same projection surface:
161
+ `getPresentationFrame()`, `renderAlpha`, `currentFrame`, `caughtUp`, and a
162
+ bounded confirmed-frame queue via the optional `confirmedPresentationBufferSize`
163
+ (absent = no queue, preserving existing cost) drained with
164
+ `drainConfirmedPresentationFrames()`. Call `beginPresentationCatchUp()` from your
165
+ transport's reconnect signal so replayed history never fills the queue.
166
+ `prepareNetworkedSyncplayTransport(transport, prepare, { signal })` buffers the
167
+ ordered `session-start`/history traffic (same 8,192-message ceiling as the SDK
168
+ transport), exposes a frozen descriptor with `levelEntryId + digest + length`,
169
+ awaits your async preparation, then replays every buffered message once.
170
+
171
+ **Capacity and late-join budgets.** Snapshot transfer defaults are **256 KiB**
172
+ chunks and a **4 MiB** total ceiling; the immutable level payload is fetched and
173
+ verified separately and never rides a snapshot. `maxPredictionTicks` is a
174
+ rollback/latency budget, not a player-count limit. 128 slots is the tested
175
+ protocol/load ceiling, not a 60 Hz performance guarantee for an arbitrary game.
176
+
177
+ ## Supported correctness kit
178
+
179
+ `@series-inc/rundot-syncplay/testing` exposes exactly six supported functions —
180
+ no browser or runtime APIs leak through it:
181
+
182
+ ```ts
183
+ import {
184
+ assertDeterministic,
185
+ assertLateJoinHydration,
186
+ assertReplayVerifies,
187
+ assertTwoClientConvergence,
188
+ } from '@series-inc/rundot-syncplay/testing'
189
+ ```
190
+
191
+ `assertDeterministic` and `assertReplayVerifies` run the straight-run /
192
+ restore-resim / hydration / replay cert; `assertTwoClientConvergence` and
193
+ `assertLateJoinHydration` run the **real** authority room and networked client
194
+ over a deterministic simulated network (`createSimulatedSyncplayMatch`,
195
+ `runSyncplaySynctest`). Failures name the phase, frame, and disagreeing
196
+ checksums.
package/dist/browser.d.ts CHANGED
@@ -19,7 +19,8 @@ export { rewindDeterministicTargets, runDeterministicMovementCrowdSimulation, ru
19
19
  export { stepDeterministicCarry3D, stepDeterministicKcc3D } from './movement3d.js';
20
20
  export { applyDeterministicReciprocalAvoidance, cookDeterministicNavigationGraph, cookTilemapNavigationGraph, createDeterministicFlowField, findDeterministicPath, smoothDeterministicPath, stepDeterministicNavigationAgents } from './navigation.js';
21
21
  export { cookDeterministicNavmesh, cookImportedDeterministicNavmesh, findDeterministicNavmeshPath, findDeterministicNavmeshPointPath, locateDeterministicNavmeshPolygon, runDeterministicNavmeshStress, smoothDeterministicNavmeshPath, stepDeterministicNavmeshAgents, stepDeterministicNavmeshOffMeshTraversal } from './navmesh.js';
22
- export { createNetworkedSyncplayClient } from './networked-client.js';
22
+ export { createNetworkedSyncplayClient, prepareNetworkedSyncplayTransport } from './networked-client.js';
23
+ export { createSyncplayRunner } from './runner.js';
23
24
  export { fbm2D, hash2, hashUint32, valueNoise1D, valueNoise2D } from './noise.js';
24
25
  export { circleCastDeterministicPhysics2D, createDeterministicPhysicsWorld2D, nearestDeterministicPhysicsHit2D, queryDeterministicPhysicsAabb2D, queryDeterministicPhysicsShapeOverlap2D, raycastAllDeterministicPhysics2D, raycastDeterministicPhysics2D, stepDeterministicPhysicsWorld2D } from './physics2d.js';
25
26
  export { applyDeterministicImpulse3D, applyDeterministicImpulses3D, applyDeterministicWorldEdit3D, contactDetailsDeterministicPhysics3D, createDeterministicPhysicsWorld3D, createTrustedDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, deterministicPhysicsCallback3D, overlapDeterministicPhysics3D, raycastDeterministicPhysics3D, raycastDeterministicPhysics3DDetailed, runDeterministicPhysics3DStress, setDeterministicBodyVelocity3D, shapeCastDeterministicPhysics3D, shapeCastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D } from './physics3d.js';
@@ -58,7 +59,8 @@ export type { DeterministicFpsAimState, DeterministicFpsInput, DeterministicKccB
58
59
  export type { DeterministicKccBody3D, DeterministicKccCarrierPose3D, DeterministicKccCarryResult3D, DeterministicKccCarryState3D, DeterministicKccCollisionCandidate3D, DeterministicKccCollisionFilter3D, DeterministicKccExternalImpulse3D, DeterministicKccGeometry3D, DeterministicKccInput3D, DeterministicKccPhysicsImpulse3D, DeterministicKccProcessor3D, DeterministicKccStepResult3D, DeterministicKccWorld3D, DeterministicMovingPlatform3D, DeterministicSlope3D, DeterministicStep3D } from './movement3d.js';
59
60
  export type { CookedDeterministicNavigationGraph, DeterministicFlowField, DeterministicNavEdge, DeterministicNavigationAgent, DeterministicNavigationGraphSource, DeterministicNavigationStepOptions, DeterministicNavigationStepResult, DeterministicNavNode, DeterministicNavRegion, DeterministicPathQuery, DeterministicPathResult, DeterministicTilemapSource } from './navigation.js';
60
61
  export type { CookedDeterministicNavmesh, DeterministicNavmeshAgent, DeterministicNavmeshAgentStepResult, DeterministicNavmeshImportSource, DeterministicNavmeshLink, DeterministicNavmeshMovingAvoidanceObstacle, DeterministicNavmeshOffMeshTraversalState, DeterministicNavmeshOffMeshTraversalStep, DeterministicNavmeshPath, DeterministicNavmeshPoint, DeterministicNavmeshPolygon, DeterministicNavmeshSource } from './navmesh.js';
61
- export type { NetworkedClientTransport, NetworkedSyncplayClient, NetworkedSyncplayClientInputDelayOptions, NetworkedSyncplayClientNetStats, NetworkedSyncplayClientOptions, NetworkedSyncplayClientPacingOptions, NetworkedSyncplaySlotPresence } from './networked-client.js';
62
+ export type { ConfirmedSyncplayPresentationFrame, KinetixRuntimeFactory, NetworkedClientTransport, NetworkedSyncplayClient, NetworkedSyncplayClientInputDelayOptions, NetworkedSyncplayClientNetStats, NetworkedSyncplayClientOptions, NetworkedSyncplayClientPacingOptions, NetworkedSyncplayPreparationOptions, NetworkedSyncplaySessionDescriptor, NetworkedSyncplaySlotPresence, PreparedNetworkedSyncplayTransport } from './networked-client.js';
63
+ export type { SyncplayRunner, SyncplayRunnerConfig, SyncplayRunnerConnectionEvent, SyncplayRunnerOfflineOptions, SyncplayRunnerPresentation, SyncplayRunnerSnapshot, SyncplayRunnerStatus, SyncplayRunnerTransport } from './runner.js';
62
64
  export type { DeterministicBodyKind, DeterministicContactEvent2D, DeterministicContactEventType, DeterministicPhysicsAabbQuery2D, DeterministicPhysicsActivePair2D, DeterministicPhysicsBody2D, DeterministicPhysicsCircleCastQuery2D, DeterministicPhysicsConstraint2D, DeterministicPhysicsHit2D, DeterministicPhysicsQueryOptions2D, DeterministicPhysicsRaycastQuery2D, DeterministicPhysicsShape2D, DeterministicPhysicsShapeOverlapQuery2D, DeterministicPhysicsStepOptions2D, DeterministicPhysicsStepResult2D, DeterministicPhysicsWorld2D } from './physics2d.js';
63
65
  export type { DeterministicContactCacheEntry3D, DeterministicJointCacheEntry3D, DeterministicPhysicsActivePair3D, DeterministicPhysicsAxisLock3D, DeterministicPhysicsBody3D, DeterministicPhysicsCallbackMask3D, DeterministicPhysicsCompoundChild3D, DeterministicPhysicsCompoundChildShape3D, DeterministicPhysicsConstraint3D, DeterministicPhysicsContactDetail3D, DeterministicPhysicsContactEvent3D, DeterministicPhysicsDetailedHit3D, DeterministicPhysicsHit3D, DeterministicPhysicsHullShape3D, DeterministicPhysicsImpulseApplication3D, DeterministicPhysicsOverlapQuery3D, DeterministicPhysicsPrimitiveShape3D, DeterministicPhysicsQuaternion3D, DeterministicPhysicsQueryOptions3D, DeterministicPhysicsRaycastQuery3D, DeterministicPhysicsScheduledQuery3D, DeterministicPhysicsShape3D, DeterministicPhysicsShapeCastQuery3D, DeterministicPhysicsStepResult3D, DeterministicPhysicsVec3, DeterministicPhysicsWorld3D, DeterministicPhysicsWorldEdit3D } from './physics3d.js';
64
66
  export type { DeterministicVoxelChunk3D, DeterministicVoxelCompoundShape3D } from './physics3d-voxel.js';
@@ -82,3 +84,10 @@ export { createSyncplaySecretEventReader, createSyncplaySecretsClient } from './
82
84
  export type { SyncplaySecretSystemDefinition, SyncplaySecretSystemsConfig } from './secret-config.js';
83
85
  export type { SyncplayOwnedSecretToken, SyncplaySecretCommand, SyncplaySecretErrorCode, SyncplaySecretPrivateMessage, SyncplaySignedReceipt } from './secret-protocol.js';
84
86
  export type { SyncplayChoiceClient, SyncplayChoiceResolvedEvent, SyncplayCollectionClient, SyncplayCollectionRevealedEvent, SyncplayOwnedSecret, SyncplayRandomClient, SyncplayRandomDrawnEvent, SyncplayRoleClient, SyncplayRoleRevealedEvent, SyncplaySecretEventReader, SyncplaySecretsClient } from './secret-client.js';
87
+ export * from './run-room-transport.js';
88
+ export { deriveCoarseRegion, detectCoarseRegion } from './coarse-region.js';
89
+ export type { CoarseRegion } from './coarse-region.js';
90
+ export { WsMultiplayerApi } from '@series-inc/rundot-game-sdk/mp-client';
91
+ export type { MultiplayerApi, JoinTicketRequest } from '@series-inc/rundot-game-sdk/mp-client';
92
+ export { openSyncplayBrowserRoom, resolveSyncplayBrowserIntent, shareSyncplayRoom, } from './browserSession.js';
93
+ export type { OpenSyncplayBrowserRoomOptions, OpenSyncplayBrowserRoomResult, ResolveSyncplayBrowserIntentOptions, SyncplayBrowserApi, SyncplayBrowserIntent, } from './browserSession.js';
package/dist/browser.js CHANGED
@@ -20,7 +20,8 @@ export { rewindDeterministicTargets, runDeterministicMovementCrowdSimulation, ru
20
20
  export { stepDeterministicCarry3D, stepDeterministicKcc3D } from './movement3d.js';
21
21
  export { applyDeterministicReciprocalAvoidance, cookDeterministicNavigationGraph, cookTilemapNavigationGraph, createDeterministicFlowField, findDeterministicPath, smoothDeterministicPath, stepDeterministicNavigationAgents } from './navigation.js';
22
22
  export { cookDeterministicNavmesh, cookImportedDeterministicNavmesh, findDeterministicNavmeshPath, findDeterministicNavmeshPointPath, locateDeterministicNavmeshPolygon, runDeterministicNavmeshStress, smoothDeterministicNavmeshPath, stepDeterministicNavmeshAgents, stepDeterministicNavmeshOffMeshTraversal } from './navmesh.js';
23
- export { createNetworkedSyncplayClient } from './networked-client.js';
23
+ export { createNetworkedSyncplayClient, prepareNetworkedSyncplayTransport } from './networked-client.js';
24
+ export { createSyncplayRunner } from './runner.js';
24
25
  export { fbm2D, hash2, hashUint32, valueNoise1D, valueNoise2D } from './noise.js';
25
26
  export { circleCastDeterministicPhysics2D, createDeterministicPhysicsWorld2D, nearestDeterministicPhysicsHit2D, queryDeterministicPhysicsAabb2D, queryDeterministicPhysicsShapeOverlap2D, raycastAllDeterministicPhysics2D, raycastDeterministicPhysics2D, stepDeterministicPhysicsWorld2D } from './physics2d.js';
26
27
  export { applyDeterministicImpulse3D, applyDeterministicImpulses3D, applyDeterministicWorldEdit3D, contactDetailsDeterministicPhysics3D, createDeterministicPhysicsWorld3D, createTrustedDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, deterministicPhysicsCallback3D, overlapDeterministicPhysics3D, raycastDeterministicPhysics3D, raycastDeterministicPhysics3DDetailed, runDeterministicPhysics3DStress, setDeterministicBodyVelocity3D, shapeCastDeterministicPhysics3D, shapeCastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D } from './physics3d.js';
@@ -42,3 +43,12 @@ export { syncplaySecretConfigHash, syncplaySecretLimits, validateSyncplaySecretS
42
43
  export { decodeSyncplaySecretPrivateMessage, isSyncplaySecretCommand, syncplaySecretProtocol } from './secret-protocol.js';
43
44
  export { createBrowserSyncplaySecretCrypto, verifySyncplaySignedReceipt } from './secret-crypto.js';
44
45
  export { createSyncplaySecretEventReader, createSyncplaySecretsClient } from './secret-client.js';
46
+ // RUN-platform multiplayer glue (relocated from the game SDK — see the dep
47
+ // inversion). The room client/transport + coarse-region matchmaking helpers a
48
+ // game hands to `createNetworkedSyncplayClient`.
49
+ export * from './run-room-transport.js';
50
+ export { deriveCoarseRegion, detectCoarseRegion } from './coarse-region.js';
51
+ export { WsMultiplayerApi } from '@series-inc/rundot-game-sdk/mp-client';
52
+ // Browser launch-intent / room-open / share composition over the RUN game API
53
+ // (RundotGameAPI comes from the SDK — syncplay may depend on the SDK).
54
+ export { openSyncplayBrowserRoom, resolveSyncplayBrowserIntent, shareSyncplayRoom, } from './browserSession.js';
@@ -0,0 +1,44 @@
1
+ import type { RundotGameAPI } from '@series-inc/rundot-game-sdk/api';
2
+ import { type SyncplayRoomOptions, type SyncplayRoomTransport } from './run-room-transport.js';
3
+ export type SyncplayBrowserIntent = {
4
+ readonly kind: 'offline';
5
+ } | {
6
+ readonly kind: 'create';
7
+ } | {
8
+ readonly kind: 'join';
9
+ readonly roomCode: string;
10
+ } | {
11
+ readonly kind: 'quick-match';
12
+ };
13
+ export type SyncplayBrowserApi = Pick<RundotGameAPI, 'app' | 'accessGate' | 'social' | 'realtime'>;
14
+ export interface ResolveSyncplayBrowserIntentOptions {
15
+ readonly override?: SyncplayBrowserIntent;
16
+ readonly search?: string;
17
+ readonly maxWaitMs?: number;
18
+ }
19
+ export interface OpenSyncplayBrowserRoomOptions extends ResolveSyncplayBrowserIntentOptions {
20
+ readonly quickMatch?: {
21
+ readonly autoRegion?: boolean;
22
+ readonly criteria?: Record<string, string | number>;
23
+ };
24
+ }
25
+ export type OpenSyncplayBrowserRoomResult = {
26
+ readonly kind: 'offline';
27
+ readonly intent: {
28
+ readonly kind: 'offline';
29
+ };
30
+ } | {
31
+ readonly kind: 'sign-in-required';
32
+ readonly intent: Exclude<SyncplayBrowserIntent, {
33
+ readonly kind: 'offline';
34
+ }>;
35
+ } | {
36
+ readonly kind: 'connected';
37
+ readonly intent: Exclude<SyncplayBrowserIntent, {
38
+ readonly kind: 'offline';
39
+ }>;
40
+ readonly transport: SyncplayRoomTransport;
41
+ };
42
+ export declare function resolveSyncplayBrowserIntent(api: SyncplayBrowserApi, options?: ResolveSyncplayBrowserIntentOptions): Promise<SyncplayBrowserIntent>;
43
+ export declare function openSyncplayBrowserRoom(api: SyncplayBrowserApi, roomOptions: SyncplayRoomOptions, options?: OpenSyncplayBrowserRoomOptions): Promise<OpenSyncplayBrowserRoomResult>;
44
+ export declare function shareSyncplayRoom(api: Pick<RundotGameAPI, 'social'>, roomCode: string): ReturnType<RundotGameAPI['social']['shareLinkAsync']>;
@@ -0,0 +1,70 @@
1
+ import { createSyncplayRoom, joinSyncplayRoomByCode, quickMatchSyncplayRoom, } from './run-room-transport.js';
2
+ function normalizeRoomCode(value) {
3
+ const code = value.trim().toUpperCase();
4
+ if (!/^[A-Z0-9]{6}$/u.test(code))
5
+ throw new Error('SYNCPLAY_ROOM_CODE_INVALID');
6
+ return code;
7
+ }
8
+ // `lenient` degrades a malformed room code (ignore it, fall through to offline)
9
+ // instead of throwing a boot-crash out of intent resolution. It is used for
10
+ // EVERY external channel — the raw URL query AND the RUN launch intent, whose
11
+ // `share`/`deeplink` kinds carry share-link and OS-deep-link params an attacker
12
+ // can craft. Only the game's explicit UI `override` is trusted and stays strict
13
+ // (a bad code there is a programming error worth surfacing).
14
+ function intentFromParams(params, lenient = false) {
15
+ if (params.room !== undefined) {
16
+ const code = params.room.trim().toUpperCase();
17
+ if (/^[A-Z0-9]{6}$/u.test(code))
18
+ return { kind: 'join', roomCode: code };
19
+ if (!lenient)
20
+ throw new Error('SYNCPLAY_ROOM_CODE_INVALID');
21
+ // lenient: ignore the malformed code and fall through to the other params
22
+ }
23
+ if (params.host === '1')
24
+ return { kind: 'create' };
25
+ if (params.quickMatch === '1')
26
+ return { kind: 'quick-match' };
27
+ return undefined;
28
+ }
29
+ function paramsFromSearch(search) {
30
+ const params = new URLSearchParams(search);
31
+ return Object.fromEntries([...params.entries()]);
32
+ }
33
+ function normalizeIntent(intent) {
34
+ return intent.kind === 'join'
35
+ ? { kind: 'join', roomCode: normalizeRoomCode(intent.roomCode) }
36
+ : intent;
37
+ }
38
+ export async function resolveSyncplayBrowserIntent(api, options = {}) {
39
+ if (options.override !== undefined)
40
+ return normalizeIntent(options.override);
41
+ const launch = await api.app.resolveLaunchIntent(options.maxWaitMs === undefined ? undefined : { maxWaitMs: options.maxWaitMs });
42
+ if (launch.kind !== 'timed_out') {
43
+ const launchIntent = intentFromParams(launch.params, true);
44
+ if (launchIntent !== undefined)
45
+ return launchIntent;
46
+ }
47
+ const search = options.search ?? (typeof globalThis.location === 'object' ? globalThis.location.search : '');
48
+ return intentFromParams(paramsFromSearch(search), true) ?? { kind: 'offline' };
49
+ }
50
+ export async function openSyncplayBrowserRoom(api, roomOptions, options = {}) {
51
+ const intent = await resolveSyncplayBrowserIntent(api, options);
52
+ if (intent.kind === 'offline')
53
+ return { kind: 'offline', intent };
54
+ if (api.accessGate.isAnonymous())
55
+ return { kind: 'sign-in-required', intent };
56
+ let transport;
57
+ if (intent.kind === 'join') {
58
+ transport = await joinSyncplayRoomByCode(api.realtime, intent.roomCode);
59
+ }
60
+ else if (intent.kind === 'create') {
61
+ transport = await createSyncplayRoom(api.realtime, roomOptions);
62
+ }
63
+ else {
64
+ transport = await quickMatchSyncplayRoom(api.realtime, { ...roomOptions, autoRegion: options.quickMatch?.autoRegion }, options.quickMatch?.criteria);
65
+ }
66
+ return { kind: 'connected', intent, transport };
67
+ }
68
+ export async function shareSyncplayRoom(api, roomCode) {
69
+ return api.social.shareLinkAsync({ shareParams: { room: normalizeRoomCode(roomCode) } });
70
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Coarse geographic region derived from the device's IANA timezone (G6:
3
+ * region-aware quick-match). Matchmaking pools pair on strict criteria
4
+ * equality, so a `region` criterion IS a region-aware pool key — no server
5
+ * schema change. Deliberately coarse (continent-scale): fine-grained buckets
6
+ * fragment thin player pools, and the goal is only to avoid cross-ocean
7
+ * pairings whose RTT forces constant deep rollback.
8
+ *
9
+ * Pure timezone-name mapping: no network calls, no clock reads.
10
+ */
11
+ export type CoarseRegion = 'na' | 'sa' | 'eu' | 'africa' | 'asia' | 'oceania' | 'unknown';
12
+ /** Pure mapping from an IANA timezone name to a coarse region. */
13
+ export declare function deriveCoarseRegion(timeZone: string): CoarseRegion;
14
+ /**
15
+ * Coarse region of THIS device, from `Intl.DateTimeFormat` timezone
16
+ * resolution. 'unknown' when the runtime resolves no zone (or an
17
+ * unclassifiable one like Etc/UTC) — callers should then skip region
18
+ * tagging rather than pool players into an 'unknown' bucket.
19
+ */
20
+ export declare function detectCoarseRegion(): CoarseRegion;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Coarse geographic region derived from the device's IANA timezone (G6:
3
+ * region-aware quick-match). Matchmaking pools pair on strict criteria
4
+ * equality, so a `region` criterion IS a region-aware pool key — no server
5
+ * schema change. Deliberately coarse (continent-scale): fine-grained buckets
6
+ * fragment thin player pools, and the goal is only to avoid cross-ocean
7
+ * pairings whose RTT forces constant deep rollback.
8
+ *
9
+ * Pure timezone-name mapping: no network calls, no clock reads.
10
+ */
11
+ /**
12
+ * America/* zones that are South America. Everything else under America/
13
+ * (including Central America and the Caribbean) stays 'na' — coarse on
14
+ * purpose. Covers the second path segment, so both `America/Sao_Paulo` and
15
+ * `America/Argentina/Buenos_Aires` resolve via 'Sao_Paulo' / 'Argentina'.
16
+ */
17
+ const SOUTH_AMERICA_SEGMENTS = new Set([
18
+ 'Araguaina',
19
+ 'Argentina',
20
+ 'Asuncion',
21
+ 'Bahia',
22
+ 'Belem',
23
+ 'Boa_Vista',
24
+ 'Bogota',
25
+ 'Buenos_Aires',
26
+ 'Campo_Grande',
27
+ 'Catamarca',
28
+ 'Cayenne',
29
+ 'Cordoba',
30
+ 'Cuiaba',
31
+ 'Eirunepe',
32
+ 'Fortaleza',
33
+ 'Guayaquil',
34
+ 'Guyana',
35
+ 'Jujuy',
36
+ 'La_Paz',
37
+ 'Lima',
38
+ 'Maceio',
39
+ 'Manaus',
40
+ 'Mendoza',
41
+ 'Montevideo',
42
+ 'Noronha',
43
+ 'Paramaribo',
44
+ 'Porto_Velho',
45
+ 'Punta_Arenas',
46
+ 'Recife',
47
+ 'Rio_Branco',
48
+ 'Rosario',
49
+ 'Santarem',
50
+ 'Santiago',
51
+ 'Sao_Paulo',
52
+ ]);
53
+ const AREA_REGIONS = {
54
+ Africa: 'africa',
55
+ // Arctic/Longyearbyen is the area's only zone (Svalbard, Norway).
56
+ Arctic: 'eu',
57
+ Asia: 'asia',
58
+ // Mostly Europe-adjacent islands (Azores, Canary, Reykjavik, Faroe...).
59
+ Atlantic: 'eu',
60
+ Australia: 'oceania',
61
+ Europe: 'eu',
62
+ // Mostly African-coast islands (Mauritius, Reunion, Antananarivo...).
63
+ Indian: 'africa',
64
+ Pacific: 'oceania',
65
+ };
66
+ /** Pure mapping from an IANA timezone name to a coarse region. */
67
+ export function deriveCoarseRegion(timeZone) {
68
+ const [area, subArea] = timeZone.split('/');
69
+ if (area === 'America' && subArea !== undefined) {
70
+ return SOUTH_AMERICA_SEGMENTS.has(subArea) ? 'sa' : 'na';
71
+ }
72
+ return AREA_REGIONS[area] ?? 'unknown';
73
+ }
74
+ /**
75
+ * Coarse region of THIS device, from `Intl.DateTimeFormat` timezone
76
+ * resolution. 'unknown' when the runtime resolves no zone (or an
77
+ * unclassifiable one like Etc/UTC) — callers should then skip region
78
+ * tagging rather than pool players into an 'unknown' bucket.
79
+ */
80
+ export function detectCoarseRegion() {
81
+ try {
82
+ return deriveCoarseRegion(Intl.DateTimeFormat().resolvedOptions().timeZone);
83
+ }
84
+ catch {
85
+ return 'unknown';
86
+ }
87
+ }
@@ -0,0 +1,14 @@
1
+ import type { Logger, RoomProtocol } from '@series-inc/rundot-game-sdk/mp-server';
2
+ interface DevSyncplayRoomContext {
3
+ protocol: RoomProtocol;
4
+ roomId: string;
5
+ config: Readonly<Record<string, unknown>>;
6
+ log: Logger;
7
+ }
8
+ export declare class DevSyncplayAuthorityRoom {
9
+ private readonly players;
10
+ private readonly room;
11
+ private tickTimer;
12
+ constructor(ctx: DevSyncplayRoomContext);
13
+ }
14
+ export {};
@@ -0,0 +1,227 @@
1
+ import { createDeterministicAuthorityRoom, } from './authority-room.js';
2
+ import { SYSTEM_MSG_RECONNECTED } from '@series-inc/rundot-game-sdk/mp-server';
3
+ import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
4
+ import { validateSyncplaySecretSystemsConfig } from './secret-config.js';
5
+ import { assertKinetixRuntimeIdentity, kinetixSessionConfigDigest, } from '@series-inc/rundot-kinetix/runtime';
6
+ /**
7
+ * Built-in deterministic (Syncplay) room for the dev sidecar — the playground
8
+ * counterpart of mp-room-server's SyncplayAuthorityGameRoom.
9
+ *
10
+ * When a rooms-config room declares `deterministic: true`, the sidecar
11
+ * instantiates this instead of loading the creator's GameRoom file: syncplay games
12
+ * have NO per-game server code — the engine's input authority orders/confirms
13
+ * inputs and never runs the sim. Clients speak the `rdm-local-authority/v2`
14
+ * session protocol under msgType `rdm`: `session-start` (unicast on join),
15
+ * `input` (client→server), `confirmed-input` (broadcast each authority tick).
16
+ *
17
+ * Unlike mp-room-server (whose worker harness drives `handleTick`), the sidecar
18
+ * has no tick driver, so this room owns its own interval at `tickRateHz`,
19
+ * started on create and cleared on dispose.
20
+ */
21
+ const SESSION_MSG_TYPE = 'rdm';
22
+ const SECRET_MSG_TYPE = 'rdm-secret';
23
+ const DEFAULT_HARD_TOLERANCE_TICKS = 6;
24
+ const DEFAULT_REDUNDANCY_WINDOW_TICKS = 8;
25
+ const DEFAULT_TICK_RATE_HZ = 30;
26
+ const DEFAULT_MAX_PLAYERS = 2;
27
+ export class DevSyncplayAuthorityRoom {
28
+ players = new Map();
29
+ room;
30
+ tickTimer = null;
31
+ constructor(ctx) {
32
+ const config = ctx.config;
33
+ const maxPlayers = readPositiveInt(config.maxPlayers, DEFAULT_MAX_PLAYERS);
34
+ const playerCount = readPositiveInt(config.playerCount, maxPlayers);
35
+ const tickRateHz = readPositiveInt(config.tickRateHz, DEFAULT_TICK_RATE_HZ);
36
+ const deterministic = isRecord(config.deterministic) ? config.deterministic : undefined;
37
+ const runtime = parseRuntimeMetadata(config.syncplayRuntime);
38
+ const secretConfig = deterministic?.secrets === undefined
39
+ ? undefined
40
+ : validateSyncplaySecretSystemsConfig(deterministic.secrets, playerCount);
41
+ const secretCrypto = secretConfig === undefined ? undefined : createEphemeralSecretCrypto();
42
+ // The server owns the seed: derive it deterministically from the room id so
43
+ // every client of this room receives the same seed (same scheme as prod).
44
+ const seed = deriveSeed(ctx.roomId);
45
+ this.room = createDeterministicAuthorityRoom({
46
+ broadcast: (msgType, payload) => ctx.protocol.broadcast(msgType, payload),
47
+ sendTo: (playerId, msgType, payload) => ctx.protocol.sendTo(playerId, msgType, payload),
48
+ }, {
49
+ playerCount,
50
+ seed,
51
+ // The engine accepts hardToleranceTicks >= 0 (0 = confirm immediately); honor an explicit 0.
52
+ hardToleranceTicks: readNonNegativeInt(config.hardToleranceTicks, DEFAULT_HARD_TOLERANCE_TICKS),
53
+ tickRateHz,
54
+ runtimeIdentity: runtime.runtimeIdentity,
55
+ sessionConfigBytes: runtime.sessionConfigBytes,
56
+ sessionConfigDigest: runtime.sessionConfigDigest,
57
+ redundancyWindowTicks: readPositiveInt(config.redundancyWindowTicks, DEFAULT_REDUNDANCY_WINDOW_TICKS),
58
+ // Game-agnostic no-op input: the authority substitutes canonical null for
59
+ // a slot with no prior input; the client maps null to the game's default.
60
+ neutralInput: null,
61
+ sessionMsgType: SESSION_MSG_TYPE,
62
+ ...(secretConfig === undefined
63
+ ? {}
64
+ : {
65
+ authorityId: ctx.roomId,
66
+ secrets: secretConfig,
67
+ secretCrypto,
68
+ secretMsgType: SECRET_MSG_TYPE,
69
+ }),
70
+ ...(config.waitForFullRoom === true ? { waitForFullRoom: true } : {}),
71
+ ...(typeof config.historyRetentionTicks === 'number'
72
+ ? { historyRetentionTicks: config.historyRetentionTicks }
73
+ : {}),
74
+ // Snapshot late-join (M5.5) knobs — same names as prod (mp-room-server).
75
+ ...(typeof config.snapshotCadenceTicks === 'number'
76
+ ? { snapshotCadenceTicks: config.snapshotCadenceTicks }
77
+ : {}),
78
+ ...(typeof config.transferTimeoutTicks === 'number'
79
+ ? { transferTimeoutTicks: config.transferTimeoutTicks }
80
+ : {}),
81
+ ...(typeof config.maxSnapshotBytes === 'number'
82
+ ? { maxSnapshotBytes: config.maxSnapshotBytes }
83
+ : {}),
84
+ // Detection only, never enforcement — surface it to the dev console.
85
+ onDesync: (info) => {
86
+ ctx.log.warn('deterministic room desync detected', { roomId: ctx.roomId, ...info });
87
+ },
88
+ onSnapshotEvent: (event) => {
89
+ if (event.kind === 'rejected') {
90
+ ctx.log.warn('deterministic room snapshot transfer rejected', { roomId: ctx.roomId, ...event });
91
+ }
92
+ },
93
+ });
94
+ ctx.protocol.handleCreate = async () => {
95
+ const intervalMs = Math.max(1, Math.round(1000 / tickRateHz));
96
+ this.tickTimer = setInterval(() => this.room.onTick(), intervalMs);
97
+ // Never hold the vite dev process open on our account.
98
+ this.tickTimer.unref?.();
99
+ };
100
+ ctx.protocol.handleRestore = async () => { };
101
+ ctx.protocol.handleDispose = async () => {
102
+ if (this.tickTimer !== null) {
103
+ clearInterval(this.tickTimer);
104
+ this.tickTimer = null;
105
+ }
106
+ };
107
+ ctx.protocol.handleJoin = async (data) => {
108
+ const result = this.room.onPlayerJoin(data.id);
109
+ if (!result.accepted) {
110
+ return { accepted: false, reason: result.reason ?? 'join-rejected' };
111
+ }
112
+ const player = {
113
+ id: data.id,
114
+ username: data.username,
115
+ avatarUrl: data.avatarUrl ?? null,
116
+ joinedAt: Date.now(),
117
+ connected: true,
118
+ };
119
+ this.players.set(data.id, player);
120
+ return { accepted: true, player };
121
+ };
122
+ ctx.protocol.handleMessage = async (playerId, msgType, data) => {
123
+ // Grace-window socket reattach (dev-server reattach): refill any confirmed
124
+ // frames missed while offline. Idempotent — clients dedupe applied ticks.
125
+ if (msgType === SYSTEM_MSG_RECONNECTED) {
126
+ this.room.onPlayerReconnected(playerId);
127
+ return;
128
+ }
129
+ if (msgType === SECRET_MSG_TYPE) {
130
+ const envelope = typeof data === 'string' ? data : JSON.stringify(data);
131
+ this.room.onPlayerSecretMessage(playerId, envelope);
132
+ return;
133
+ }
134
+ if (msgType !== SESSION_MSG_TYPE)
135
+ return;
136
+ // The engine envelope is a JSON string; the transport may deliver it parsed.
137
+ const envelope = typeof data === 'string' ? data : JSON.stringify(data);
138
+ this.room.onPlayerMessage(playerId, envelope);
139
+ };
140
+ ctx.protocol.handleLeave = async (playerId) => {
141
+ this.room.onPlayerLeave(playerId);
142
+ this.players.delete(playerId);
143
+ };
144
+ // Ticking is owned by handleCreate's interval (the sidecar has no external
145
+ // tick driver). handleTick stays a no-op so a harness that DOES drive ticks
146
+ // can never double-advance the authority.
147
+ ctx.protocol.handleTick = async () => { };
148
+ ctx.protocol.serializePersistState = () => ({
149
+ ...this.room.snapshot(),
150
+ ...(this.room.secretSnapshot() === undefined ? {} : { secretState: this.room.secretSnapshot() }),
151
+ });
152
+ ctx.protocol.getLocked = () => false;
153
+ ctx.protocol.getPlayers = () => this.players;
154
+ }
155
+ }
156
+ function parseRuntimeMetadata(value) {
157
+ if (!isRecord(value)
158
+ || Object.keys(value).sort().join('\0') !== 'runtimeIdentity\0sessionConfigBytes\0sessionConfigDigest'
159
+ || typeof value.sessionConfigBytes !== 'string'
160
+ || typeof value.sessionConfigDigest !== 'string') {
161
+ throw new Error('SYNCPLAY_RUNTIME_METADATA_INVALID');
162
+ }
163
+ assertKinetixRuntimeIdentity(value.runtimeIdentity);
164
+ const sessionConfigBytes = decodeCanonicalBase64(value.sessionConfigBytes);
165
+ if (sessionConfigBytes.byteLength < 1
166
+ || sessionConfigBytes.byteLength > 65_536
167
+ || kinetixSessionConfigDigest(sessionConfigBytes) !== value.sessionConfigDigest) {
168
+ throw new Error('SYNCPLAY_RUNTIME_METADATA_INVALID');
169
+ }
170
+ return {
171
+ runtimeIdentity: value.runtimeIdentity,
172
+ sessionConfigBytes,
173
+ sessionConfigDigest: value.sessionConfigDigest,
174
+ };
175
+ }
176
+ function decodeCanonicalBase64(value) {
177
+ if (value.length === 0 || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) {
178
+ throw new Error('SYNCPLAY_RUNTIME_METADATA_INVALID');
179
+ }
180
+ let binary;
181
+ try {
182
+ binary = atob(value);
183
+ }
184
+ catch {
185
+ throw new Error('SYNCPLAY_RUNTIME_METADATA_INVALID');
186
+ }
187
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
188
+ const canonical = btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(''));
189
+ if (canonical !== value)
190
+ throw new Error('SYNCPLAY_RUNTIME_METADATA_INVALID');
191
+ return bytes;
192
+ }
193
+ function readPositiveInt(value, fallback) {
194
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
195
+ }
196
+ function readNonNegativeInt(value, fallback) {
197
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback;
198
+ }
199
+ function deriveSeed(roomId) {
200
+ let hash = 0x811c9dc5;
201
+ for (let index = 0; index < roomId.length; index += 1) {
202
+ hash ^= roomId.charCodeAt(index);
203
+ hash = Math.imul(hash, 0x01000193);
204
+ }
205
+ return hash >>> 0;
206
+ }
207
+ function createEphemeralSecretCrypto() {
208
+ const pair = generateKeyPairSync('ed25519');
209
+ const privateKey = createPrivateKey(pair.privateKey.export({ type: 'pkcs8', format: 'pem' }));
210
+ const spkiDer = Buffer.from(new Uint8Array(pair.publicKey.export({ type: 'spki', format: 'der' })));
211
+ return {
212
+ kid: 'dev-ephemeral',
213
+ publicKeySpki: spkiDer.toString('base64'),
214
+ randomBytes(length) {
215
+ return Uint8Array.from(randomBytes(length));
216
+ },
217
+ sha256(bytes) {
218
+ return Uint8Array.from(createHash('sha256').update(bytes).digest());
219
+ },
220
+ sign(bytes) {
221
+ return Uint8Array.from(sign(null, bytes, privateKey));
222
+ },
223
+ };
224
+ }
225
+ function isRecord(value) {
226
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
227
+ }
package/dist/index.d.ts CHANGED
@@ -22,7 +22,8 @@ export { rewindDeterministicTargets, runDeterministicMovementCrowdSimulation, ru
22
22
  export { stepDeterministicCarry3D, stepDeterministicKcc3D } from './movement3d.js';
23
23
  export { applyDeterministicReciprocalAvoidance, cookDeterministicNavigationGraph, cookTilemapNavigationGraph, createDeterministicFlowField, findDeterministicPath, smoothDeterministicPath, stepDeterministicNavigationAgents } from './navigation.js';
24
24
  export { cookDeterministicNavmesh, cookImportedDeterministicNavmesh, findDeterministicNavmeshPath, findDeterministicNavmeshPointPath, locateDeterministicNavmeshPolygon, runDeterministicNavmeshStress, smoothDeterministicNavmeshPath, stepDeterministicNavmeshAgents, stepDeterministicNavmeshOffMeshTraversal } from './navmesh.js';
25
- export { createNetworkedSyncplayClient } from './networked-client.js';
25
+ export { createNetworkedSyncplayClient, prepareNetworkedSyncplayTransport } from './networked-client.js';
26
+ export { createSyncplayRunner } from './runner.js';
26
27
  export { fbm2D, hash2, hashUint32, valueNoise1D, valueNoise2D } from './noise.js';
27
28
  export { circleCastDeterministicPhysics2D, createDeterministicPhysicsWorld2D, nearestDeterministicPhysicsHit2D, queryDeterministicPhysicsAabb2D, queryDeterministicPhysicsShapeOverlap2D, raycastAllDeterministicPhysics2D, raycastDeterministicPhysics2D, stepDeterministicPhysicsWorld2D } from './physics2d.js';
28
29
  export { applyDeterministicImpulse3D, applyDeterministicImpulses3D, applyDeterministicWorldEdit3D, contactDetailsDeterministicPhysics3D, createDeterministicPhysicsWorld3D, createTrustedDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, deterministicPhysicsCallback3D, overlapDeterministicPhysics3D, raycastDeterministicPhysics3D, raycastDeterministicPhysics3DDetailed, runDeterministicPhysics3DStress, setDeterministicBodyVelocity3D, shapeCastDeterministicPhysics3D, shapeCastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D } from './physics3d.js';
@@ -70,7 +71,8 @@ export type { DeterministicFpsAimState, DeterministicFpsInput, DeterministicKccB
70
71
  export type { DeterministicKccBody3D, DeterministicKccCarrierPose3D, DeterministicKccCarryResult3D, DeterministicKccCarryState3D, DeterministicKccCollisionCandidate3D, DeterministicKccCollisionFilter3D, DeterministicKccExternalImpulse3D, DeterministicKccGeometry3D, DeterministicKccInput3D, DeterministicKccPhysicsImpulse3D, DeterministicKccProcessor3D, DeterministicKccStepResult3D, DeterministicKccWorld3D, DeterministicMovingPlatform3D, DeterministicSlope3D, DeterministicStep3D } from './movement3d.js';
71
72
  export type { CookedDeterministicNavigationGraph, DeterministicFlowField, DeterministicNavEdge, DeterministicNavigationAgent, DeterministicNavigationGraphSource, DeterministicNavigationStepOptions, DeterministicNavigationStepResult, DeterministicNavNode, DeterministicNavRegion, DeterministicPathQuery, DeterministicPathResult, DeterministicTilemapSource } from './navigation.js';
72
73
  export type { CookedDeterministicNavmesh, DeterministicNavmeshAgent, DeterministicNavmeshAgentStepResult, DeterministicNavmeshImportSource, DeterministicNavmeshLink, DeterministicNavmeshMovingAvoidanceObstacle, DeterministicNavmeshOffMeshTraversalState, DeterministicNavmeshOffMeshTraversalStep, DeterministicNavmeshPath, DeterministicNavmeshPoint, DeterministicNavmeshPolygon, DeterministicNavmeshSource } from './navmesh.js';
73
- export type { NetworkedClientTransport, NetworkedSyncplayClient, NetworkedSyncplayClientInputDelayOptions, NetworkedSyncplayClientNetStats, NetworkedSyncplayClientOptions, NetworkedSyncplayClientPacingOptions } from './networked-client.js';
74
+ export type { ConfirmedSyncplayPresentationFrame, KinetixRuntimeFactory, NetworkedClientTransport, NetworkedSyncplayClient, NetworkedSyncplayClientInputDelayOptions, NetworkedSyncplayClientNetStats, NetworkedSyncplayClientOptions, NetworkedSyncplayClientPacingOptions, NetworkedSyncplayPreparationOptions, NetworkedSyncplaySessionDescriptor, PreparedNetworkedSyncplayTransport } from './networked-client.js';
75
+ export type { SyncplayRunner, SyncplayRunnerConfig, SyncplayRunnerConnectionEvent, SyncplayRunnerOfflineOptions, SyncplayRunnerPresentation, SyncplayRunnerSnapshot, SyncplayRunnerStatus, SyncplayRunnerTransport } from './runner.js';
74
76
  export type { DeterministicBodyKind, DeterministicContactEvent2D, DeterministicContactEventType, DeterministicPhysicsAabbQuery2D, DeterministicPhysicsBody2D, DeterministicPhysicsCircleCastQuery2D, DeterministicPhysicsConstraint2D, DeterministicPhysicsHit2D, DeterministicPhysicsQueryOptions2D, DeterministicPhysicsRaycastQuery2D, DeterministicPhysicsShape2D, DeterministicPhysicsShapeOverlapQuery2D, DeterministicPhysicsStepOptions2D, DeterministicPhysicsStepResult2D, DeterministicPhysicsWorld2D } from './physics2d.js';
75
77
  export type { DeterministicContactCacheEntry3D, DeterministicJointCacheEntry3D, DeterministicPhysicsActivePair3D, DeterministicPhysicsAxisLock3D, DeterministicPhysicsBody3D, DeterministicPhysicsCallbackMask3D, DeterministicPhysicsCompoundChild3D, DeterministicPhysicsCompoundChildShape3D, DeterministicPhysicsConstraint3D, DeterministicPhysicsContactDetail3D, DeterministicPhysicsContactEvent3D, DeterministicPhysicsDetailedHit3D, DeterministicPhysicsHit3D, DeterministicPhysicsHullShape3D, DeterministicPhysicsImpulseApplication3D, DeterministicPhysicsOverlapQuery3D, DeterministicPhysicsPrimitiveShape3D, DeterministicPhysicsQuaternion3D, DeterministicPhysicsQueryOptions3D, DeterministicPhysicsRaycastQuery3D, DeterministicPhysicsScheduledQuery3D, DeterministicPhysicsShape3D, DeterministicPhysicsShapeCastQuery3D, DeterministicPhysicsStepResult3D, DeterministicPhysicsVec3, DeterministicPhysicsWorld3D, DeterministicPhysicsWorldEdit3D } from './physics3d.js';
76
78
  export type { DeterministicVoxelChunk3D, DeterministicVoxelCompoundShape3D } from './physics3d-voxel.js';