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

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
@@ -52,6 +52,33 @@ session-config bytes, asks the game for a matching runtime through its runtime
52
52
  factory, and rejects a locally loaded pack mismatch before frame 0. Replays use
53
53
  the same identity/config/factory path.
54
54
 
55
+ ## Presentation frames
56
+
57
+ `session.getState()` returns a detached deep copy of the current frame, which
58
+ costs O(state) per read and is unaffordable for a large mutable world (a
59
+ destructible voxel city, a big streaming map).
60
+
61
+ `session.getPresentationFrame()` is the no-clone path. It calls the runtime's
62
+ optional `project(checkpoint, previous)` and returns the result untouched:
63
+
64
+ - **Requires** a runtime built with a `projectState` hook. A runtime without
65
+ `project` throws `SYNCPLAY_PROJECTION_UNSUPPORTED` — there is no silent
66
+ fallback to `inspect`.
67
+ - **The result is immutable.** Callers must not mutate it. It may share structure
68
+ with authoritative state and with previously returned projections, which is
69
+ exactly what lets a renderer diff persistent roots and skip shared subtrees.
70
+ - **`previous` is the last frame this session projected**, enabling delta
71
+ projections. It is absent — meaning the runtime should produce a full frame —
72
+ on the first call, after `restoreToFrame` or `restoreSnapshot`, and whenever
73
+ the previously projected frame is no longer retained (pruned or rolled past).
74
+ - **Reading the same frame twice is free and idempotent.** Repeat calls return
75
+ the identical projection rather than re-projecting the frame against itself
76
+ (which would report an empty delta). Authoritative state cannot change without
77
+ the frame advancing, so multiple render passes per frame are safe.
78
+
79
+ Projections are presentation-only: they never re-enter simulation and are not
80
+ part of deterministic identity.
81
+
55
82
  ## Public projections and secrets
56
83
 
57
84
  Every peer applies the same confirmed inputs and authority commands. Optional
@@ -1,6 +1,19 @@
1
1
  import { type KinetixRuntime, type KinetixRuntimeIdentity } from '@series-inc/rundot-kinetix/runtime';
2
2
  import type { RecordedChecksum, RecordedCommandFrame, RecordedInputFlags, RecordedInputFrame, ReplayFile } from './types.js';
3
3
  export type { KinetixRuntime, KinetixRuntimeIdentity } from '@series-inc/rundot-kinetix/runtime';
4
+ /**
5
+ * A Kinetix runtime that may expose the optional no-clone presentation
6
+ * projection, re-typed for this session's Projection.
7
+ *
8
+ * `project` is omitted and re-declared rather than inherited so this compiles
9
+ * against both Kinetix builds we resolve against: the published ABI v1 runtime
10
+ * (no `project`) that the lockfile pins and publish lanes keep, and the
11
+ * in-repo runtime (whose `project` defaults to `Projection = State`) that test
12
+ * lanes overlay. Presence is feature-detected at runtime either way.
13
+ */
14
+ export type ProjectionCapableRuntime<State, Input, Checkpoint, Projection> = Omit<KinetixRuntime<State, Input, Checkpoint>, 'project'> & {
15
+ project?(checkpoint: Checkpoint, previous?: Checkpoint): Readonly<Projection>;
16
+ };
4
17
  export interface RuntimeExternalSnapshot<Input> {
5
18
  readonly frame: number;
6
19
  readonly runtimeBytes: Uint8Array;
@@ -16,8 +29,8 @@ export interface RuntimeFrameSnapshot<State, Input, Checkpoint> {
16
29
  readonly checkpoint: Checkpoint;
17
30
  readonly state: State;
18
31
  }
19
- export interface RuntimeSessionConfig<State, Input, Checkpoint> {
20
- readonly runtime: KinetixRuntime<State, Input, Checkpoint>;
32
+ export interface RuntimeSessionConfig<State, Input, Checkpoint, Projection = State> {
33
+ readonly runtime: ProjectionCapableRuntime<State, Input, Checkpoint, Projection>;
21
34
  readonly playerCount: number;
22
35
  readonly defaultInput: Input;
23
36
  readonly snapshotBufferSize?: number;
@@ -30,7 +43,7 @@ export interface RuntimeSessionConfig<State, Input, Checkpoint> {
30
43
  readonly onCommandFrameRecorded?: (frame: RecordedCommandFrame) => void;
31
44
  readonly onChecksumRecorded?: (checksum: RecordedChecksum) => void;
32
45
  }
33
- export interface RuntimeSession<State, Input, Checkpoint = unknown> {
46
+ export interface RuntimeSession<State, Input, Checkpoint = unknown, Projection = State> {
34
47
  readonly currentFrame: number;
35
48
  readonly accumulatedMs: number;
36
49
  readonly schemaIdentity: KinetixRuntimeIdentity;
@@ -40,6 +53,13 @@ export interface RuntimeSession<State, Input, Checkpoint = unknown> {
40
53
  update(deltaMs: number): number;
41
54
  stepFrames(count: number): void;
42
55
  getState(): State;
56
+ /**
57
+ * Projects the current frame for presentation without cloning. Requires a
58
+ * runtime that implements `project`; throws SYNCPLAY_PROJECTION_UNSUPPORTED
59
+ * otherwise. The result is immutable and may share structure with previous
60
+ * frames — callers must not mutate it.
61
+ */
62
+ getPresentationFrame(): Readonly<Projection>;
43
63
  exportReplay(): ReplayFile;
44
64
  peekSnapshot(frame: number): RuntimeFrameSnapshot<State, Input, Checkpoint> | undefined;
45
65
  getSnapshot(frame: number): RuntimeFrameSnapshot<State, Input, Checkpoint> | undefined;
@@ -53,4 +73,4 @@ export interface RuntimeSession<State, Input, Checkpoint = unknown> {
53
73
  getSnapshotFrames(): readonly number[];
54
74
  dispose(): void;
55
75
  }
56
- export declare function createRuntimeSession<State, Input, Checkpoint>(config: RuntimeSessionConfig<State, Input, Checkpoint>): RuntimeSession<State, Input, Checkpoint>;
76
+ export declare function createRuntimeSession<State, Input, Checkpoint, Projection = State>(config: RuntimeSessionConfig<State, Input, Checkpoint, Projection>): RuntimeSession<State, Input, Checkpoint, Projection>;
@@ -38,6 +38,7 @@ export function createRuntimeSession(config) {
38
38
  let accumulatedMs = 0;
39
39
  let disposed = false;
40
40
  let hydrated = false;
41
+ let lastProjected;
41
42
  const frame0 = config.runtime.capture();
42
43
  retained.set(config.runtime.currentFrame, {
43
44
  checkpoint: frame0,
@@ -116,7 +117,19 @@ export function createRuntimeSession(config) {
116
117
  checksum: frame % checksumInterval === 0,
117
118
  });
118
119
  }
119
- const results = config.runtime.advance(requests);
120
+ let results;
121
+ try {
122
+ results = config.runtime.advance(requests);
123
+ }
124
+ catch (error) {
125
+ // advance() is atomic: the batch stepped no frames, so its commands must
126
+ // not be replayed on the next attempt. A command the runtime refuses to
127
+ // decode would otherwise be rebuilt into every subsequent request and
128
+ // wedge the session permanently.
129
+ for (const request of requests)
130
+ pendingCommands.delete(request.frame);
131
+ throw error;
132
+ }
120
133
  if (results.length !== requests.length)
121
134
  throw new Error('SYNCPLAY_RUNTIME_RESULT_COUNT_MISMATCH');
122
135
  for (let index = 0; index < results.length; index += 1) {
@@ -205,9 +218,11 @@ export function createRuntimeSession(config) {
205
218
  const count = Math.max(0, Math.floor((total * config.runtime.identity.tickRate + 1e-9) / 1000));
206
219
  if (count > (config.maxCatchUpFrames ?? 240))
207
220
  throw new Error('session.catch-up-limit');
208
- accumulatedMs = total;
209
221
  runFrames(count);
210
- accumulatedMs = Math.max(0, accumulatedMs - count * (1000 / config.runtime.identity.tickRate));
222
+ // Commit consumed time only after the frames actually land; if runFrames
223
+ // throws, accumulatedMs keeps its pre-update value so a later update does
224
+ // not over-count catch-up for frames that never stepped.
225
+ accumulatedMs = Math.max(0, total - count * (1000 / config.runtime.identity.tickRate));
211
226
  return count;
212
227
  },
213
228
  stepFrames: runFrames,
@@ -215,6 +230,29 @@ export function createRuntimeSession(config) {
215
230
  active();
216
231
  return structuredClone(config.runtime.inspect(requireRetained(config.runtime.currentFrame).checkpoint));
217
232
  },
233
+ getPresentationFrame() {
234
+ active();
235
+ const project = config.runtime.project;
236
+ if (typeof project !== 'function')
237
+ throw new Error('SYNCPLAY_PROJECTION_UNSUPPORTED');
238
+ const frame = config.runtime.currentFrame;
239
+ const entry = requireRetained(frame);
240
+ // Re-reading a frame must not diff it against itself — a delta hook given
241
+ // the current frame as its own baseline would report nothing changed.
242
+ // State cannot move without the frame advancing, so the projection this
243
+ // frame already produced is still the answer.
244
+ if (lastProjected?.frame === frame)
245
+ return lastProjected.projection;
246
+ // A previously projected frame may have been pruned or rolled past; the
247
+ // runtime then gets no baseline and produces a full frame. Deliberate
248
+ // contract, not a fallback.
249
+ const previous = lastProjected === undefined
250
+ ? undefined
251
+ : retained.get(lastProjected.frame)?.checkpoint;
252
+ const projection = project.call(config.runtime, entry.checkpoint, previous);
253
+ lastProjected = { frame, projection };
254
+ return projection;
255
+ },
218
256
  exportReplay() {
219
257
  active();
220
258
  if (hydrated)
@@ -277,6 +315,7 @@ export function createRuntimeSession(config) {
277
315
  commandFrames.splice(0, commandFrames.length, ...commandFrames.filter((record) => record.frame <= frame));
278
316
  checksums.splice(0, checksums.length, ...checksums.filter((record) => record.frame <= frame));
279
317
  accumulatedMs = 0;
318
+ lastProjected = undefined;
280
319
  },
281
320
  restoreSnapshot(snapshot) {
282
321
  active();
@@ -314,6 +353,7 @@ export function createRuntimeSession(config) {
314
353
  commandFrames.length = 0;
315
354
  checksums.length = 0;
316
355
  accumulatedMs = 0;
356
+ lastProjected = undefined;
317
357
  hydrated = true;
318
358
  },
319
359
  getSnapshotFrames() { active(); return [...retained.keys()]; },
@@ -323,6 +363,9 @@ export function createRuntimeSession(config) {
323
363
  for (const entry of retained.values())
324
364
  release(entry.checkpoint);
325
365
  retained.clear();
366
+ // A projection may share structure with the whole world; releasing the
367
+ // checkpoints while still pinning it would defeat the release.
368
+ lastProjected = undefined;
326
369
  disposed = true;
327
370
  },
328
371
  };
@@ -1,5 +1,4 @@
1
- import type { KinetixRuntime } from '@series-inc/rundot-kinetix/runtime';
2
- import { type RuntimeSession, type RuntimeSessionConfig } from './runtime-session.js';
3
- export type SyncplaySessionPolicy<State, Input, Checkpoint = unknown> = Omit<RuntimeSessionConfig<State, Input, Checkpoint>, 'runtime'>;
4
- export type SyncplaySession<State, Input, Checkpoint = unknown> = RuntimeSession<State, Input, Checkpoint>;
5
- export declare function createSyncplaySession<State, Input, Checkpoint = unknown>(runtime: KinetixRuntime<State, Input, Checkpoint>, policy: SyncplaySessionPolicy<State, Input, Checkpoint>): SyncplaySession<State, Input, Checkpoint>;
1
+ import { type ProjectionCapableRuntime, type RuntimeSession, type RuntimeSessionConfig } from './runtime-session.js';
2
+ export type SyncplaySessionPolicy<State, Input, Checkpoint = unknown, Projection = State> = Omit<RuntimeSessionConfig<State, Input, Checkpoint, Projection>, 'runtime'>;
3
+ export type SyncplaySession<State, Input, Checkpoint = unknown, Projection = State> = RuntimeSession<State, Input, Checkpoint, Projection>;
4
+ export declare function createSyncplaySession<State, Input, Checkpoint = unknown, Projection = State>(runtime: ProjectionCapableRuntime<State, Input, Checkpoint, Projection>, policy: SyncplaySessionPolicy<State, Input, Checkpoint, Projection>): SyncplaySession<State, Input, Checkpoint, Projection>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/rundot-syncplay",
3
- "version": "5.25.0-beta.0",
3
+ "version": "5.25.0-beta.2",
4
4
  "description": "Kinetix orchestration, rollback, replay, late-join, and deterministic networking for RUN.game",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,8 +49,9 @@
49
49
  "scripts": {
50
50
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
51
51
  "test": "npm run test:core",
52
- "test:core": "tsx --test tests/canonical.test.ts tests/runtime-session.test.ts tests/kinetix-runtime-session.test.ts tests/runtime-support.test.ts tests/runtime-guards.test.ts tests/r3f-render-adapter.test.ts tests/snapshot-cost.test.ts tests/sdkSession.test.ts tests/input-authority.test.ts tests/session-wire.test.ts tests/session-snapshot.test.ts tests/authority-room.test.ts tests/networked-client-snapshot.test.ts tests/secret-config.test.ts tests/secret-protocol.test.ts tests/secret-authority.test.ts tests/secret-client.test.ts tests/late-join-checksum.test.ts tests/replay-scoring.test.ts tests/bot-backfill.test.ts tests/latency-simulation.test.ts tests/time-sync.test.ts tests/match-log.test.ts tests/spectator.test.ts tests/synctest.test.ts tests/instant-replay.test.ts tests/session-promotion.test.ts tests/physics3d-broadphase-stats.test.ts tests/entrypoints.test.ts tests/export-surface.test.ts",
52
+ "test:core": "tsx --test tests/canonical.test.ts tests/runtime-session.test.ts tests/kinetix-runtime-session.test.ts tests/runtime-support.test.ts tests/runtime-guards.test.ts tests/r3f-render-adapter.test.ts tests/snapshot-cost.test.ts tests/sdkSession.test.ts tests/input-authority.test.ts tests/session-wire.test.ts tests/session-snapshot.test.ts tests/authority-room.test.ts tests/networked-client-snapshot.test.ts tests/secret-config.test.ts tests/secret-protocol.test.ts tests/secret-authority.test.ts tests/secret-client.test.ts tests/late-join-checksum.test.ts tests/replay-scoring.test.ts tests/bot-backfill.test.ts tests/latency-simulation.test.ts tests/time-sync.test.ts tests/match-log.test.ts tests/spectator.test.ts tests/synctest.test.ts tests/instant-replay.test.ts tests/session-promotion.test.ts tests/physics3d-broadphase-stats.test.ts tests/entrypoints.test.ts tests/export-surface.test.ts tests/test-manifest.test.ts",
53
53
  "test:soak": "tsx --test tests/runtime-soak.test.ts",
54
+ "test:extended": "tsx --test tests/animation.test.ts tests/asset-cooking.test.ts tests/backend-proof-checksum-vectors.test.ts tests/bot-documents.test.ts tests/bot-primitives.test.ts tests/collider-cooking.test.ts tests/collision.test.ts tests/command-timeline.test.ts tests/config-bundle.test.ts tests/cosmetic-physics3d.test.ts tests/deterministic-primitives.test.ts tests/ecs-lite.test.ts tests/effects-adapter.test.ts tests/engine-runtime.test.ts tests/errors.test.ts tests/events.test.ts tests/input-button.test.ts tests/load-128-players.test.ts tests/math.test.ts tests/movement.test.ts tests/movement3d.test.ts tests/multiclient-authority.test.ts tests/navigation.test.ts tests/noise.test.ts tests/physics-cert.test.ts tests/physics2d.test.ts tests/physics3d-ccd.test.ts tests/physics3d-coverage.test.ts tests/physics3d-destruction-cert.test.ts tests/physics3d-heightfield.test.ts tests/physics3d-hull.test.ts tests/physics3d-joints.test.ts tests/physics3d-kinematic-mesh.test.ts tests/physics3d-mass.test.ts tests/physics3d-materials.test.ts tests/physics3d-mesh-onesided.test.ts tests/physics3d-query-index.test.ts tests/physics3d-recipes.test.ts tests/physics3d-rotation-convention.test.ts tests/physics3d-shared.test.ts tests/physics3d-sleeping-manifold-reuse.test.ts tests/physics3d-solver.test.ts tests/physics3d-stacking.test.ts tests/physics3d-toi.test.ts tests/physics3d-vehicle.test.ts tests/physics3d-voxel.test.ts tests/physics3d-world-edit.test.ts tests/physics3d.test.ts tests/random.test.ts tests/rollback-history.test.ts tests/schema-artifacts.test.ts tests/schema-codegen.test.ts tests/schema-dsl-binary.test.ts tests/schema.test.ts tests/signals.test.ts tests/sparse-set.test.ts tests/static-checker.test.ts tests/system-lifecycle.test.ts tests/wire-canonical-sentinels.test.ts tests/ws-frame.test.ts",
54
55
  "test:ci": "npm run build && npm run test:core",
55
56
  "pack:dry-run": "npm pack --dry-run"
56
57
  },
@@ -64,7 +65,7 @@
64
65
  "Kinetix"
65
66
  ],
66
67
  "dependencies": {
67
- "@series-inc/rundot-kinetix": "0.1.0-beta.0"
68
+ "@series-inc/rundot-kinetix": "0.1.0-beta.6"
68
69
  },
69
70
  "devDependencies": {
70
71
  "@dimforge/rapier2d-compat": "0.19.3",