@series-inc/rundot-syncplay 5.25.0-beta.6 → 5.25.0-beta.7

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
@@ -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 |
@@ -184,8 +189,60 @@ bounded confirmed-frame queue via the optional `confirmedPresentationBufferSize`
184
189
  transport's reconnect signal so replayed history never fills the queue.
185
190
  `prepareNetworkedSyncplayTransport(transport, prepare, { signal })` buffers the
186
191
  ordered `session-start`/history traffic (same 8,192-message ceiling as the SDK
187
- transport), exposes a frozen descriptor with `levelEntryId + digest + length`,
188
- awaits your async preparation, then replays every buffered message once.
192
+ transport), exposes a frozen `NetworkedSyncplaySessionDescriptor`, awaits your
193
+ async preparation, then replays every buffered message once.
194
+
195
+ The descriptor is exactly `{ slot, playerCount, seed, tickRateHz,
196
+ hardToleranceTicks, role, runtimeIdentity, sessionConfigBytes,
197
+ sessionConfigDigest }`. It does **not** carry `levelEntryId`, a level digest, or a
198
+ level length — those are game-specific fields inside `sessionConfigBytes`, and you
199
+ decode them with your own session-config schema:
200
+
201
+ ```ts
202
+ import { decodeKinetixSessionConfig } from '@series-inc/rundot-kinetix/runtime';
203
+ import { kinetixPlatformer2dSessionConfigSchema } from '@series-inc/rundot-kinetix/runtime';
204
+
205
+ const prepareNetworkRuntime = async (descriptor, signal) => {
206
+ const session = decodeKinetixSessionConfig(
207
+ kinetixPlatformer2dSessionConfigSchema,
208
+ descriptor.sessionConfigBytes,
209
+ );
210
+ // session.levelEntryId / session.levelDigest / session.levelByteLength
211
+ const levelBytes = await preparePlatformerLevel(api.ugc, {
212
+ levelEntryId: session.levelEntryId,
213
+ levelDigest: session.levelDigest,
214
+ levelByteLength: session.levelByteLength,
215
+ encodeLevel: (level) => encodeKinetixPlatformer2dLevel(level, { maxBytes: 102_400 }),
216
+ });
217
+ registerPreparedLevel(descriptor.sessionConfigDigest, levelBytes);
218
+ };
219
+ ```
220
+
221
+ ## Safe input codecs
222
+
223
+ `defineSyncplayInputCodec(schema)` derives `{ encodeInput, decodeInput,
224
+ neutralInput }` from an exact field declaration (`boolean`, `int` with
225
+ `min`/`max`, `enum` of numbers). Decoding a non-conforming payload throws
226
+ `SYNCPLAY_INPUT_DECODE_INVALID`, and the canonical `null` substitute maps to
227
+ `neutralInput` — i.e. correct `decodeInputSafe` semantics by construction, rather
228
+ than by remembering to write them. Neutral is `false` for booleans and `0` for
229
+ numeric fields when their range admits it; otherwise the field must declare
230
+ `neutral` or construction throws `SYNCPLAY_INPUT_SCHEMA_INVALID`.
231
+
232
+ For an existing hand-written codec, `validateSyncplayInputCodec({ encodeInput,
233
+ decodeInput, samples })` probes round-trip stability, `null → neutral`, and a
234
+ battery of hostile payloads, failing with `SYNCPLAY_INPUT_CODEC_UNSAFE`.
235
+
236
+ ## Version-safe content references
237
+
238
+ `publishPlatformerLevel(api, level, options)` always calls `ugc.create` — never
239
+ `ugc.update` on a referenced entry, which would make every live room pinned to it
240
+ permanently unjoinable. `preparePlatformerLevel(api, declaration)` resolves a pin
241
+ and raises the typed `SYNCPLAY_LEVEL_CONTENT_CHANGED` (naming the entry id and the
242
+ edit-vs-pin cause) rather than an opaque `KINETIX_AUX_ASSET_MISMATCH` deep inside
243
+ runtime construction. `declaration.encodeLevel` is an **injected callback**, not a
244
+ static Kinetix import, because Syncplay must compile against both the published
245
+ Kinetix pin and an overlaid local build.
189
246
 
190
247
  **Capacity and late-join budgets.** Snapshot transfer defaults are **256 KiB**
191
248
  chunks and a **4 MiB** total ceiling; the immutable level payload is fetched and
@@ -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;
package/dist/browser.d.ts CHANGED
@@ -11,6 +11,8 @@ export { createDeterministicEventEffectsAdapter } from './effects-adapter.js';
11
11
  export { assertDeterministicFrameHeapStaleHandleError, assertDeterministicFrameSystemsStateless, compileDeterministicDslRuntimeDescriptor, createDeterministicBitset, createDeterministicFrameRuntime, createDeterministicFrameRuntimeAccessors, createDeterministicSlotMap, createDeterministicSparseSetFrameRuntime, generateDeterministicFrameRuntimeSource, isDeterministicFrameHeapStaleHandleError, runDeterministicFrameRuntimeMutationCorpus, validateDeterministicFrameSystemsStateless } from './engine-runtime.js';
12
12
  export { createDeterministicEventTimeline, filterDeterministicEventsForPlayer } from './events.js';
13
13
  export { createInputAuthority } from './input-authority.js';
14
+ export { defineSyncplayInputCodec, validateSyncplayInputCodec } from './input-codec.js';
15
+ export { preparePlatformerLevel, publishPlatformerLevel, SYNCPLAY_PLATFORMER_LEVEL_CONTENT_TYPE, SYNCPLAY_PLATFORMER_LEVEL_DATA_KEY } from './platformer-content.js';
14
16
  export { createInstantReplayRunner, runInstantReplay } from './instant-replay.js';
15
17
  export { buildDeterministicLagCompensatedHitboxProxies3D, createDeterministicLagCompensation3DHistory, rewindDeterministicLagCompensatedTargets3D, validateDeterministicAuthoritativeLagCompensatedShot3D, validateDeterministicLagCompensatedShot3D } from './lag-compensation3d.js';
16
18
  export { createDeterministicLocalAuthoritySnapshotChunkEnvelope, createDeterministicLocalAuthoritySnapshotCompleteEnvelope, createDeterministicLocalAuthoritySnapshotOfferEnvelope, createDeterministicLocalAuthoritySnapshotRequestEnvelope, decodeDeterministicLocalAuthorityWireEnvelope, deterministicLocalAuthorityWireProtocol, encodeDeterministicLocalAuthorityWireEnvelope } from './local-authority-wire.js';
@@ -51,6 +53,8 @@ export type { DeterministicEffectDispatch, DeterministicEffectTarget, Determinis
51
53
  export type { DeterministicCommandAccessor, DeterministicComponentAccessor, DeterministicComponentData, DeterministicComponentDescriptor, DeterministicComponentFieldDescriptor, DeterministicComponentFieldKind, DeterministicComponentFixedArrayFieldKind, DeterministicComponentFrameHeapFieldKind, DeterministicComponentScalarFieldKind, DeterministicDslRuntimeCompileOptions, DeterministicEntityRef, DeterministicEventAccessor, DeterministicFrameHeapCollectionKind, DeterministicFrameHeapCollectionSnapshot, DeterministicFrameHeapHandle, DeterministicFrameHeapSnapshot, DeterministicFrameQueryFilter, DeterministicFrameRuntime, DeterministicFrameRuntimeAccessors, DeterministicFrameRuntimeDescriptor, DeterministicFrameRuntimeOptions, DeterministicFrameSnapshot, DeterministicQueryRow, DeterministicRuntimeMessage, DeterministicRuntimePayloadDescriptor, DeterministicRuntimeSignalHandler, DeterministicSignalAccessor, DeterministicSingletonAccessor, DeterministicStatelessSystemDiagnostic, DeterministicStatelessSystemDiagnosticReason, DeterministicStatelessSystemValidationResult, DeterministicSystem, DeterministicSystemContext, DeterministicSystemDescriptor } from './engine-runtime.js';
52
54
  export type { DeterministicEventDeliveryScope, DeterministicEventEmitOptions, DeterministicEventPredictionState, DeterministicEventRecord, DeterministicEventTimeline } from './events.js';
53
55
  export type { ConfirmedInputFrame, EncodedInput, InputAuthority, InputAuthorityConfig, InputAuthorityRestoreState, InputAuthorityStats, InputRejectionReason, InputSubmissionResult } from './input-authority.js';
56
+ export type { SyncplayBooleanInputField, SyncplayEnumInputField, SyncplayInputCodec, SyncplayInputCodecProbe, SyncplayInputField, SyncplayInputSchema, SyncplayIntInputField } from './input-codec.js';
57
+ export type { PlatformerLevelDeclaration, PlatformerLevelUgcApi, PlatformerLevelUgcCreateParams, PlatformerLevelUgcEntry, PublishPlatformerLevelOptions } from './platformer-content.js';
54
58
  export type { InstantReplayInputFrame, InstantReplayResult, InstantReplayRunner, InstantReplayRunnerOptions, InstantReplayRunOptions, RunInstantReplayOptions } from './instant-replay.js';
55
59
  export type { DeterministicAuthoritativeLagCompensation3DShotContext, DeterministicLagCompensatedPellet3DResult, DeterministicLagCompensatedShot3D, DeterministicLagCompensatedShot3DResult, DeterministicLagCompensatedTarget3D, DeterministicLagCompensation3DHistory, DeterministicLagCompensation3DHistoryOptions, DeterministicLagCompensation3DHistorySnapshot, DeterministicLagCompensation3DRewindSnapshot } from './lag-compensation3d.js';
56
60
  export type { DeterministicLocalAuthoritySnapshotIdentity, DeterministicLocalAuthoritySnapshotTransfer, DeterministicLocalAuthorityWireEnvelope, DeterministicLocalAuthorityWireKind } from './local-authority-wire.js';
@@ -59,8 +63,8 @@ export type { DeterministicFpsAimState, DeterministicFpsInput, DeterministicKccB
59
63
  export type { DeterministicKccBody3D, DeterministicKccCarrierPose3D, DeterministicKccCarryResult3D, DeterministicKccCarryState3D, DeterministicKccCollisionCandidate3D, DeterministicKccCollisionFilter3D, DeterministicKccExternalImpulse3D, DeterministicKccGeometry3D, DeterministicKccInput3D, DeterministicKccPhysicsImpulse3D, DeterministicKccProcessor3D, DeterministicKccStepResult3D, DeterministicKccWorld3D, DeterministicMovingPlatform3D, DeterministicSlope3D, DeterministicStep3D } from './movement3d.js';
60
64
  export type { CookedDeterministicNavigationGraph, DeterministicFlowField, DeterministicNavEdge, DeterministicNavigationAgent, DeterministicNavigationGraphSource, DeterministicNavigationStepOptions, DeterministicNavigationStepResult, DeterministicNavNode, DeterministicNavRegion, DeterministicPathQuery, DeterministicPathResult, DeterministicTilemapSource } from './navigation.js';
61
65
  export type { CookedDeterministicNavmesh, DeterministicNavmeshAgent, DeterministicNavmeshAgentStepResult, DeterministicNavmeshImportSource, DeterministicNavmeshLink, DeterministicNavmeshMovingAvoidanceObstacle, DeterministicNavmeshOffMeshTraversalState, DeterministicNavmeshOffMeshTraversalStep, DeterministicNavmeshPath, DeterministicNavmeshPoint, DeterministicNavmeshPolygon, DeterministicNavmeshSource } from './navmesh.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';
66
+ export type { ConfirmedSyncplayPresentationFrame, KinetixRuntimeFactory, NetworkedClientTransport, NetworkedSyncplayClient, NetworkedSyncplayClientInputDelayOptions, NetworkedSyncplayClientNetStats, NetworkedSyncplayClientOptions, NetworkedSyncplayClientPacingOptions, NetworkedSyncplayPreparationOptions, NetworkedSyncplaySessionDescriptor, NetworkedSyncplaySlotPresence, PreparedNetworkedSyncplayTransport, SyncplayPredictionMode } from './networked-client.js';
67
+ export type { SyncplayRunner, SyncplayRunnerConfig, SyncplayRunnerConnectionEvent, SyncplayRunnerOccupancy, SyncplayRunnerOfflineOptions, SyncplayRunnerPresentation, SyncplayRunnerSnapshot, SyncplayRunnerStatus, SyncplayRunnerTransport } from './runner.js';
64
68
  export type { DeterministicBodyKind, DeterministicContactEvent2D, DeterministicContactEventType, DeterministicPhysicsAabbQuery2D, DeterministicPhysicsActivePair2D, DeterministicPhysicsBody2D, DeterministicPhysicsCircleCastQuery2D, DeterministicPhysicsConstraint2D, DeterministicPhysicsHit2D, DeterministicPhysicsQueryOptions2D, DeterministicPhysicsRaycastQuery2D, DeterministicPhysicsShape2D, DeterministicPhysicsShapeOverlapQuery2D, DeterministicPhysicsStepOptions2D, DeterministicPhysicsStepResult2D, DeterministicPhysicsWorld2D } from './physics2d.js';
65
69
  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';
66
70
  export type { DeterministicVoxelChunk3D, DeterministicVoxelCompoundShape3D } from './physics3d-voxel.js';
@@ -71,7 +75,7 @@ export type { SchemaBinaryProof, SchemaBinarySourceOptions, SchemaTypesOptions }
71
75
  export type { DeterministicBinarySerializer, DeterministicDslDeclaration, DeterministicDslDeclarationKind, DeterministicDslDictionaryEntry, DeterministicDslDictionaryFieldKind, DeterministicDslField, DeterministicDslFieldKind, DeterministicDslFixedArrayFieldKind, DeterministicDslListFieldKind, DeterministicDslScalarFieldKind, DeterministicDslScalarValue, DeterministicDslSchema, DeterministicDslSetFieldKind, DeterministicDslValue } from './schema-dsl-binary.js';
72
76
  export type { SyncplaySession, SyncplaySessionPolicy } from './sdk-session.js';
73
77
  export type { CollectedSnapshotTransfer, CreateSnapshotTransferOptions, DeterministicStateSnapshot, DeterministicStateSnapshotDecodeRejection, DeterministicStateSnapshotDecodeResult, SnapshotTransferCollector, SnapshotTransferCollectorOptions, SnapshotTransferCompleteResult, SnapshotTransferRejection, SnapshotTransferStepResult } from './session-snapshot.js';
74
- export type { DeterministicSessionDecodeRejection, DeterministicSessionDecodeResult, DeterministicSessionMessage, DeterministicSessionMessageKind, DeterministicSessionRole } from './session-wire.js';
78
+ export type { DeterministicCommandRequestMessage, DeterministicEphemeralMessage, DeterministicReconfigureRequestMessage, DeterministicSessionDecodeRejection, DeterministicSessionDecodeResult, DeterministicSessionMessage, DeterministicSessionMessageKind, DeterministicSessionRole } from './session-wire.js';
75
79
  export type { DeterministicSignal, DeterministicSignalBus, DeterministicSignalBusSnapshot, DeterministicSignalSubscription } from './signals.js';
76
80
  export type { DeterministicSparseSet, DeterministicSparseSetSnapshot } from './sparse-set.js';
77
81
  export type { DeterministicLifecycleCounterSummary, DeterministicLifecycleRunner, DeterministicLifecycleSignal, DeterministicLifecycleSnapshot, DeterministicLifecycleSystemContext, DeterministicLifecycleSystemDescriptor, DeterministicLifecycleSystemHooks } from './system-lifecycle.js';
package/dist/browser.js CHANGED
@@ -12,6 +12,8 @@ export { createDeterministicEventEffectsAdapter } from './effects-adapter.js';
12
12
  export { assertDeterministicFrameHeapStaleHandleError, assertDeterministicFrameSystemsStateless, compileDeterministicDslRuntimeDescriptor, createDeterministicBitset, createDeterministicFrameRuntime, createDeterministicFrameRuntimeAccessors, createDeterministicSlotMap, createDeterministicSparseSetFrameRuntime, generateDeterministicFrameRuntimeSource, isDeterministicFrameHeapStaleHandleError, runDeterministicFrameRuntimeMutationCorpus, validateDeterministicFrameSystemsStateless } from './engine-runtime.js';
13
13
  export { createDeterministicEventTimeline, filterDeterministicEventsForPlayer } from './events.js';
14
14
  export { createInputAuthority } from './input-authority.js';
15
+ export { defineSyncplayInputCodec, validateSyncplayInputCodec } from './input-codec.js';
16
+ export { preparePlatformerLevel, publishPlatformerLevel, SYNCPLAY_PLATFORMER_LEVEL_CONTENT_TYPE, SYNCPLAY_PLATFORMER_LEVEL_DATA_KEY } from './platformer-content.js';
15
17
  export { createInstantReplayRunner, runInstantReplay } from './instant-replay.js';
16
18
  export { buildDeterministicLagCompensatedHitboxProxies3D, createDeterministicLagCompensation3DHistory, rewindDeterministicLagCompensatedTargets3D, validateDeterministicAuthoritativeLagCompensatedShot3D, validateDeterministicLagCompensatedShot3D } from './lag-compensation3d.js';
17
19
  export { createDeterministicLocalAuthoritySnapshotChunkEnvelope, createDeterministicLocalAuthoritySnapshotCompleteEnvelope, createDeterministicLocalAuthoritySnapshotOfferEnvelope, createDeterministicLocalAuthoritySnapshotRequestEnvelope, decodeDeterministicLocalAuthorityWireEnvelope, deterministicLocalAuthorityWireProtocol, encodeDeterministicLocalAuthorityWireEnvelope } from './local-authority-wire.js';
@@ -39,6 +39,8 @@ export class DevSyncplayAuthorityRoom {
39
39
  ? undefined
40
40
  : validateSyncplaySecretSystemsConfig(deterministic.secrets, playerCount);
41
41
  const secretCrypto = secretConfig === undefined ? undefined : createEphemeralSecretCrypto();
42
+ const reconfigurePolicy = deterministic?.reconfigurePolicy;
43
+ const commandsPerSecond = deterministic?.commandsPerSecond;
42
44
  // The server owns the seed: derive it deterministically from the room id so
43
45
  // every client of this room receives the same seed (same scheme as prod).
44
46
  const seed = deriveSeed(ctx.roomId);
@@ -55,6 +57,16 @@ export class DevSyncplayAuthorityRoom {
55
57
  sessionConfigBytes: runtime.sessionConfigBytes,
56
58
  sessionConfigDigest: runtime.sessionConfigDigest,
57
59
  redundancyWindowTicks: readPositiveInt(config.redundancyWindowTicks, DEFAULT_REDUNDANCY_WINDOW_TICKS),
60
+ // Collaborative-editing knobs, read from the same `deterministic` block
61
+ // the production room server reads. Without these a `reconfigurePolicy`
62
+ // a creator declared in their realtime config is silently ignored under
63
+ // `npm run dev`, and every publish is dropped by the default 'none'.
64
+ ...(reconfigurePolicy === undefined ? {} : { reconfigurePolicy }),
65
+ ...(commandsPerSecond === undefined ? {} : { commandsPerSecond }),
66
+ // Slot 0 is the room's first seat — the creator in the create-and-share
67
+ // flow. The dev sidecar has no platform identity to consult, so this
68
+ // matches the production wrapper's choice.
69
+ creatorSlot: 0,
58
70
  // Game-agnostic no-op input: the authority substitutes canonical null for
59
71
  // a slot with no prior input; the client maps null to the game's default.
60
72
  neutralInput: null,
@@ -14,8 +14,14 @@ export interface DeterministicEventEffectsAdapter<Payload, Effect> {
14
14
  subscribe(listener: (dispatch: DeterministicEffectDispatch<Payload, Effect>) => void): () => void;
15
15
  reset(): void;
16
16
  }
17
+ export declare const DEFAULT_EFFECT_RETENTION_FRAMES = 1024;
17
18
  export interface DeterministicEventEffectsAdapterConfig<Payload, Effect> {
18
19
  readonly target: DeterministicEffectTarget;
19
20
  readonly mapEvent: (record: DeterministicEventRecord<Payload>) => Effect | undefined;
21
+ /**
22
+ * How far behind the newest dispatched frame a record is still remembered for
23
+ * dedupe, in frames. Defaults to {@link DEFAULT_EFFECT_RETENTION_FRAMES}.
24
+ */
25
+ readonly retentionFrames?: number;
20
26
  }
21
27
  export declare function createDeterministicEventEffectsAdapter<Payload, Effect>(config: DeterministicEventEffectsAdapterConfig<Payload, Effect>): DeterministicEventEffectsAdapter<Payload, Effect>;
@@ -1,21 +1,48 @@
1
1
  import { cloneCanonical } from './canonical.js';
2
2
  import { stableSortedKeys } from './collections.js';
3
3
  import { SyncplayError } from './errors.js';
4
+ export const DEFAULT_EFFECT_RETENTION_FRAMES = 1024;
4
5
  export function createDeterministicEventEffectsAdapter(config) {
5
6
  validateTarget(config.target);
6
- const dispatched = [];
7
- const seen = new Set();
7
+ const retentionFrames = config.retentionFrames ?? DEFAULT_EFFECT_RETENTION_FRAMES;
8
+ if (!Number.isSafeInteger(retentionFrames) || retentionFrames < 1) {
9
+ throw new SyncplayError(`Effect retention must be a positive integer, got ${String(retentionFrames)}`, {
10
+ code: 'effects.retention',
11
+ });
12
+ }
13
+ let dispatched = [];
14
+ const seen = new Map();
8
15
  const listeners = new Set();
16
+ let newestFrame = 0;
17
+ // Both `seen` and `dispatched` used to grow until teardown, so a long match
18
+ // leaked one entry per event forever. Records older than the retention window
19
+ // can no longer be re-delivered by the confirmed-frame ring, so dropping them
20
+ // cannot resurrect a duplicate.
21
+ const prune = () => {
22
+ const horizon = newestFrame - retentionFrames;
23
+ if (horizon <= 0)
24
+ return;
25
+ for (const [key, frame] of seen) {
26
+ if (frame < horizon)
27
+ seen.delete(key);
28
+ }
29
+ if (dispatched.length > 0 && dispatched[0].record.frame < horizon) {
30
+ dispatched = dispatched.filter((entry) => entry.record.frame >= horizon);
31
+ }
32
+ };
9
33
  return {
10
34
  dispatch(records) {
11
35
  const nextDispatches = [];
12
36
  for (const record of records) {
13
- const dedupeKey = `${record.sequence}:${record.predictionState}`;
37
+ // A game-supplied dedupeKey is authoritative: sequence alone collides
38
+ // across frames once a runtime restarts its per-frame numbering.
39
+ const dedupeKey = record.dedupeKey ?? `${record.frame}:${record.sequence}:${record.predictionState}`;
14
40
  if (seen.has(dedupeKey)) {
15
41
  continue;
16
42
  }
17
43
  const effect = config.mapEvent(cloneEventRecord(record));
18
- seen.add(dedupeKey);
44
+ seen.set(dedupeKey, record.frame);
45
+ newestFrame = Math.max(newestFrame, record.frame);
19
46
  if (effect === undefined) {
20
47
  continue;
21
48
  }
@@ -30,8 +57,10 @@ export function createDeterministicEventEffectsAdapter(config) {
30
57
  listener(dispatch);
31
58
  }
32
59
  }
60
+ prune();
33
61
  return nextDispatches;
34
62
  },
63
+ /** Bounded: only dispatches within the retention window are retained. */
35
64
  getDispatched() {
36
65
  return dispatched.map((dispatch) => freezeEffectDispatch({
37
66
  target: dispatch.target,
@@ -46,8 +75,9 @@ export function createDeterministicEventEffectsAdapter(config) {
46
75
  };
47
76
  },
48
77
  reset() {
49
- dispatched.length = 0;
78
+ dispatched = [];
50
79
  seen.clear();
80
+ newestFrame = 0;
51
81
  },
52
82
  };
53
83
  }