@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 +84 -0
- package/dist/browser.d.ts +11 -2
- package/dist/browser.js +11 -1
- package/dist/browserSession.d.ts +44 -0
- package/dist/browserSession.js +70 -0
- package/dist/coarse-region.d.ts +20 -0
- package/dist/coarse-region.js +87 -0
- package/dist/dev-authority-room.d.ts +14 -0
- package/dist/dev-authority-room.js +227 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/networked-client.d.ts +51 -6
- package/dist/networked-client.js +266 -23
- package/dist/node.d.ts +1 -0
- package/dist/node.js +3 -0
- package/dist/replay.d.ts +8 -7
- package/dist/run-room-transport.d.ts +100 -0
- package/dist/run-room-transport.js +280 -0
- package/dist/runner.d.ts +64 -0
- package/dist/runner.js +291 -0
- package/dist/synctest.d.ts +5 -5
- package/dist/testing-network.d.ts +24 -0
- package/dist/testing-network.js +92 -0
- package/dist/testing.d.ts +34 -1
- package/dist/testing.js +88 -1
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/vite-plugin.d.ts +40 -0
- package/dist/tools/vite-plugin.js +100 -0
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -23,7 +23,8 @@ export { rewindDeterministicTargets, runDeterministicMovementCrowdSimulation, ru
|
|
|
23
23
|
export { stepDeterministicCarry3D, stepDeterministicKcc3D } from './movement3d.js';
|
|
24
24
|
export { applyDeterministicReciprocalAvoidance, cookDeterministicNavigationGraph, cookTilemapNavigationGraph, createDeterministicFlowField, findDeterministicPath, smoothDeterministicPath, stepDeterministicNavigationAgents } from './navigation.js';
|
|
25
25
|
export { cookDeterministicNavmesh, cookImportedDeterministicNavmesh, findDeterministicNavmeshPath, findDeterministicNavmeshPointPath, locateDeterministicNavmeshPolygon, runDeterministicNavmeshStress, smoothDeterministicNavmeshPath, stepDeterministicNavmeshAgents, stepDeterministicNavmeshOffMeshTraversal } from './navmesh.js';
|
|
26
|
-
export { createNetworkedSyncplayClient } from './networked-client.js';
|
|
26
|
+
export { createNetworkedSyncplayClient, prepareNetworkedSyncplayTransport } from './networked-client.js';
|
|
27
|
+
export { createSyncplayRunner } from './runner.js';
|
|
27
28
|
export { fbm2D, hash2, hashUint32, valueNoise1D, valueNoise2D } from './noise.js';
|
|
28
29
|
export { circleCastDeterministicPhysics2D, createDeterministicPhysicsWorld2D, nearestDeterministicPhysicsHit2D, queryDeterministicPhysicsAabb2D, queryDeterministicPhysicsShapeOverlap2D, raycastAllDeterministicPhysics2D, raycastDeterministicPhysics2D, stepDeterministicPhysicsWorld2D } from './physics2d.js';
|
|
29
30
|
export { applyDeterministicImpulse3D, applyDeterministicImpulses3D, applyDeterministicWorldEdit3D, contactDetailsDeterministicPhysics3D, createDeterministicPhysicsWorld3D, createTrustedDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, deterministicPhysicsCallback3D, overlapDeterministicPhysics3D, raycastDeterministicPhysics3D, raycastDeterministicPhysics3DDetailed, runDeterministicPhysics3DStress, setDeterministicBodyVelocity3D, shapeCastDeterministicPhysics3D, shapeCastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D } from './physics3d.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EncodedInput } from './input-authority.js';
|
|
2
2
|
import { type DeterministicSessionRole } from './session-wire.js';
|
|
3
|
-
import { type
|
|
3
|
+
import { type KinetixRuntimeIdentity, type ProjectionCapableRuntime } from './runtime-session.js';
|
|
4
4
|
import type { ReplayFile } from './types.js';
|
|
5
5
|
import { type SyncplaySecretsClient } from './secret-client.js';
|
|
6
6
|
import type { SyncplaySecretBrowserCrypto } from './secret-crypto.js';
|
|
@@ -21,11 +21,18 @@ export interface NetworkedClientTransport {
|
|
|
21
21
|
onSecretMessage?(handler: (text: string) => void): void;
|
|
22
22
|
readonly playerId?: string;
|
|
23
23
|
}
|
|
24
|
-
export type KinetixRuntimeFactory<State, Input, Checkpoint = unknown> = (identity: KinetixRuntimeIdentity, sessionConfigBytes: Uint8Array) =>
|
|
25
|
-
export interface NetworkedSyncplayClientOptions<State, Input, Checkpoint = unknown> {
|
|
24
|
+
export type KinetixRuntimeFactory<State, Input, Checkpoint = unknown, Projection = State> = (identity: KinetixRuntimeIdentity, sessionConfigBytes: Uint8Array) => ProjectionCapableRuntime<State, Input, Checkpoint, Projection>;
|
|
25
|
+
export interface NetworkedSyncplayClientOptions<State, Input, Checkpoint = unknown, Projection = State> {
|
|
26
26
|
readonly transport: NetworkedClientTransport;
|
|
27
|
-
readonly runtimeFactory: KinetixRuntimeFactory<State, Input, Checkpoint>;
|
|
27
|
+
readonly runtimeFactory: KinetixRuntimeFactory<State, Input, Checkpoint, Projection>;
|
|
28
28
|
readonly defaultInput: Input;
|
|
29
|
+
/**
|
|
30
|
+
* Enables a bounded queue of authority-confirmed presentation frames drained
|
|
31
|
+
* via {@link NetworkedSyncplayClient.drainConfirmedPresentationFrames}. Absent
|
|
32
|
+
* means no queue and preserves existing client cost. Must be a positive safe
|
|
33
|
+
* integer.
|
|
34
|
+
*/
|
|
35
|
+
readonly confirmedPresentationBufferSize?: number;
|
|
29
36
|
readonly snapshotBufferSize?: number;
|
|
30
37
|
readonly checksumIntervalFrames?: number;
|
|
31
38
|
/** This client's own input for a tick (game-defined). */
|
|
@@ -107,7 +114,29 @@ export interface NetworkedSyncplayClientNetStats {
|
|
|
107
114
|
readonly maxRollbackDepth: number;
|
|
108
115
|
}
|
|
109
116
|
export type NetworkedSyncplaySlotPresence = 'human' | 'substituted' | 'empty' | 'unknown';
|
|
110
|
-
export interface
|
|
117
|
+
export interface ConfirmedSyncplayPresentationFrame<Projection> {
|
|
118
|
+
readonly frame: number;
|
|
119
|
+
readonly projection: Readonly<Projection>;
|
|
120
|
+
}
|
|
121
|
+
export interface NetworkedSyncplaySessionDescriptor {
|
|
122
|
+
readonly slot: number;
|
|
123
|
+
readonly playerCount: number;
|
|
124
|
+
readonly seed: number;
|
|
125
|
+
readonly tickRateHz: number;
|
|
126
|
+
readonly hardToleranceTicks: number;
|
|
127
|
+
readonly role: DeterministicSessionRole;
|
|
128
|
+
readonly runtimeIdentity: KinetixRuntimeIdentity;
|
|
129
|
+
readonly sessionConfigBytes: Uint8Array;
|
|
130
|
+
readonly sessionConfigDigest: string;
|
|
131
|
+
}
|
|
132
|
+
export interface PreparedNetworkedSyncplayTransport {
|
|
133
|
+
readonly descriptor: NetworkedSyncplaySessionDescriptor;
|
|
134
|
+
readonly transport: NetworkedClientTransport;
|
|
135
|
+
}
|
|
136
|
+
export interface NetworkedSyncplayPreparationOptions {
|
|
137
|
+
readonly signal?: AbortSignal;
|
|
138
|
+
}
|
|
139
|
+
export interface NetworkedSyncplayClient<Projection = unknown> {
|
|
111
140
|
readonly slot: number;
|
|
112
141
|
/** 'player' (holds a slot) or 'spectator' (read-only follower, slot -1). From session-start. */
|
|
113
142
|
readonly role: DeterministicSessionRole;
|
|
@@ -138,5 +167,21 @@ export interface NetworkedSyncplayClient {
|
|
|
138
167
|
/** Confirmed-state checksum at `frame` (must be <= appliedThrough + 1). */
|
|
139
168
|
confirmedStateChecksum(frame: number): string;
|
|
140
169
|
exportReplay(): ReplayFile;
|
|
170
|
+
readonly currentFrame: number;
|
|
171
|
+
readonly renderAlpha: number;
|
|
172
|
+
readonly caughtUp: boolean;
|
|
173
|
+
getPresentationFrame(): Readonly<Projection>;
|
|
174
|
+
drainConfirmedPresentationFrames(): readonly ConfirmedSyncplayPresentationFrame<Projection>[];
|
|
175
|
+
beginPresentationCatchUp(): void;
|
|
176
|
+
dispose(): void;
|
|
141
177
|
}
|
|
142
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Attaches to the ordered transport, awaits and validates the first
|
|
180
|
+
* `session-start`, exposes an immutable descriptor with detached session-config
|
|
181
|
+
* bytes, buffers subsequent public messages (same 8,192-message ceiling as the
|
|
182
|
+
* SDK transport), awaits `prepare`, then returns a replaying transport for
|
|
183
|
+
* {@link createNetworkedSyncplayClient}. Rejects on abort, terminal
|
|
184
|
+
* session-close/error, duplicate handler attachment, or overflow.
|
|
185
|
+
*/
|
|
186
|
+
export declare function prepareNetworkedSyncplayTransport(transport: NetworkedClientTransport, prepare: (descriptor: NetworkedSyncplaySessionDescriptor, signal: AbortSignal) => Promise<void>, options?: NetworkedSyncplayPreparationOptions): Promise<PreparedNetworkedSyncplayTransport>;
|
|
187
|
+
export declare function createNetworkedSyncplayClient<State, Input, Checkpoint = unknown, Projection = State>(options: NetworkedSyncplayClientOptions<State, Input, Checkpoint, Projection>): NetworkedSyncplayClient<Projection>;
|
package/dist/networked-client.js
CHANGED
|
@@ -39,6 +39,141 @@ function equalBytes(left, right) {
|
|
|
39
39
|
}
|
|
40
40
|
return true;
|
|
41
41
|
}
|
|
42
|
+
const MAX_RUNTIME_PREPARATION_MESSAGES = 8_192;
|
|
43
|
+
function immutableDescriptor(message) {
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
slot: message.slot,
|
|
46
|
+
playerCount: message.playerCount,
|
|
47
|
+
seed: message.seed,
|
|
48
|
+
tickRateHz: message.tickRateHz,
|
|
49
|
+
hardToleranceTicks: message.hardToleranceTicks,
|
|
50
|
+
role: message.role,
|
|
51
|
+
runtimeIdentity: Object.freeze({ ...message.runtimeIdentity }),
|
|
52
|
+
sessionConfigBytes: new Uint8Array(message.sessionConfigBytes),
|
|
53
|
+
sessionConfigDigest: message.sessionConfigDigest,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Attaches to the ordered transport, awaits and validates the first
|
|
58
|
+
* `session-start`, exposes an immutable descriptor with detached session-config
|
|
59
|
+
* bytes, buffers subsequent public messages (same 8,192-message ceiling as the
|
|
60
|
+
* SDK transport), awaits `prepare`, then returns a replaying transport for
|
|
61
|
+
* {@link createNetworkedSyncplayClient}. Rejects on abort, terminal
|
|
62
|
+
* session-close/error, duplicate handler attachment, or overflow.
|
|
63
|
+
*/
|
|
64
|
+
export async function prepareNetworkedSyncplayTransport(transport, prepare, options = {}) {
|
|
65
|
+
const localController = new AbortController();
|
|
66
|
+
const signal = options.signal ?? localController.signal;
|
|
67
|
+
const buffered = [];
|
|
68
|
+
let descriptorValue;
|
|
69
|
+
// A single gate for descriptor acquisition (resolve on session-start, reject on
|
|
70
|
+
// terminal/abort) keeps the descriptor→prepare handoff one microtask deep, so a
|
|
71
|
+
// caller that flushes a single microtask sees preparation already started.
|
|
72
|
+
let resolveDescriptor;
|
|
73
|
+
let rejectDescriptor;
|
|
74
|
+
const descriptorGate = new Promise((resolve, reject) => {
|
|
75
|
+
resolveDescriptor = resolve;
|
|
76
|
+
rejectDescriptor = reject;
|
|
77
|
+
});
|
|
78
|
+
void descriptorGate.catch(() => undefined);
|
|
79
|
+
let rejectTerminal;
|
|
80
|
+
const terminalPromise = new Promise((_resolve, reject) => {
|
|
81
|
+
rejectTerminal = reject;
|
|
82
|
+
});
|
|
83
|
+
void terminalPromise.catch(() => undefined);
|
|
84
|
+
let failure;
|
|
85
|
+
let preparationComplete = false;
|
|
86
|
+
let clientHandler;
|
|
87
|
+
// Tracks attachment independently of clientHandler: clientHandler is published
|
|
88
|
+
// only AFTER the replay drains (so re-entrant deliveries buffer in FIFO), but
|
|
89
|
+
// a second attach must still fail loud even if a replayed message threw
|
|
90
|
+
// mid-drain and left clientHandler unpublished.
|
|
91
|
+
let handlerAttached = false;
|
|
92
|
+
const fail = (error) => {
|
|
93
|
+
if (failure !== undefined || clientHandler !== undefined)
|
|
94
|
+
return;
|
|
95
|
+
failure = error;
|
|
96
|
+
if (!preparationComplete) {
|
|
97
|
+
rejectDescriptor(error);
|
|
98
|
+
rejectTerminal(error);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const abort = () => fail(new Error('SYNCPLAY_RUNTIME_PREPARATION_ABORTED'));
|
|
102
|
+
if (signal.aborted)
|
|
103
|
+
abort();
|
|
104
|
+
else
|
|
105
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
106
|
+
transport.onMessage((text) => {
|
|
107
|
+
if (clientHandler !== undefined) {
|
|
108
|
+
clientHandler(text);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (failure !== undefined)
|
|
112
|
+
return;
|
|
113
|
+
if (buffered.length >= MAX_RUNTIME_PREPARATION_MESSAGES) {
|
|
114
|
+
fail(new Error('SYNCPLAY_RUNTIME_PREPARATION_BUFFER_OVERFLOW'));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
buffered.push(text);
|
|
118
|
+
const decoded = decodeDeterministicSessionMessage(text);
|
|
119
|
+
if (!decoded.ok)
|
|
120
|
+
return;
|
|
121
|
+
if (decoded.message.kind === 'error') {
|
|
122
|
+
fail(new Error(decoded.message.message));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (decoded.message.kind === 'close') {
|
|
126
|
+
fail(new Error(decoded.message.reason));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (decoded.message.kind !== 'session-start')
|
|
130
|
+
return;
|
|
131
|
+
if (descriptorValue !== undefined) {
|
|
132
|
+
fail(new Error('SYNCPLAY_RUNTIME_PREPARATION_SESSION_RESTARTED'));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
descriptorValue = immutableDescriptor(decoded.message);
|
|
136
|
+
resolveDescriptor(descriptorValue);
|
|
137
|
+
});
|
|
138
|
+
try {
|
|
139
|
+
const descriptor = await descriptorGate;
|
|
140
|
+
await Promise.race([prepare(descriptor, signal), terminalPromise]);
|
|
141
|
+
if (failure !== undefined)
|
|
142
|
+
throw failure;
|
|
143
|
+
preparationComplete = true;
|
|
144
|
+
const replayingTransport = {
|
|
145
|
+
send: (text) => transport.send(text),
|
|
146
|
+
onMessage: (handler) => {
|
|
147
|
+
if (handlerAttached) {
|
|
148
|
+
throw new Error('SYNCPLAY_RUNTIME_PREPARATION_HANDLER_ALREADY_ATTACHED');
|
|
149
|
+
}
|
|
150
|
+
if (failure !== undefined)
|
|
151
|
+
throw failure;
|
|
152
|
+
handlerAttached = true;
|
|
153
|
+
// Drain the buffer BEFORE publishing clientHandler: while it is still
|
|
154
|
+
// undefined, a message the client emits synchronously during replay
|
|
155
|
+
// loops back through the source onMessage into `buffered` (FIFO) instead
|
|
156
|
+
// of jumping ahead of the not-yet-replayed tail.
|
|
157
|
+
while (buffered.length > 0) {
|
|
158
|
+
const text = buffered.shift();
|
|
159
|
+
handler(text);
|
|
160
|
+
}
|
|
161
|
+
clientHandler = handler;
|
|
162
|
+
},
|
|
163
|
+
...(transport.sendSecret === undefined
|
|
164
|
+
? {}
|
|
165
|
+
: { sendSecret: (text) => transport.sendSecret(text) }),
|
|
166
|
+
...(transport.onSecretMessage === undefined
|
|
167
|
+
? {}
|
|
168
|
+
: { onSecretMessage: (handler) => transport.onSecretMessage(handler) }),
|
|
169
|
+
...(transport.playerId === undefined ? {} : { playerId: transport.playerId }),
|
|
170
|
+
};
|
|
171
|
+
return Object.freeze({ descriptor, transport: replayingTransport });
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
signal.removeEventListener('abort', abort);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
42
177
|
export function createNetworkedSyncplayClient(options) {
|
|
43
178
|
const sessionMsgType = options.sessionMsgType ?? DEFAULT_MSG_TYPE;
|
|
44
179
|
const maxPredictionTicks = options.maxPredictionTicks ?? DEFAULT_MAX_PREDICTION;
|
|
@@ -59,6 +194,7 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
59
194
|
const inputDelayMaxTicks = Math.max(options.inputDelay?.maxTicks ?? inputDelayMinTicks, inputDelayMinTicks);
|
|
60
195
|
const wallClock = options.now ?? (() => Date.now());
|
|
61
196
|
let session;
|
|
197
|
+
let activeRuntime;
|
|
62
198
|
let slot = -1;
|
|
63
199
|
let role = 'player';
|
|
64
200
|
let seed = 0;
|
|
@@ -67,6 +203,17 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
67
203
|
let appliedThrough = -1;
|
|
68
204
|
let rollbacks = 0;
|
|
69
205
|
let maxRollbackDepth = 0;
|
|
206
|
+
let hardToleranceTicks = 0;
|
|
207
|
+
let latestAuthorityTick = -1;
|
|
208
|
+
let caughtUp = false;
|
|
209
|
+
let presentationCatchUpFloor;
|
|
210
|
+
let disposed = false;
|
|
211
|
+
const confirmedPresentationFrames = [];
|
|
212
|
+
const confirmedPresentationBufferSize = options.confirmedPresentationBufferSize;
|
|
213
|
+
if (confirmedPresentationBufferSize !== undefined
|
|
214
|
+
&& (!Number.isSafeInteger(confirmedPresentationBufferSize) || confirmedPresentationBufferSize < 1)) {
|
|
215
|
+
throw new Error('SYNCPLAY_CONFIRMED_PROJECTION_BUFFER_INVALID');
|
|
216
|
+
}
|
|
70
217
|
// ── G1/G2 pacing state (only advanced by pumpPaced; inert under raw pump) ──
|
|
71
218
|
let tickRateHz = 0;
|
|
72
219
|
let frameMs = 0;
|
|
@@ -111,10 +258,25 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
111
258
|
const outgoingTransferQueue = [];
|
|
112
259
|
let donorTransferCounter = 0;
|
|
113
260
|
let readyResolve;
|
|
114
|
-
|
|
261
|
+
let readyReject;
|
|
262
|
+
const readyPromise = new Promise((resolve, reject) => {
|
|
115
263
|
readyResolve = resolve;
|
|
264
|
+
readyReject = reject;
|
|
116
265
|
});
|
|
266
|
+
void readyPromise.catch(() => undefined);
|
|
267
|
+
function assertActiveClient() {
|
|
268
|
+
if (disposed)
|
|
269
|
+
throw new Error('SYNCPLAY_CLIENT_DISPOSED');
|
|
270
|
+
}
|
|
271
|
+
function requireSession() {
|
|
272
|
+
assertActiveClient();
|
|
273
|
+
if (session === undefined)
|
|
274
|
+
throw new Error('SYNCPLAY_CLIENT_NOT_READY');
|
|
275
|
+
return session;
|
|
276
|
+
}
|
|
117
277
|
options.transport.onMessage((text) => {
|
|
278
|
+
if (disposed)
|
|
279
|
+
return;
|
|
118
280
|
const decoded = decodeDeterministicSessionMessage(text);
|
|
119
281
|
if (!decoded.ok) {
|
|
120
282
|
// The other frozen wire family (snapshot negotiation) rides the same
|
|
@@ -134,6 +296,15 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
134
296
|
role = message.role;
|
|
135
297
|
seed = message.seed;
|
|
136
298
|
playerCount = message.playerCount;
|
|
299
|
+
hardToleranceTicks = message.hardToleranceTicks;
|
|
300
|
+
latestAuthorityTick = -1;
|
|
301
|
+
caughtUp = false;
|
|
302
|
+
// A re-greet resets the tick space; a catch-up floor from the prior session
|
|
303
|
+
// is meaningless here (it would suppress `caughtUp` until the new session
|
|
304
|
+
// re-reached an old tick number). The runner's own recovery flag bridges
|
|
305
|
+
// event-silence across the re-hydrate.
|
|
306
|
+
presentationCatchUpFloor = undefined;
|
|
307
|
+
confirmedPresentationFrames.length = 0;
|
|
137
308
|
lastConfirmedBySlot = Array.from({ length: playerCount }, () => null);
|
|
138
309
|
confirmedPlayerPresence = unknownPresence();
|
|
139
310
|
appliedThrough = -1;
|
|
@@ -168,6 +339,7 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
168
339
|
});
|
|
169
340
|
}
|
|
170
341
|
session?.dispose();
|
|
342
|
+
activeRuntime = runtime;
|
|
171
343
|
session = createSyncplaySession(runtime, {
|
|
172
344
|
playerCount,
|
|
173
345
|
defaultInput: options.defaultInput,
|
|
@@ -185,7 +357,7 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
185
357
|
}
|
|
186
358
|
else if (message.kind === 'confirmed-input') {
|
|
187
359
|
observeAuthorityTick(message.authorityTick, wallClock());
|
|
188
|
-
receiveConfirmedFrames(message.frames);
|
|
360
|
+
receiveConfirmedFrames(message.frames, message.authorityTick);
|
|
189
361
|
}
|
|
190
362
|
else if (message.kind === 'time-sync-pong') {
|
|
191
363
|
handleTimeSyncPong(message.nonce, message.authorityTick);
|
|
@@ -399,6 +571,7 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
399
571
|
}
|
|
400
572
|
}
|
|
401
573
|
function pump() {
|
|
574
|
+
assertActiveClient();
|
|
402
575
|
if (secretFailure)
|
|
403
576
|
throw secretFailure;
|
|
404
577
|
if (session === undefined) {
|
|
@@ -410,6 +583,7 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
410
583
|
}
|
|
411
584
|
}
|
|
412
585
|
function pumpPaced(nowMs) {
|
|
586
|
+
assertActiveClient();
|
|
413
587
|
if (secretFailure)
|
|
414
588
|
throw secretFailure;
|
|
415
589
|
flushOutgoingTransfer();
|
|
@@ -447,36 +621,37 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
447
621
|
options.transport.send(encodeDeterministicSessionMessage({ kind: 'input', slot, tick, input, attempt: 0 }));
|
|
448
622
|
}
|
|
449
623
|
function confirmedStateChecksum(frame) {
|
|
450
|
-
const active =
|
|
451
|
-
if (active === undefined) {
|
|
452
|
-
return '';
|
|
453
|
-
}
|
|
624
|
+
const active = requireSession();
|
|
454
625
|
active.restoreToFrame(frame);
|
|
455
626
|
return active.getWireStateChecksum(frame);
|
|
456
627
|
}
|
|
457
|
-
function receiveConfirmedFrames(frames) {
|
|
628
|
+
function receiveConfirmedFrames(frames, authorityTick) {
|
|
458
629
|
const containsSecretCommands = frames.some((frame) => (frame.commands ?? []).some((command) => (command !== null && typeof command === 'object' && !Array.isArray(command)
|
|
459
630
|
&& typeof command.kind === 'string'
|
|
460
631
|
&& command.kind.startsWith('syncplay.'))));
|
|
461
632
|
if (!containsSecretCommands && confirmationQueue === undefined) {
|
|
462
|
-
applyConfirmedFrames(frames);
|
|
633
|
+
applyConfirmedFrames(frames, authorityTick);
|
|
463
634
|
return;
|
|
464
635
|
}
|
|
465
636
|
const prior = confirmationQueue ?? Promise.resolve();
|
|
466
637
|
const next = prior.then(async () => {
|
|
467
638
|
for (const frame of frames)
|
|
468
639
|
await secrets.acceptConfirmedCommands(frame.commands ?? []);
|
|
469
|
-
applyConfirmedFrames(frames);
|
|
640
|
+
applyConfirmedFrames(frames, authorityTick);
|
|
470
641
|
});
|
|
471
642
|
confirmationQueue = next;
|
|
472
|
-
void next.catch((
|
|
473
|
-
secretFailure =
|
|
643
|
+
void next.catch((caught) => {
|
|
644
|
+
secretFailure = caught instanceof Error ? caught : new Error('signature-invalid');
|
|
474
645
|
}).finally(() => {
|
|
475
646
|
if (confirmationQueue === next)
|
|
476
647
|
confirmationQueue = undefined;
|
|
477
648
|
});
|
|
478
649
|
}
|
|
479
|
-
function applyConfirmedFrames(frames) {
|
|
650
|
+
function applyConfirmedFrames(frames, authorityTick) {
|
|
651
|
+
const firstNewTick = frames.find((frame) => frame.tick > appliedThrough)?.tick;
|
|
652
|
+
const presentationWasLive = caughtUp;
|
|
653
|
+
const freshRoomFirstFrame = appliedThrough === -1 && firstNewTick === 0 && authorityTick === 0;
|
|
654
|
+
latestAuthorityTick = Math.max(latestAuthorityTick, authorityTick);
|
|
480
655
|
for (const frame of frames) {
|
|
481
656
|
if (frame.tick > appliedThrough) {
|
|
482
657
|
confirmed.set(frame.tick, {
|
|
@@ -496,25 +671,52 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
496
671
|
if (predFrame === undefined || canonicalStringify(simulationFrame) !== canonicalStringify(predFrame)) {
|
|
497
672
|
timeline[tick] = { inputs: confInputs.slice(), commands: confFrame.commands };
|
|
498
673
|
if (predictedThrough > tick) {
|
|
499
|
-
// Rollback: we had already predicted this tick and the confirmed inputs differ.
|
|
500
674
|
rollbacks += 1;
|
|
501
675
|
maxRollbackDepth = Math.max(maxRollbackDepth, predictedThrough - tick);
|
|
502
676
|
simulate(tick);
|
|
503
677
|
}
|
|
504
678
|
else {
|
|
505
|
-
// Confirm leads prediction (a lagging client): the authority confirmed a tick we had not
|
|
506
|
-
// predicted yet. Extend the predicted head and apply the confirmed inputs so the session
|
|
507
|
-
// actually advances through it — otherwise appliedThrough would outrun the simulated state.
|
|
508
679
|
predictedThrough = tick + 1;
|
|
509
680
|
simulate(tick);
|
|
510
681
|
}
|
|
511
682
|
}
|
|
512
|
-
for (let
|
|
513
|
-
lastConfirmedBySlot[
|
|
683
|
+
for (let playerSlot = 0; playerSlot < playerCount; playerSlot += 1) {
|
|
684
|
+
lastConfirmedBySlot[playerSlot] = confInputs[playerSlot];
|
|
514
685
|
}
|
|
515
686
|
appliedThrough = tick;
|
|
516
687
|
confirmedPlayerPresence = classifyPresence(confFrame);
|
|
688
|
+
const presentationEligible = presentationWasLive || (freshRoomFirstFrame && tick === 0);
|
|
689
|
+
if (presentationEligible
|
|
690
|
+
&& confirmedPresentationBufferSize !== undefined && activeRuntime?.project !== undefined) {
|
|
691
|
+
// Bounded ring: drop the oldest when full rather than throw. A consumer
|
|
692
|
+
// that stalls (e.g. a backgrounded tab whose rAF-driven drain pauses
|
|
693
|
+
// while the socket keeps delivering) must shed the debt and stay at the
|
|
694
|
+
// live tail, not throw uncaught out of the transport dispatch and leave a
|
|
695
|
+
// permanent gap. A single post-live batch fits well under the bound.
|
|
696
|
+
while (confirmedPresentationFrames.length >= confirmedPresentationBufferSize) {
|
|
697
|
+
confirmedPresentationFrames.shift();
|
|
698
|
+
}
|
|
699
|
+
const frame = tick + 1;
|
|
700
|
+
const selected = requireSession().peekSnapshot(frame);
|
|
701
|
+
const previous = requireSession().peekSnapshot(frame - 1);
|
|
702
|
+
if (selected === undefined)
|
|
703
|
+
throw new Error('SYNCPLAY_CONFIRMED_PROJECTION_MISSING');
|
|
704
|
+
confirmedPresentationFrames.push(Object.freeze({
|
|
705
|
+
frame,
|
|
706
|
+
projection: activeRuntime.project(selected.checkpoint, previous?.checkpoint),
|
|
707
|
+
}));
|
|
708
|
+
}
|
|
517
709
|
}
|
|
710
|
+
// `caughtUp` requires having actually observed an authority tick — a joiner
|
|
711
|
+
// that hydrates from a snapshot before any confirmed-input broadcast has
|
|
712
|
+
// latestAuthorityTick === -1 and is not yet at any real boundary.
|
|
713
|
+
const reachedAuthorityBoundary = appliedThrough >= 0 && latestAuthorityTick >= 0
|
|
714
|
+
&& appliedThrough >= latestAuthorityTick - hardToleranceTicks;
|
|
715
|
+
if (presentationCatchUpFloor !== undefined
|
|
716
|
+
&& reachedAuthorityBoundary && appliedThrough > presentationCatchUpFloor) {
|
|
717
|
+
presentationCatchUpFloor = undefined;
|
|
718
|
+
}
|
|
719
|
+
caughtUp = reachedAuthorityBoundary && presentationCatchUpFloor === undefined;
|
|
518
720
|
autoReportCrossedCadences();
|
|
519
721
|
}
|
|
520
722
|
function unknownPresence() {
|
|
@@ -754,7 +956,7 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
754
956
|
lastAutoReportedTick = Math.max(lastAutoReportedTick, snapshot.tick);
|
|
755
957
|
// Frames that arrived before hydration are buffered in `confirmed` — the
|
|
756
958
|
// normal contiguous-apply drains the tail from here.
|
|
757
|
-
applyConfirmedFrames([]);
|
|
959
|
+
applyConfirmedFrames([], latestAuthorityTick);
|
|
758
960
|
}
|
|
759
961
|
return {
|
|
760
962
|
get slot() {
|
|
@@ -788,12 +990,56 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
788
990
|
};
|
|
789
991
|
},
|
|
790
992
|
secrets,
|
|
993
|
+
get currentFrame() {
|
|
994
|
+
return requireSession().currentFrame;
|
|
995
|
+
},
|
|
996
|
+
get caughtUp() {
|
|
997
|
+
return session !== undefined && caughtUp;
|
|
998
|
+
},
|
|
999
|
+
get renderAlpha() {
|
|
1000
|
+
requireSession();
|
|
1001
|
+
return frameMs > 0 ? pacingAccumulatorMs / frameMs : 0;
|
|
1002
|
+
},
|
|
1003
|
+
getPresentationFrame() {
|
|
1004
|
+
return requireSession().getPresentationFrame();
|
|
1005
|
+
},
|
|
1006
|
+
drainConfirmedPresentationFrames() {
|
|
1007
|
+
requireSession();
|
|
1008
|
+
const drained = confirmedPresentationFrames.splice(0, confirmedPresentationFrames.length);
|
|
1009
|
+
return Object.freeze(drained);
|
|
1010
|
+
},
|
|
1011
|
+
beginPresentationCatchUp() {
|
|
1012
|
+
assertActiveClient();
|
|
1013
|
+
presentationCatchUpFloor = appliedThrough;
|
|
1014
|
+
caughtUp = false;
|
|
1015
|
+
confirmedPresentationFrames.length = 0;
|
|
1016
|
+
},
|
|
1017
|
+
dispose() {
|
|
1018
|
+
if (disposed)
|
|
1019
|
+
return;
|
|
1020
|
+
disposed = true;
|
|
1021
|
+
session?.dispose();
|
|
1022
|
+
session = undefined;
|
|
1023
|
+
activeRuntime = undefined;
|
|
1024
|
+
caughtUp = false;
|
|
1025
|
+
presentationCatchUpFloor = undefined;
|
|
1026
|
+
latestAuthorityTick = -1;
|
|
1027
|
+
confirmedPresentationFrames.length = 0;
|
|
1028
|
+
outgoingTransferQueue.length = 0;
|
|
1029
|
+
pendingPings.clear();
|
|
1030
|
+
const error = new Error('SYNCPLAY_CLIENT_DISPOSED');
|
|
1031
|
+
readyReject?.(error);
|
|
1032
|
+
readyResolve = undefined;
|
|
1033
|
+
readyReject = undefined;
|
|
1034
|
+
},
|
|
791
1035
|
whenReady() {
|
|
1036
|
+
assertActiveClient();
|
|
792
1037
|
return readyPromise;
|
|
793
1038
|
},
|
|
794
1039
|
pump,
|
|
795
1040
|
pumpPaced,
|
|
796
1041
|
reportChecksum() {
|
|
1042
|
+
assertActiveClient();
|
|
797
1043
|
if (session === undefined || appliedThrough < 0) {
|
|
798
1044
|
return;
|
|
799
1045
|
}
|
|
@@ -801,10 +1047,7 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
801
1047
|
},
|
|
802
1048
|
confirmedStateChecksum,
|
|
803
1049
|
exportReplay() {
|
|
804
|
-
|
|
805
|
-
throw new Error('networked client has not started');
|
|
806
|
-
}
|
|
807
|
-
return session.exportReplay();
|
|
1050
|
+
return requireSession().exportReplay();
|
|
808
1051
|
},
|
|
809
1052
|
};
|
|
810
1053
|
}
|
package/dist/node.d.ts
CHANGED
|
@@ -20,3 +20,4 @@ export type { SyncplaySecretAuthorityCrypto, SyncplaySecretBrowserCrypto } from
|
|
|
20
20
|
export * from './ws-frame.js';
|
|
21
21
|
export { assertCookedColliderIsRuntimeValid, cookConvexDecomposition, } from './collider-cooking.js';
|
|
22
22
|
export type { ColliderCookOptions, ColliderSourceMesh, CookedCollider, CookedColliderHull, } from './collider-cooking.js';
|
|
23
|
+
export { DevSyncplayAuthorityRoom } from './dev-authority-room.js';
|
package/dist/node.js
CHANGED
|
@@ -16,3 +16,6 @@ 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 { assertCookedColliderIsRuntimeValid, cookConvexDecomposition, } from './collider-cooking.js';
|
|
19
|
+
// Dev-server deterministic room class (relocated from the game SDK). A game's
|
|
20
|
+
// vite.config passes this to rundotMultiplayerPlugin({ deterministicRoomClass }).
|
|
21
|
+
export { DevSyncplayAuthorityRoom } from './dev-authority-room.js';
|
package/dist/replay.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type KinetixRuntimeIdentity } from '@series-inc/rundot-kinetix/runtime';
|
|
2
2
|
import { type SyncplaySessionPolicy } from './sdk-session.js';
|
|
3
|
+
import type { ProjectionCapableRuntime } from './runtime-session.js';
|
|
3
4
|
import { type SyncplaySecretCommand } from './secret-protocol.js';
|
|
4
5
|
import type { ReplayFile, ReplayVerificationResult } from './types.js';
|
|
5
|
-
export type ReplayRuntimeFactory<State, Input, Checkpoint = unknown> = (identity: KinetixRuntimeIdentity, sessionConfigBytes: Uint8Array) =>
|
|
6
|
-
export interface ReplayVerificationConfig<State, Input, Checkpoint = unknown> {
|
|
6
|
+
export type ReplayRuntimeFactory<State, Input, Checkpoint = unknown, Projection = State> = (identity: KinetixRuntimeIdentity, sessionConfigBytes: Uint8Array) => ProjectionCapableRuntime<State, Input, Checkpoint, Projection>;
|
|
7
|
+
export interface ReplayVerificationConfig<State, Input, Checkpoint = unknown, Projection = State> {
|
|
7
8
|
readonly replay: ReplayFile;
|
|
8
|
-
readonly runtimeFactory: ReplayRuntimeFactory<State, Input, Checkpoint>;
|
|
9
|
-
readonly policy: SyncplaySessionPolicy<State, Input, Checkpoint>;
|
|
9
|
+
readonly runtimeFactory: ReplayRuntimeFactory<State, Input, Checkpoint, Projection>;
|
|
10
|
+
readonly policy: SyncplaySessionPolicy<State, Input, Checkpoint, Projection>;
|
|
10
11
|
readonly decodeInput?: (recordedInput: unknown, slot: number) => Input;
|
|
11
12
|
readonly throwOnMismatch?: boolean;
|
|
12
13
|
readonly expectedAuthorityId?: string;
|
|
@@ -15,6 +16,6 @@ export interface ReplayVerificationConfig<State, Input, Checkpoint = unknown> {
|
|
|
15
16
|
export interface ReplayVerificationExecution<State> extends ReplayVerificationResult {
|
|
16
17
|
readonly finalState: State;
|
|
17
18
|
}
|
|
18
|
-
export declare function verifyReplay<State, Input, Checkpoint = unknown>(config: ReplayVerificationConfig<State, Input, Checkpoint>): ReplayVerificationResult;
|
|
19
|
-
export declare function executeReplay<State, Input, Checkpoint = unknown>(config: ReplayVerificationConfig<State, Input, Checkpoint>): ReplayVerificationExecution<State>;
|
|
19
|
+
export declare function verifyReplay<State, Input, Checkpoint = unknown, Projection = State>(config: ReplayVerificationConfig<State, Input, Checkpoint, Projection>): ReplayVerificationResult;
|
|
20
|
+
export declare function executeReplay<State, Input, Checkpoint = unknown, Projection = State>(config: ReplayVerificationConfig<State, Input, Checkpoint, Projection>): ReplayVerificationExecution<State>;
|
|
20
21
|
export declare function assertReplayFileStructure(replay: ReplayFile): void;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable, production-shaped client surface for deterministic multiplayer.
|
|
3
|
+
*
|
|
4
|
+
* A game joins a deterministic room by code (or creates one) and gets back a
|
|
5
|
+
* {@link NetworkedClientTransport} — the exact socket-like surface the
|
|
6
|
+
* deterministic engine's `createNetworkedSyncplayClient` consumes. There is
|
|
7
|
+
* NO per-game server logic: the server-side deterministic room (built-in input
|
|
8
|
+
* authority) is generic, and this transport is a thin, opaque pass-through of the
|
|
9
|
+
* frozen `rdm-local-authority/v2` session envelope over the `rdm` room channel.
|
|
10
|
+
*
|
|
11
|
+
* The room's raw channel ({@link ServerRoom.sendRaw}/{@link ServerRoom.onRaw})
|
|
12
|
+
* carries the session envelope verbatim; the typed-message path would spread a
|
|
13
|
+
* raw string and corrupt it, which is why the raw channel exists.
|
|
14
|
+
*/
|
|
15
|
+
import type { NetworkedClientTransport } from './networked-client.js';
|
|
16
|
+
import type { KinetixRuntimeIdentity } from './runtime-session.js';
|
|
17
|
+
import type { MultiplayerApi, ServerPlayer } from '@series-inc/rundot-game-sdk/mp-client';
|
|
18
|
+
/** Connection-health signal surfaced by {@link SyncplayRoomTransport.onConnectionEvent}. */
|
|
19
|
+
export type SyncplayConnectionEvent = {
|
|
20
|
+
readonly kind: 'error';
|
|
21
|
+
readonly message: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: 'disconnected';
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: 'reconnecting';
|
|
26
|
+
};
|
|
27
|
+
export interface SyncplayRoomOptions {
|
|
28
|
+
readonly maxPlayers?: number;
|
|
29
|
+
readonly runtimeIdentity: KinetixRuntimeIdentity;
|
|
30
|
+
readonly sessionConfigBytes: Uint8Array;
|
|
31
|
+
readonly sessionConfigDigest: string;
|
|
32
|
+
}
|
|
33
|
+
export interface SyncplayOccupancySnapshot {
|
|
34
|
+
readonly players: readonly ServerPlayer[];
|
|
35
|
+
readonly occupied: number;
|
|
36
|
+
readonly maxPlayers: number;
|
|
37
|
+
readonly full: boolean;
|
|
38
|
+
readonly locked: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface SyncplayRoomTransport extends NetworkedClientTransport {
|
|
41
|
+
/** The shareable room code (e.g. "HX9KWR"). */
|
|
42
|
+
readonly roomCode: string;
|
|
43
|
+
/** This client's player id, as assigned by the server on join. */
|
|
44
|
+
readonly playerId: string;
|
|
45
|
+
readonly occupancy: SyncplayOccupancySnapshot;
|
|
46
|
+
readonly isCreator: boolean;
|
|
47
|
+
/** Leave the deterministic room and close the underlying connection. */
|
|
48
|
+
close(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Connection-health signal: socket errors, disconnects, and reconnect
|
|
51
|
+
* attempts. Without observing this, a mid-match socket drop is silent — the
|
|
52
|
+
* room layer no-ops sends while dead, so the engine would predict into a
|
|
53
|
+
* dead socket forever. Games should surface these to the player and/or tear
|
|
54
|
+
* the match down.
|
|
55
|
+
*/
|
|
56
|
+
onConnectionEvent(handler: (event: SyncplayConnectionEvent) => void): void;
|
|
57
|
+
onOccupancyChange(handler: (snapshot: SyncplayOccupancySnapshot) => void): () => void;
|
|
58
|
+
setSeatingOpen(open: boolean): void;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Join an existing deterministic room by its 6-char code and return a transport
|
|
62
|
+
* ready to hand to `createNetworkedSyncplayClient`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function joinSyncplayRoomByCode(api: MultiplayerApi, code: string): Promise<SyncplayRoomTransport>;
|
|
65
|
+
/** Options for {@link quickMatchSyncplayRoom}. */
|
|
66
|
+
export interface QuickMatchSyncplayOptions extends SyncplayRoomOptions {
|
|
67
|
+
/**
|
|
68
|
+
* Auto-inject a coarse `region` criterion (default true). See
|
|
69
|
+
* {@link quickMatchSyncplayRoom} for the semantics and the tradeoff.
|
|
70
|
+
*/
|
|
71
|
+
readonly autoRegion?: boolean;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Quick-match into a deterministic room (M9): the platform's joinOrCreate
|
|
75
|
+
* matchmaking pools compatible players into an open room or creates a fresh
|
|
76
|
+
* one. Optional `criteria` narrow the pool (matched exactly by the platform).
|
|
77
|
+
* With drop-in defaults the match starts immediately; unfilled slots
|
|
78
|
+
* substitute the canonical null input, which a game can treat as
|
|
79
|
+
* "bot-controlled" deterministically (see SYNCPLAY.md — Bots & quick match).
|
|
80
|
+
*
|
|
81
|
+
* Region-aware by default (G6): a coarse `region` criterion ('na' | 'sa' |
|
|
82
|
+
* 'eu' | 'africa' | 'asia' | 'oceania', derived from the device timezone) is
|
|
83
|
+
* auto-injected so cross-ocean pairings — whose RTT forces constant deep
|
|
84
|
+
* rollback — don't happen by accident. An explicit `criteria.region` wins
|
|
85
|
+
* over the derived value, and an underivable region ('unknown') is never
|
|
86
|
+
* injected: pooling globally beats fragmenting into an 'unknown' bucket.
|
|
87
|
+
*
|
|
88
|
+
* Tradeoff: strict region pools can go thin for low-traffic games. Pass
|
|
89
|
+
* `{ autoRegion: false }` (or your own `region` value) to pool globally;
|
|
90
|
+
* cross-region fallback-after-timeout is future work.
|
|
91
|
+
*/
|
|
92
|
+
export declare function quickMatchSyncplayRoom(api: MultiplayerApi, options: QuickMatchSyncplayOptions, criteria?: Record<string, string | number>): Promise<SyncplayRoomTransport>;
|
|
93
|
+
/**
|
|
94
|
+
* Create a new deterministic room and return a transport ready to hand to
|
|
95
|
+
* `createNetworkedSyncplayClient`. The server mints the room code; read it
|
|
96
|
+
* back from {@link SyncplayRoomTransport.roomCode} to share with peers.
|
|
97
|
+
* (Caller-chosen codes are not supported on create — when the platform grows
|
|
98
|
+
* that path, this signature gains a typed parameter rather than a dead one.)
|
|
99
|
+
*/
|
|
100
|
+
export declare function createSyncplayRoom(api: MultiplayerApi, options: SyncplayRoomOptions): Promise<SyncplayRoomTransport>;
|