@series-inc/rundot-syncplay 5.25.0-beta.1 → 5.25.0-beta.10

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.
Files changed (46) hide show
  1. package/README.md +220 -4
  2. package/dist/authority-room.d.ts +38 -0
  3. package/dist/authority-room.js +183 -17
  4. package/dist/browser.d.ts +16 -3
  5. package/dist/browser.js +13 -1
  6. package/dist/browserSession.d.ts +44 -0
  7. package/dist/browserSession.js +70 -0
  8. package/dist/coarse-region.d.ts +20 -0
  9. package/dist/coarse-region.js +87 -0
  10. package/dist/dev-authority-room.d.ts +14 -0
  11. package/dist/dev-authority-room.js +239 -0
  12. package/dist/effects-adapter.d.ts +6 -0
  13. package/dist/effects-adapter.js +35 -5
  14. package/dist/index.d.ts +9 -3
  15. package/dist/index.js +4 -1
  16. package/dist/input-codec.d.ts +32 -0
  17. package/dist/input-codec.js +191 -0
  18. package/dist/networked-client.d.ts +97 -7
  19. package/dist/networked-client.js +429 -32
  20. package/dist/node.d.ts +1 -0
  21. package/dist/node.js +3 -0
  22. package/dist/platformer-content.d.ts +49 -0
  23. package/dist/platformer-content.js +100 -0
  24. package/dist/replay.d.ts +8 -7
  25. package/dist/run-room-transport.d.ts +100 -0
  26. package/dist/run-room-transport.js +280 -0
  27. package/dist/runner-harness.d.ts +77 -0
  28. package/dist/runner-harness.js +226 -0
  29. package/dist/runner.d.ts +129 -0
  30. package/dist/runner.js +417 -0
  31. package/dist/runtime-session.d.ts +31 -4
  32. package/dist/runtime-session.js +53 -1
  33. package/dist/sdk-session.d.ts +4 -5
  34. package/dist/sdk-session.js +1 -0
  35. package/dist/session-wire.d.ts +41 -1
  36. package/dist/session-wire.js +58 -2
  37. package/dist/synctest.d.ts +5 -5
  38. package/dist/testing-network.d.ts +36 -0
  39. package/dist/testing-network.js +141 -0
  40. package/dist/testing.d.ts +36 -1
  41. package/dist/testing.js +89 -1
  42. package/dist/tools/index.d.ts +1 -0
  43. package/dist/tools/index.js +2 -0
  44. package/dist/tools/vite-plugin.d.ts +40 -0
  45. package/dist/tools/vite-plugin.js +100 -0
  46. package/package.json +5 -3
package/README.md CHANGED
@@ -5,6 +5,11 @@ deterministic state, RNG streams, stepping, opaque checkpoints, serialization,
5
5
  hydration, and checksums. Syncplay owns inputs, prediction, confirmation,
6
6
  rollback policy, replay logs, late join, secrets, and transport.
7
7
 
8
+ > **Upgrading an existing game?** See
9
+ > [MIGRATING-SESSION-PRIMITIVES.md](./MIGRATING-SESSION-PRIMITIVES.md). Two
10
+ > changes are not additive: the runner now rejects an unsafe input codec at
11
+ > construction, and the effects adapter now dedupes on your event `dedupeKey`.
12
+
8
13
  ## Ownership boundary
9
14
 
10
15
  | Concern | Owner |
@@ -20,10 +25,12 @@ There is one session factory:
20
25
 
21
26
  ```ts
22
27
  import { createSyncplaySession } from '@series-inc/rundot-syncplay/browser';
23
- import { createFixed1000Runtime } from '@series-inc/rundot-kinetix/runtime';
28
+ import { createInstalledPlatformer2dRuntime } from '@series-inc/rundot-kinetix/runtime';
24
29
 
25
- const runtime = createFixed1000Runtime({
26
- product,
30
+ const runtime = createInstalledPlatformer2dRuntime({
31
+ pack,
32
+ levelBytes,
33
+ identity,
27
34
  sessionConfigBytes,
28
35
  });
29
36
 
@@ -32,12 +39,15 @@ const session = createSyncplaySession(runtime, {
32
39
  defaultInput: { throttle: 0, steer: 0, brake: false },
33
40
  snapshotBufferSize: 256,
34
41
  checksumIntervalFrames: 30,
42
+ requires: { projection: true },
35
43
  });
36
44
  ```
37
45
 
38
46
  The runtime must implement `@series-inc/rundot-kinetix/runtime`. Syncplay never
39
47
  accepts an initial state, reducer, or creator-authored step callback. There is no
40
- compatibility mode for the removed reducer API.
48
+ compatibility mode for the removed reducer API. A non-platformer game supplies
49
+ the same ABI through `createInstalledRuntimeAdapter` and binds its source with
50
+ `deriveCustomRuntimeIdentity` plus `kinetixRuntimeModuleManifest`.
41
51
 
42
52
  ## Runtime and snapshot contract
43
53
 
@@ -52,6 +62,50 @@ session-config bytes, asks the game for a matching runtime through its runtime
52
62
  factory, and rejects a locally loaded pack mismatch before frame 0. Replays use
53
63
  the same identity/config/factory path.
54
64
 
65
+ ### Capability requirements
66
+
67
+ Session policies and runner configs accept
68
+ `requires?: { commands?: boolean; projection?: boolean }`. Requirements are
69
+ checked when each offline or networked session is constructed, before any frame
70
+ advances:
71
+
72
+ | Requirement | Failure |
73
+ |---|---|
74
+ | `projection: true` without `runtime.project` | `SYNCPLAY_PROJECTION_UNSUPPORTED` |
75
+ | `commands: true` with no capabilities declaration | `SYNCPLAY_RUNTIME_CAPABILITY_UNKNOWN` |
76
+ | `commands: true` with `capabilities.commands !== true` | `SYNCPLAY_RUNTIME_COMMANDS_UNSUPPORTED` |
77
+
78
+ The runner forwards one `requires` policy through both `startOffline()` and
79
+ `startNetworked()`, including reconfigured network sessions. Unknown capability
80
+ metadata never silently passes negotiation.
81
+
82
+ ## Presentation frames
83
+
84
+ `session.getState()` returns a detached deep copy of the current frame, which
85
+ costs O(state) per read and is unaffordable for a large mutable world (a
86
+ destructible voxel city, a big streaming map).
87
+
88
+ `session.getPresentationFrame()` is the no-clone path. It calls the runtime's
89
+ optional `project(checkpoint, previous)` and returns the result untouched:
90
+
91
+ - **Requires** a runtime built with a `projectState` hook. A runtime without
92
+ `project` throws `SYNCPLAY_PROJECTION_UNSUPPORTED` — there is no silent
93
+ fallback to `inspect`.
94
+ - **The result is immutable.** Callers must not mutate it. It may share structure
95
+ with authoritative state and with previously returned projections, which is
96
+ exactly what lets a renderer diff persistent roots and skip shared subtrees.
97
+ - **`previous` is the last frame this session projected**, enabling delta
98
+ projections. It is absent — meaning the runtime should produce a full frame —
99
+ on the first call, after `restoreToFrame` or `restoreSnapshot`, and whenever
100
+ the previously projected frame is no longer retained (pruned or rolled past).
101
+ - **Reading the same frame twice is free and idempotent.** Repeat calls return
102
+ the identical projection rather than re-projecting the frame against itself
103
+ (which would report an empty delta). Authoritative state cannot change without
104
+ the frame advancing, so multiple render passes per frame are safe.
105
+
106
+ Projections are presentation-only: they never re-enter simulation and are not
107
+ part of deterministic identity.
108
+
55
109
  ## Public projections and secrets
56
110
 
57
111
  Every peer applies the same confirmed inputs and authority commands. Optional
@@ -83,3 +137,165 @@ The core suite covers checkpoint ownership/release, frame-zero rollback, v2
83
137
  snapshot hydration, replay verification, authority persistence, latency/loss
84
138
  convergence, late join, spectators, bots, secrets, and a real Kinetix world
85
139
  driven through `createSyncplaySession`.
140
+
141
+ ## Unified session runner
142
+
143
+ `createSyncplayRunner` owns the offline / co-op / networked lifecycle behind one
144
+ runtime factory and one presentation contract, so a game does not re-implement
145
+ create/join resolution, the local/network tick branch, bot filling, projection
146
+ extraction, and stale-connection invalidation:
147
+
148
+ ```ts
149
+ const runner = createSyncplayRunner({
150
+ runtimeFactory,
151
+ prepareNetworkRuntime: (descriptor, signal) => prepareLevelFromUgc(descriptor, signal),
152
+ defaultInput,
153
+ encodeInput,
154
+ decodeInput,
155
+ presentation: {
156
+ project: (frame) => frame,
157
+ interpolate: interpolateRenderState,
158
+ events: (frame) => frame.events,
159
+ },
160
+ })
161
+ runner.startOffline({ identity, sessionConfigBytes, playerCount: 2, botInputForTick })
162
+ await runner.startNetworked(() => createSyncplayRoom(api.realtime, roomOptions))
163
+ runner.applyInput(readInput())
164
+ runner.update(deltaMs)
165
+ render(runner.getRenderState(), runner.renderAlpha)
166
+ ```
167
+
168
+ **Status** is exactly `offline | connecting | syncing | live | stopped | error`:
169
+
170
+ - `connecting` covers the room-open promise.
171
+ - `syncing` covers session-descriptor/asset preparation, reconnect, and catch-up.
172
+ - `live` requires `client.caughtUp` plus at least one applied authority frame.
173
+ - `offline` owns the local fixed-step accumulator and bot filling.
174
+ - `error` carries connection, preparation, and offline-construction failures on
175
+ the runner snapshot (and the throw is rethrown to the caller).
176
+ - `stop` aborts preparation, closes the transport, and disposes the active
177
+ client/session — idempotently.
178
+
179
+ Presentation stays **predicted** for render motion, while side-effect events are
180
+ delivered only from authority-confirmed frames received after the first live
181
+ boundary. Initial and reconnect catch-up history is silent; a post-live
182
+ authority-leading frame still emits its event exactly once. Interpolation is
183
+ presentation-only and never feeds simulation. Starting a new mode always tears
184
+ down the old one, and a stale async connection/preparation closes without
185
+ replacing the current session.
186
+
187
+ **Incremental adoption (mature games).** A game with its own stateful renderer,
188
+ input model, and connection UI can swap only its lifecycle orchestration onto the
189
+ runner without rewriting those layers:
190
+
191
+ - Keep your own presentation singleton — use an identity `project: (frame) => frame`
192
+ and feed your renderer from the `subscribe` snapshot (`renderState`, `renderAlpha`)
193
+ instead of `getRenderState()`.
194
+ - Supply `localInputForTick: (slot, frame) => Input` when your local input is a
195
+ per-tick pulse/edge that must be consumed and cleared each frame; the runner then
196
+ reads it (offline local slot and networked prediction) instead of the persistent
197
+ `applyInput(...)` value.
198
+ - Drive a room badge from the snapshot's `ready`, `appliedThrough`, `predictedThrough`,
199
+ `localSlot`, and `status` — no reach into the underlying client (e.g. `appliedThrough < 0`
200
+ means "waiting for peers"; `predictedThrough - appliedThrough` is the catch-up depth).
201
+ - Tune the networked client through the runner: `pacing` (`targetLeadTicks`,
202
+ `maxStepsPerPump`, drift correction) and `inputDelay` (`minTicks`/`maxTicks`,
203
+ RTT-adaptive) are forwarded verbatim, and live `netStats` (RTT, timescale, input
204
+ delay) is surfaced on the snapshot + runner (`null` offline) for a netcode HUD.
205
+
206
+ Direct `NetworkedSyncplayClient` users get the same projection surface:
207
+ `getPresentationFrame()`, `renderAlpha`, `currentFrame`, `caughtUp`, and a
208
+ bounded confirmed-frame queue via the optional `confirmedPresentationBufferSize`
209
+ (absent = no queue, preserving existing cost) drained with
210
+ `drainConfirmedPresentationFrames()`. Call `beginPresentationCatchUp()` from your
211
+ transport's reconnect signal so replayed history never fills the queue.
212
+ `prepareNetworkedSyncplayTransport(transport, prepare, { signal })` buffers the
213
+ ordered `session-start`/history traffic (same 8,192-message ceiling as the SDK
214
+ transport), exposes a frozen `NetworkedSyncplaySessionDescriptor`, awaits your
215
+ async preparation, then replays every buffered message once.
216
+
217
+ The descriptor is exactly `{ slot, playerCount, seed, tickRateHz,
218
+ hardToleranceTicks, role, runtimeIdentity, sessionConfigBytes,
219
+ sessionConfigDigest }`. It does **not** carry `levelEntryId`, a level digest, or a
220
+ level length — those are game-specific fields inside `sessionConfigBytes`, and you
221
+ decode them with your own session-config schema:
222
+
223
+ ```ts
224
+ import { decodeKinetixSessionConfig } from '@series-inc/rundot-kinetix/runtime';
225
+ import { kinetixPlatformer2dSessionConfigSchema } from '@series-inc/rundot-kinetix/runtime';
226
+
227
+ const prepareNetworkRuntime = async (descriptor, signal) => {
228
+ const session = decodeKinetixSessionConfig(
229
+ kinetixPlatformer2dSessionConfigSchema,
230
+ descriptor.sessionConfigBytes,
231
+ );
232
+ // session.levelEntryId / session.levelDigest / session.levelByteLength
233
+ const levelBytes = await preparePlatformerLevel(api.ugc, {
234
+ levelEntryId: session.levelEntryId,
235
+ levelDigest: session.levelDigest,
236
+ levelByteLength: session.levelByteLength,
237
+ encodeLevel: (level) => encodeKinetixPlatformer2dLevel(level, { maxBytes: 102_400 }),
238
+ });
239
+ registerPreparedLevel(descriptor.sessionConfigDigest, levelBytes);
240
+ };
241
+ ```
242
+
243
+ ## Safe input codecs
244
+
245
+ `defineSyncplayInputCodec(schema)` derives `{ encodeInput, decodeInput,
246
+ neutralInput }` from an exact field declaration (`boolean`, `int` with
247
+ `min`/`max`, `enum` of numbers). Decoding a non-conforming payload throws
248
+ `SYNCPLAY_INPUT_DECODE_INVALID`, and the canonical `null` substitute maps to
249
+ `neutralInput` — i.e. correct `decodeInputSafe` semantics by construction, rather
250
+ than by remembering to write them. Neutral is `false` for booleans and `0` for
251
+ numeric fields when their range admits it; otherwise the field must declare
252
+ `neutral` or construction throws `SYNCPLAY_INPUT_SCHEMA_INVALID`.
253
+
254
+ For an existing hand-written codec, `validateSyncplayInputCodec({ encodeInput,
255
+ decodeInput, samples })` probes round-trip stability, `null → neutral`, and a
256
+ battery of hostile payloads, failing with `SYNCPLAY_INPUT_CODEC_UNSAFE`.
257
+
258
+ ## Version-safe content references
259
+
260
+ `publishPlatformerLevel(api, level, options)` always calls `ugc.create` — never
261
+ `ugc.update` on a referenced entry, which would make every live room pinned to it
262
+ permanently unjoinable. `preparePlatformerLevel(api, declaration)` resolves a pin
263
+ and raises the typed `SYNCPLAY_LEVEL_CONTENT_CHANGED` (naming the entry id and the
264
+ edit-vs-pin cause) rather than an opaque `KINETIX_AUX_ASSET_MISMATCH` deep inside
265
+ runtime construction. `declaration.encodeLevel` is an **injected callback**, not a
266
+ static Kinetix import, because Syncplay must compile against both the published
267
+ Kinetix pin and an overlaid local build.
268
+
269
+ **Capacity and late-join budgets.** Snapshot transfer defaults are **256 KiB**
270
+ chunks and a **4 MiB** total ceiling; the immutable level payload is fetched and
271
+ verified separately and never rides a snapshot. `maxPredictionTicks` is a
272
+ rollback/latency budget, not a player-count limit. 128 slots is the tested
273
+ protocol/load ceiling, not a 60 Hz performance guarantee for an arbitrary game.
274
+
275
+ ## Supported correctness kit
276
+
277
+ `@series-inc/rundot-syncplay/testing` exposes exactly nine supported
278
+ entry points — no browser or runtime APIs leak through it:
279
+
280
+ ```ts
281
+ import {
282
+ assertDeterministic,
283
+ assertLateJoinHydration,
284
+ assertPresentationParity,
285
+ assertReplayVerifies,
286
+ assertRunnerLifecycle,
287
+ assertTwoClientConvergence,
288
+ createSimulatedSyncplayMatch,
289
+ createSyncplayRunnerHarness,
290
+ runSyncplaySynctest,
291
+ } from '@series-inc/rundot-syncplay/testing'
292
+ ```
293
+
294
+ `assertDeterministic` and `assertReplayVerifies` run the straight-run /
295
+ restore-resim / hydration / replay cert; `assertTwoClientConvergence` and
296
+ `assertLateJoinHydration` run the **real** authority room and networked client
297
+ over a deterministic simulated network (`createSimulatedSyncplayMatch`,
298
+ `runSyncplaySynctest`). `createSyncplayRunnerHarness`, `assertRunnerLifecycle`,
299
+ and `assertPresentationParity` cover the runner seam — a real runner driven on
300
+ a fully controlled clock against the simulated match. Failures name the phase,
301
+ frame, and disagreeing checksums.
@@ -106,6 +106,23 @@ export interface DeterministicAuthorityRoomConfig {
106
106
  readonly secrets?: SyncplaySecretSystemsConfig;
107
107
  readonly secretCrypto?: SyncplaySecretAuthorityCrypto;
108
108
  readonly secretMsgType?: string;
109
+ /** Per-slot ceiling on accepted `command-request` messages in a one-second window. Default 60. */
110
+ readonly commandsPerSecond?: number;
111
+ /**
112
+ * Who may drive {@link DeterministicAuthorityRoom.reconfigureSession} over the
113
+ * wire. Default 'none' — a `reconfigure-request` is dropped and counted, and
114
+ * only the host may reconfigure in-process.
115
+ */
116
+ readonly reconfigurePolicy?: 'creator' | 'any' | 'none';
117
+ /** The creator's seat, required to enforce `reconfigurePolicy: 'creator'`. */
118
+ readonly creatorSlot?: number;
119
+ }
120
+ export interface DeterministicAuthorityRoomReconfiguration {
121
+ readonly sessionConfigBytes: Uint8Array;
122
+ readonly sessionConfigDigest: string;
123
+ readonly seed?: number;
124
+ /** When supplied, must equal the live identity; a mismatch throws. */
125
+ readonly runtimeIdentity?: KinetixRuntimeIdentity;
109
126
  }
110
127
  export interface DeterministicAuthorityRoomRestoreState {
111
128
  readonly now: number;
@@ -119,6 +136,8 @@ export interface DeterministicAuthorityRoomRestoreState {
119
136
  readonly sessionConfigBytes: Uint8Array;
120
137
  readonly sessionConfigDigest: string;
121
138
  readonly secretState?: SyncplaySecretAuthoritySnapshot;
139
+ /** Session generation at persist time; a restored room re-greets with it. Default 0. */
140
+ readonly generation?: number;
122
141
  }
123
142
  export interface AuthorityRoomCatchUpEvent {
124
143
  readonly playerId: string;
@@ -161,6 +180,12 @@ export interface AuthorityRoomNetcodeStats {
161
180
  /** Ticks in which each slot's input had to be substituted (cumulative). */
162
181
  readonly substitutedTicksBySlot: Readonly<Record<number, number>>;
163
182
  readonly clientStats: Readonly<Record<number, AuthorityRoomClientNetStats>>;
183
+ /** Command requests dropped for seat, size, rate, sequence, or stale generation. */
184
+ readonly rejectedCommands: number;
185
+ /** Reconfigure requests dropped for policy or size. */
186
+ readonly rejectedReconfigures: number;
187
+ /** Ephemeral relays dropped for seat, size, or rate. */
188
+ readonly rejectedEphemeral: number;
164
189
  }
165
190
  export interface AuthorityRoomJoinResult {
166
191
  readonly accepted: boolean;
@@ -178,8 +203,11 @@ export interface AuthorityRoomSnapshot {
178
203
  /** Lowest confirmed tick still retained (-1 when nothing is confirmed). */
179
204
  readonly earliestRetainedTick: number;
180
205
  readonly runtimeIdentity: KinetixRuntimeIdentity;
206
+ /** The CURRENT session config (post-reconfigure), not the creation-time one. */
181
207
  readonly sessionConfigBytes: Uint8Array;
182
208
  readonly sessionConfigDigest: string;
209
+ /** Session generation: 0 until the first reconfigure. */
210
+ readonly generation: number;
183
211
  /**
184
212
  * Metadata of the latest VALIDATED state snapshot. The raw bytes are
185
213
  * deliberately excluded — hosts persist this record (mp-room-server hard-caps
@@ -213,6 +241,16 @@ export interface DeterministicAuthorityRoom {
213
241
  onPlayerReconnected(playerId: string): void;
214
242
  /** Advance the authority by one server tick and broadcast newly-confirmed frames. */
215
243
  onTick(): void;
244
+ /**
245
+ * Swap the session config in a live room: bumps the generation, resets the
246
+ * authority's own session state, and re-greets every seated player and
247
+ * spectator with a fresh `session-start`. Nobody disconnects — clients treat
248
+ * a differing generation as a full session swap. Throws
249
+ * SYNCPLAY_RECONFIGURE_IDENTITY_MISMATCH when the supplied runtime identity
250
+ * differs from the live one, and SYNCPLAY_RECONFIGURE_CONFIG_INVALID on bad
251
+ * or oversized bytes.
252
+ */
253
+ reconfigureSession(next: DeterministicAuthorityRoomReconfiguration): void;
216
254
  snapshot(): AuthorityRoomSnapshot;
217
255
  /** Raw bytes of the latest validated state snapshot (see AuthorityRoomSnapshot.latestSnapshot). */
218
256
  latestSnapshotBytes(): string | undefined;
@@ -1,10 +1,10 @@
1
- import { defaultChecksum } from './canonical.js';
1
+ import { canonicalStringify, defaultChecksum, encodeUtf8 } from './canonical.js';
2
2
  import { assertKinetixRuntimeIdentity, kinetixSessionConfigDigest, } from '@series-inc/rundot-kinetix/runtime';
3
3
  import { invariant } from './errors.js';
4
4
  import { createInputAuthority } from './input-authority.js';
5
5
  import { createDeterministicLocalAuthoritySnapshotChunkEnvelope, createDeterministicLocalAuthoritySnapshotCompleteEnvelope, createDeterministicLocalAuthoritySnapshotOfferEnvelope, createDeterministicLocalAuthoritySnapshotRequestEnvelope, decodeDeterministicLocalAuthorityWireEnvelope, } from './local-authority-wire.js';
6
6
  import { DEFAULT_SNAPSHOT_CHUNK_SIZE, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, deterministicRoomDescriptorHash, } from './session-snapshot.js';
7
- import { MAX_KINETIX_SESSION_CONFIG_BYTES, decodeDeterministicSessionMessage, encodeDeterministicSessionMessage, } from './session-wire.js';
7
+ import { MAX_KINETIX_SESSION_CONFIG_BYTES, MAX_SYNCPLAY_COMMAND_PAYLOAD_BYTES, MAX_SYNCPLAY_EPHEMERAL_PAYLOAD_BYTES, decodeDeterministicSessionMessage, encodeDeterministicSessionMessage, } from './session-wire.js';
8
8
  import { createSyncplaySecretAuthority, } from './secret-authority.js';
9
9
  const DEFAULT_SESSION_MSG_TYPE = 'rdm';
10
10
  const DEFAULT_SECRET_MSG_TYPE = 'rdm-secret';
@@ -16,6 +16,10 @@ const HISTORY_CHUNK_FRAMES = 500;
16
16
  const DEFAULT_SNAPSHOT_CADENCE_TICKS = 3600;
17
17
  /** Ten seconds at 60 Hz — see DeterministicAuthorityRoomConfig.transferTimeoutTicks. */
18
18
  const DEFAULT_TRANSFER_TIMEOUT_TICKS = 600;
19
+ /** See DeterministicAuthorityRoomConfig.commandsPerSecond. */
20
+ const DEFAULT_COMMANDS_PER_SECOND = 60;
21
+ /** Per-slot ephemeral relay ceiling per one-second window (matches the client's outbound coalescing). */
22
+ const EPHEMERAL_RELAYS_PER_SECOND = 20;
19
23
  export function createDeterministicAuthorityRoom(transport, config) {
20
24
  assertKinetixRuntimeIdentity(config.runtimeIdentity);
21
25
  invariant(config.runtimeIdentity.tickRate === config.tickRateHz, 'runtime identity tick rate does not match the authority clock', 'authority-room.runtime-tick-rate');
@@ -30,10 +34,20 @@ export function createDeterministicAuthorityRoom(transport, config) {
30
34
  }
31
35
  const sessionMsgType = config.sessionMsgType ?? DEFAULT_SESSION_MSG_TYPE;
32
36
  const historyRetentionTicks = config.historyRetentionTicks ?? DEFAULT_HISTORY_RETENTION_TICKS;
37
+ const commandsPerSecond = config.commandsPerSecond ?? DEFAULT_COMMANDS_PER_SECOND;
38
+ invariant(Number.isInteger(commandsPerSecond) && commandsPerSecond >= 0, 'commandsPerSecond must be a non-negative integer', 'authority-room.commands-per-second');
39
+ const reconfigurePolicy = config.reconfigurePolicy ?? 'none';
40
+ // Mutable session identity: reconfigureSession swaps these, and every greet,
41
+ // snapshot validation, and persisted record must read the CURRENT values.
42
+ let sessionConfigBytes = config.sessionConfigBytes.slice();
43
+ let sessionConfigDigest = config.sessionConfigDigest;
44
+ let seed = config.seed;
45
+ let generation = config.restoreFrom?.generation ?? 0;
46
+ invariant(Number.isInteger(generation) && generation >= 0, 'restored generation must be a non-negative integer', 'authority-room.restore-generation');
33
47
  if (config.maxSnapshotBytes !== undefined && (!Number.isInteger(config.maxSnapshotBytes) || config.maxSnapshotBytes < 1)) {
34
48
  throw new Error(`createDeterministicAuthorityRoom: maxSnapshotBytes must be a positive integer, received ${config.maxSnapshotBytes}`);
35
49
  }
36
- const authority = createInputAuthority({
50
+ const createSessionAuthority = (restoreFrom) => createInputAuthority({
37
51
  playerCount: config.playerCount,
38
52
  hardToleranceTicks: config.hardToleranceTicks,
39
53
  neutralInput: config.neutralInput,
@@ -43,10 +57,11 @@ export function createDeterministicAuthorityRoom(transport, config) {
43
57
  // (historyRetentionTicks — explicit caller choice wins; the generous floor
44
58
  // lives in DEFAULT_HISTORY_RETENTION_TICKS, not here, so tests can shrink it).
45
59
  retainedConfirmedFrames: Math.max(historyRetentionTicks, config.redundancyWindowTicks * 4),
46
- ...(config.restoreFrom === undefined
47
- ? {}
48
- : { restoreFrom: { confirmedThrough: config.restoreFrom.confirmedThrough, frames: config.restoreFrom.tail } }),
60
+ ...(restoreFrom === undefined ? {} : { restoreFrom }),
49
61
  });
62
+ let authority = createSessionAuthority(config.restoreFrom === undefined
63
+ ? undefined
64
+ : { confirmedThrough: config.restoreFrom.confirmedThrough, frames: config.restoreFrom.tail });
50
65
  const slotByPlayer = new Map();
51
66
  // G11 spectators: read-only connections with no slot. Their input /
52
67
  // checksum-report / net-stats envelopes are already dropped by the
@@ -81,12 +96,13 @@ export function createDeterministicAuthorityRoom(transport, config) {
81
96
  // snapshot and runs at most one donor transfer at a time.
82
97
  const snapshotCadenceTicks = config.snapshotCadenceTicks ?? DEFAULT_SNAPSHOT_CADENCE_TICKS;
83
98
  const transferTimeoutTicks = config.transferTimeoutTicks ?? DEFAULT_TRANSFER_TIMEOUT_TICKS;
84
- const roomDescriptorHash = deterministicRoomDescriptorHash({
85
- seed: config.seed,
99
+ const describeRoom = () => deterministicRoomDescriptorHash({
100
+ seed,
86
101
  playerCount: config.playerCount,
87
102
  tickRateHz: config.tickRateHz,
88
103
  hardToleranceTicks: config.hardToleranceTicks,
89
104
  });
105
+ let roomDescriptorHash = describeRoom();
90
106
  let storedSnapshot;
91
107
  let pendingTransfer;
92
108
  let nextCadenceTarget = snapshotCadenceTicks;
@@ -98,6 +114,14 @@ export function createDeterministicAuthorityRoom(transport, config) {
98
114
  // once per newly-confirmed tick) and the latest client-reported stats.
99
115
  const substitutedTicksBySlot = new Map();
100
116
  const clientNetStatsBySlot = new Map();
117
+ // Command ingress bookkeeping: per-slot sliding acceptance window (in authority
118
+ // ticks) and the last accepted sequence, both reset by a reconfigure.
119
+ let commandTicksBySlot = new Map();
120
+ let lastCommandSequenceBySlot = new Map();
121
+ const ephemeralTicksBySlot = new Map();
122
+ let rejectedCommands = 0;
123
+ let rejectedReconfigures = 0;
124
+ let rejectedEphemeral = 0;
101
125
  let substitutionCountedThrough = -1;
102
126
  // Highest tick ever included in a confirmed-input broadcast — the anchor
103
127
  // that guarantees frontier jumps larger than the redundancy window can
@@ -126,7 +150,7 @@ export function createDeterministicAuthorityRoom(transport, config) {
126
150
  const decoded = decodeDeterministicStateSnapshot(restore.snapshotBytes);
127
151
  invariant(decoded.ok, 'restored snapshotBytes do not decode', 'authority-room.restore-snapshot');
128
152
  invariant(equalRuntimeIdentity(decoded.snapshot.runtimeIdentity, config.runtimeIdentity)
129
- && decoded.snapshot.sessionConfigDigest === config.sessionConfigDigest, 'restored snapshot runtime identity or config digest does not match the room', 'authority-room.restore-snapshot-runtime-config');
153
+ && decoded.snapshot.sessionConfigDigest === sessionConfigDigest, 'restored snapshot runtime identity or config digest does not match the room', 'authority-room.restore-snapshot-runtime-config');
130
154
  // With preserveFullHistory the persisted tail reaches back to tick 0
131
155
  // (below the snapshot); a tail starting ABOVE the snapshot tick would
132
156
  // leave an unreplayable gap and must still fail loud.
@@ -159,13 +183,16 @@ export function createDeterministicAuthorityRoom(transport, config) {
159
183
  kind: 'session-start',
160
184
  slot,
161
185
  playerCount: config.playerCount,
162
- seed: config.seed,
186
+ seed,
163
187
  tickRateHz: config.tickRateHz,
164
188
  hardToleranceTicks: config.hardToleranceTicks,
165
189
  role,
166
190
  runtimeIdentity: config.runtimeIdentity,
167
- sessionConfigBytes: config.sessionConfigBytes.slice(),
168
- sessionConfigDigest: config.sessionConfigDigest,
191
+ sessionConfigBytes: sessionConfigBytes.slice(),
192
+ sessionConfigDigest,
193
+ // Omitted at generation 0 so a room that never reconfigures encodes
194
+ // byte-identically to what pre-generation peers expect.
195
+ ...(generation === 0 ? {} : { generation }),
169
196
  }));
170
197
  }
171
198
  /**
@@ -379,12 +406,31 @@ export function createDeterministicAuthorityRoom(transport, config) {
379
406
  }));
380
407
  return;
381
408
  }
382
- if (message.kind !== 'input' && message.kind !== 'checksum-report' && message.kind !== 'net-stats') {
409
+ if (message.kind !== 'input' && message.kind !== 'checksum-report' && message.kind !== 'net-stats'
410
+ && message.kind !== 'command-request' && message.kind !== 'ephemeral' && message.kind !== 'reconfigure-request') {
383
411
  return;
384
412
  }
385
413
  const slot = slotByPlayer.get(playerId);
386
414
  // Server owns the slot mapping — a client cannot act for a slot it was not assigned.
387
415
  if (slot === undefined || message.slot !== slot) {
416
+ if (message.kind === 'command-request')
417
+ rejectedCommands += 1;
418
+ else if (message.kind === 'ephemeral')
419
+ rejectedEphemeral += 1;
420
+ else if (message.kind === 'reconfigure-request')
421
+ rejectedReconfigures += 1;
422
+ return;
423
+ }
424
+ if (message.kind === 'command-request') {
425
+ acceptCommandRequest(slot, message.generation, message.sequence, message.command);
426
+ return;
427
+ }
428
+ if (message.kind === 'ephemeral') {
429
+ relayEphemeral(playerId, slot, message.payload);
430
+ return;
431
+ }
432
+ if (message.kind === 'reconfigure-request') {
433
+ acceptReconfigureRequest(slot, message.sessionConfigBytes, message.sessionConfigDigest, message.seed);
388
434
  return;
389
435
  }
390
436
  if (message.kind === 'input') {
@@ -404,6 +450,118 @@ export function createDeterministicAuthorityRoom(transport, config) {
404
450
  });
405
451
  }
406
452
  }
453
+ /**
454
+ * Sliding one-second acceptance window, measured in authority ticks so the
455
+ * limit is clock-free (the room owns no wall clock). Returns false when the
456
+ * slot is over budget.
457
+ */
458
+ function admitInWindow(windows, slot, perSecond) {
459
+ const floor = now - config.tickRateHz + 1;
460
+ const retained = (windows.get(slot) ?? []).filter((tick) => tick >= floor);
461
+ if (retained.length >= perSecond) {
462
+ windows.set(slot, retained);
463
+ return false;
464
+ }
465
+ retained.push(now);
466
+ windows.set(slot, retained);
467
+ return true;
468
+ }
469
+ function acceptCommandRequest(slot, requestGeneration, sequence, command) {
470
+ // A stale generation means the request was authored against a session that
471
+ // no longer exists — applying it would mutate the wrong level.
472
+ if (requestGeneration !== generation) {
473
+ rejectedCommands += 1;
474
+ return;
475
+ }
476
+ const lastSequence = lastCommandSequenceBySlot.get(slot);
477
+ if (lastSequence !== undefined && sequence <= lastSequence) {
478
+ rejectedCommands += 1;
479
+ return;
480
+ }
481
+ if (canonicalStringifyByteLength(command.payload) > MAX_SYNCPLAY_COMMAND_PAYLOAD_BYTES) {
482
+ rejectedCommands += 1;
483
+ return;
484
+ }
485
+ if (!admitInWindow(commandTicksBySlot, slot, commandsPerSecond)) {
486
+ rejectedCommands += 1;
487
+ return;
488
+ }
489
+ lastCommandSequenceBySlot.set(slot, sequence);
490
+ // The same tick-assignment path secret commands use, so canonicalization,
491
+ // confirmed-frame distribution, replay, and snapshots need no change.
492
+ const tick = Math.max(now, authority.confirmedThrough) + 1;
493
+ authority.queueCommand(tick, {
494
+ kind: `game.${command.kind}`,
495
+ slot,
496
+ payload: command.payload,
497
+ });
498
+ }
499
+ function relayEphemeral(senderId, slot, payload) {
500
+ if (canonicalStringifyByteLength(payload) > MAX_SYNCPLAY_EPHEMERAL_PAYLOAD_BYTES) {
501
+ rejectedEphemeral += 1;
502
+ return;
503
+ }
504
+ if (!admitInWindow(ephemeralTicksBySlot, slot, EPHEMERAL_RELAYS_PER_SECOND)) {
505
+ rejectedEphemeral += 1;
506
+ return;
507
+ }
508
+ const encoded = encodeDeterministicSessionMessage({ kind: 'ephemeral', slot, payload });
509
+ for (const playerId of [...slotByPlayer.keys(), ...spectators]) {
510
+ if (playerId === senderId)
511
+ continue;
512
+ transport.sendTo(playerId, sessionMsgType, encoded);
513
+ }
514
+ }
515
+ function acceptReconfigureRequest(slot, nextBytes, nextDigest, nextSeed) {
516
+ const authorized = reconfigurePolicy === 'any'
517
+ || (reconfigurePolicy === 'creator' && config.creatorSlot !== undefined && slot === config.creatorSlot);
518
+ if (!authorized || nextBytes.byteLength > MAX_KINETIX_SESSION_CONFIG_BYTES) {
519
+ rejectedReconfigures += 1;
520
+ return;
521
+ }
522
+ reconfigureSession({
523
+ sessionConfigBytes: nextBytes,
524
+ sessionConfigDigest: nextDigest,
525
+ ...(nextSeed === undefined ? {} : { seed: nextSeed }),
526
+ });
527
+ }
528
+ function reconfigureSession(next) {
529
+ if (next.runtimeIdentity !== undefined && !equalRuntimeIdentity(next.runtimeIdentity, config.runtimeIdentity)) {
530
+ throw new Error('SYNCPLAY_RECONFIGURE_IDENTITY_MISMATCH');
531
+ }
532
+ if (!(next.sessionConfigBytes instanceof Uint8Array)
533
+ || next.sessionConfigBytes.byteLength === 0
534
+ || next.sessionConfigBytes.byteLength > MAX_KINETIX_SESSION_CONFIG_BYTES
535
+ || kinetixSessionConfigDigest(next.sessionConfigBytes) !== next.sessionConfigDigest) {
536
+ throw new Error('SYNCPLAY_RECONFIGURE_CONFIG_INVALID');
537
+ }
538
+ generation += 1;
539
+ sessionConfigBytes = next.sessionConfigBytes.slice();
540
+ sessionConfigDigest = next.sessionConfigDigest;
541
+ if (next.seed !== undefined)
542
+ seed = next.seed;
543
+ roomDescriptorHash = describeRoom();
544
+ // The tick space restarts at 0: every client full-resets on the differing
545
+ // generation, so retained history, snapshots, and reports all describe a
546
+ // session that no longer exists.
547
+ authority = createSessionAuthority();
548
+ storedSnapshot = undefined;
549
+ pendingTransfer = undefined;
550
+ checksumReports.clear();
551
+ reportsDirty = false;
552
+ substitutedTicksBySlot.clear();
553
+ substitutionCountedThrough = -1;
554
+ commandTicksBySlot = new Map();
555
+ lastCommandSequenceBySlot = new Map();
556
+ nextCadenceTarget = snapshotCadenceTicks;
557
+ broadcastThrough = -1;
558
+ restoredRoom = false;
559
+ now = -1;
560
+ for (const [playerId, slot] of slotByPlayer)
561
+ sendSessionStart(playerId, slot);
562
+ for (const playerId of spectators)
563
+ sendSessionStart(playerId, -1, 'spectator');
564
+ }
407
565
  function recordChecksumReport(slot, tick, checksum) {
408
566
  let reports = checksumReports.get(tick);
409
567
  if (reports === undefined) {
@@ -507,7 +665,7 @@ export function createDeterministicAuthorityRoom(transport, config) {
507
665
  return;
508
666
  }
509
667
  if (!equalRuntimeIdentity(snapshot.runtimeIdentity, config.runtimeIdentity)
510
- || snapshot.sessionConfigDigest !== config.sessionConfigDigest) {
668
+ || snapshot.sessionConfigDigest !== sessionConfigDigest) {
511
669
  failPendingTransfer('snapshot-runtime-config-mismatch');
512
670
  return;
513
671
  }
@@ -704,8 +862,9 @@ export function createDeterministicAuthorityRoom(transport, config) {
704
862
  everSlots: Object.fromEntries(everSlotByPlayer),
705
863
  earliestRetainedTick: authority.earliestRetainedTick,
706
864
  runtimeIdentity: config.runtimeIdentity,
707
- sessionConfigBytes: config.sessionConfigBytes.slice(),
708
- sessionConfigDigest: config.sessionConfigDigest,
865
+ sessionConfigBytes: sessionConfigBytes.slice(),
866
+ sessionConfigDigest,
867
+ generation,
709
868
  ...(storedSnapshot === undefined
710
869
  ? {}
711
870
  : {
@@ -741,15 +900,19 @@ export function createDeterministicAuthorityRoom(transport, config) {
741
900
  confirmedThrough: authority.confirmedThrough,
742
901
  substitutedTicksBySlot: Object.fromEntries(substitutedTicksBySlot),
743
902
  clientStats: Object.fromEntries(clientNetStatsBySlot),
903
+ rejectedCommands,
904
+ rejectedReconfigures,
905
+ rejectedEphemeral,
744
906
  };
745
907
  },
908
+ reconfigureSession,
746
909
  matchLog() {
747
910
  if (authority.confirmedThrough < 0 || !historyReachesTickZero()) {
748
911
  return undefined;
749
912
  }
750
913
  return {
751
914
  v: 1,
752
- seed: config.seed,
915
+ seed,
753
916
  tickRateHz: config.tickRateHz,
754
917
  playerCount: config.playerCount,
755
918
  hardToleranceTicks: config.hardToleranceTicks,
@@ -763,6 +926,9 @@ export function createDeterministicAuthorityRoom(transport, config) {
763
926
  },
764
927
  };
765
928
  }
929
+ function canonicalStringifyByteLength(value) {
930
+ return encodeUtf8(canonicalStringify(value)).byteLength;
931
+ }
766
932
  function requireSecretAuthorityId(authorityId) {
767
933
  invariant(typeof authorityId === 'string' && authorityId.length > 0, 'secret authorityId is required', 'authority-room.secret-authority-id');
768
934
  return authorityId;