@series-inc/rundot-syncplay 5.23.0-beta.7 → 5.23.0

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 (75) hide show
  1. package/README.md +116 -15
  2. package/dist/browser.d.ts +87 -55
  3. package/dist/browser.js +47 -52
  4. package/dist/certification.d.ts +32 -3
  5. package/dist/certification.js +88 -3
  6. package/dist/cjs/browser.js +259 -87
  7. package/dist/cjs/certification.js +87 -2
  8. package/dist/cjs/collision.js +58 -0
  9. package/dist/cjs/creator.js +2 -1
  10. package/dist/cjs/engine-complete.js +213 -3
  11. package/dist/cjs/engine-parity-matrix.js +19 -4
  12. package/dist/cjs/index.js +199 -132
  13. package/dist/cjs/math.js +342 -15
  14. package/dist/cjs/movement3d.js +230 -1
  15. package/dist/cjs/node.js +5 -1
  16. package/dist/cjs/noise.js +58 -0
  17. package/dist/cjs/physics-cert.js +147 -6
  18. package/dist/cjs/physics2d.js +10 -4
  19. package/dist/cjs/physics3d-joints.js +637 -0
  20. package/dist/cjs/physics3d-shared.js +288 -0
  21. package/dist/cjs/physics3d-solver.js +1351 -0
  22. package/dist/cjs/physics3d-vehicle.js +467 -0
  23. package/dist/cjs/physics3d.js +1057 -223
  24. package/dist/cjs/random.js +49 -0
  25. package/dist/cjs/replay-bundle.js +127 -20
  26. package/dist/cjs/sample-scenes.js +3 -0
  27. package/dist/cjs/sdk-offline.js +6 -32
  28. package/dist/cjs/sdk-session.js +156 -0
  29. package/dist/cjs/session-build.js +42 -0
  30. package/dist/cjs/testing.js +107 -0
  31. package/dist/cli.js +40 -12
  32. package/dist/collision.d.ts +12 -0
  33. package/dist/collision.js +52 -0
  34. package/dist/creator.d.ts +1 -1
  35. package/dist/creator.js +1 -1
  36. package/dist/engine-complete.js +214 -4
  37. package/dist/engine-parity-matrix.js +19 -4
  38. package/dist/index.d.ts +94 -89
  39. package/dist/index.js +45 -56
  40. package/dist/math.d.ts +59 -6
  41. package/dist/math.js +342 -15
  42. package/dist/movement3d.d.ts +73 -0
  43. package/dist/movement3d.js +229 -1
  44. package/dist/node.d.ts +4 -0
  45. package/dist/node.js +2 -0
  46. package/dist/noise.d.ts +7 -0
  47. package/dist/noise.js +51 -0
  48. package/dist/physics-cert.d.ts +1 -1
  49. package/dist/physics-cert.js +147 -6
  50. package/dist/physics2d.js +10 -4
  51. package/dist/physics3d-joints.d.ts +94 -0
  52. package/dist/physics3d-joints.js +634 -0
  53. package/dist/physics3d-shared.d.ts +72 -0
  54. package/dist/physics3d-shared.js +257 -0
  55. package/dist/physics3d-solver.d.ts +197 -0
  56. package/dist/physics3d-solver.js +1346 -0
  57. package/dist/physics3d-vehicle.d.ts +84 -0
  58. package/dist/physics3d-vehicle.js +463 -0
  59. package/dist/physics3d.d.ts +108 -8
  60. package/dist/physics3d.js +1006 -177
  61. package/dist/random.d.ts +7 -0
  62. package/dist/random.js +49 -0
  63. package/dist/replay-bundle.d.ts +40 -1
  64. package/dist/replay-bundle.js +126 -20
  65. package/dist/sample-scenes.js +3 -0
  66. package/dist/sdk-offline.js +6 -32
  67. package/dist/sdk-session.d.ts +47 -0
  68. package/dist/sdk-session.js +153 -0
  69. package/dist/session-build.d.ts +21 -0
  70. package/dist/session-build.js +39 -0
  71. package/dist/testing.d.ts +52 -0
  72. package/dist/testing.js +45 -0
  73. package/dist/tools/vite-plugin.d.ts +8 -0
  74. package/dist/tools/vite-plugin.js +63 -9
  75. package/package.json +11 -3
package/dist/random.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { DeterministicMath } from './math.js';
1
2
  export interface DeterministicRandomSnapshot {
2
3
  readonly state: number;
3
4
  }
@@ -8,6 +9,12 @@ export declare class DeterministicRandom {
8
9
  nextInt(minInclusive: number, maxInclusive: number): number;
9
10
  nextFixed(scale?: number): number;
10
11
  fork(label: string | number): DeterministicRandom;
12
+ nextBool(): boolean;
13
+ nextRangeFixed(minFixed: number, maxFixed: number): number;
14
+ nextAngleTurns(fixedScale: number): number;
15
+ pickOne<T>(items: readonly T[]): T;
16
+ weighted<T>(items: readonly T[], weights: readonly number[]): T;
17
+ gaussian(math: DeterministicMath): number;
11
18
  snapshot(): DeterministicRandomSnapshot;
12
19
  restore(snapshot: DeterministicRandomSnapshot): void;
13
20
  }
package/dist/random.js CHANGED
@@ -33,6 +33,55 @@ export class DeterministicRandom {
33
33
  child.restore({ state: (this.state ^ labelHash) >>> 0 });
34
34
  return child;
35
35
  }
36
+ nextBool() {
37
+ return (this.nextUint32() & 1) === 1;
38
+ }
39
+ nextRangeFixed(minFixed, maxFixed) {
40
+ if (maxFixed <= minFixed) {
41
+ throw new Error('DeterministicRandom.nextRangeFixed: max <= min');
42
+ }
43
+ const span = maxFixed - minFixed;
44
+ return minFixed + Math.trunc((this.nextUint32() / 4294967296) * span);
45
+ }
46
+ nextAngleTurns(fixedScale) {
47
+ return Math.trunc((this.nextUint32() / 4294967296) * fixedScale);
48
+ }
49
+ pickOne(items) {
50
+ if (items.length === 0) {
51
+ throw new Error('DeterministicRandom.pickOne: empty');
52
+ }
53
+ return items[this.nextInt(0, items.length - 1)];
54
+ }
55
+ weighted(items, weights) {
56
+ if (items.length !== weights.length || items.length === 0) {
57
+ throw new Error('DeterministicRandom.weighted: length mismatch');
58
+ }
59
+ let total = 0;
60
+ for (const w of weights) {
61
+ if (w < 0) {
62
+ throw new Error('DeterministicRandom.weighted: negative weight');
63
+ }
64
+ total += w;
65
+ }
66
+ if (total <= 0) {
67
+ throw new Error('DeterministicRandom.weighted: non-positive total');
68
+ }
69
+ let r = this.nextInt(0, total - 1);
70
+ for (let i = 0; i < items.length; i += 1) {
71
+ r -= weights[i];
72
+ if (r < 0) {
73
+ return items[i];
74
+ }
75
+ }
76
+ return items[items.length - 1];
77
+ }
78
+ gaussian(math) {
79
+ // Box-Muller: sqrt(-2 ln u1) * cos(2π u2), u1 in (0,1]. Fixed-point.
80
+ const u1 = math.max(this.nextFixed(math.fixedScale), 1);
81
+ const u2 = this.nextFixed(math.fixedScale);
82
+ const r = math.sqrt(math.mul(math.toFixed(2), math.abs(math.log(u1))));
83
+ return math.mul(r, math.cosTurns(u2)); // u2 already in [0,scale) turns
84
+ }
36
85
  snapshot() {
37
86
  return { state: this.state };
38
87
  }
@@ -1,6 +1,6 @@
1
1
  import { type CanonicalValue } from './canonical.js';
2
2
  import { type DeterministicFrameRuntime, type DeterministicFrameRuntimeDescriptor, type DeterministicSystem } from './engine-runtime.js';
3
- import type { RecordedChecksum, RecordedCommandFrame, RecordedDebugEvent, RecordedInputFrame } from './types.js';
3
+ import type { RecordedChecksum, RecordedCommandFrame, RecordedDebugEvent, RecordedInputFrame, ReplayFile } from './types.js';
4
4
  export interface DeterministicReplayBundle {
5
5
  readonly schemaVersion: 1;
6
6
  readonly runtimeVersion: string;
@@ -141,6 +141,45 @@ export declare function verifyDeterministicFrameRuntimeReplayBundle(bundle: Dete
141
141
  export declare function verifyDeterministicSelfContainedFrameRuntimeReplayBundle(bundle: DeterministicReplayBundle, options?: {
142
142
  readonly sparse?: boolean;
143
143
  }): DeterministicFrameRuntimeReplayBundleVerification;
144
+ /**
145
+ * The trusted rules artifact the score re-run needs, extracted from a certify
146
+ * replay bundle at deploy and stored server-side keyed by `deterministicVersion`.
147
+ * It carries the built-in runtime `descriptor` + `systemId` + `beforeFrameId`
148
+ * (never client-supplied) plus the identity fields the re-run gates on. The
149
+ * client's `ReplayFile.initialState` and authored-state checksums are NOT part of
150
+ * this manifest and are never trusted.
151
+ */
152
+ export interface DeterministicReplayVerificationManifest {
153
+ readonly deterministicVersion: string;
154
+ readonly inputSchemaId: string;
155
+ readonly stateSchemaId: string;
156
+ readonly descriptor: DeterministicFrameRuntimeDescriptor;
157
+ readonly systemId: DeterministicReplayRuntimeSystemId;
158
+ readonly beforeFrameId: DeterministicReplayRuntimeBeforeFrameId;
159
+ readonly seed: number;
160
+ readonly sparse?: boolean;
161
+ }
162
+ export type ReplayScoreVerificationReason = 'score-mismatch' | 'replay-invalid';
163
+ export interface ReplayScoreVerificationResult {
164
+ readonly ok: boolean;
165
+ readonly reason?: ReplayScoreVerificationReason;
166
+ readonly verifiedScore?: number;
167
+ }
168
+ /**
169
+ * Code-free score re-run (M8.5). Re-runs the TRUSTED built-in runtime (from the
170
+ * deploy-time manifest) over the CLIENT's recorded inputs and reads the score
171
+ * the runtime produces — no game `step`/`extractScore` and no vm sandbox. The
172
+ * runtime's initial state is seeded from the trusted descriptor; the client's
173
+ * `replay.initialState`/checksums are ignored. Rejects `'replay-invalid'` when
174
+ * the replay's identity fields don't match the manifest or the inputs cannot be
175
+ * re-run, `'score-mismatch'` on a clean re-run whose score differs from
176
+ * `submittedScore`. Fails closed: any thrown error becomes `'replay-invalid'`.
177
+ */
178
+ export declare function verifyReplayScoreAgainstTrustedRuntime(input: {
179
+ readonly manifest: DeterministicReplayVerificationManifest;
180
+ readonly replay: ReplayFile;
181
+ readonly submittedScore: number;
182
+ }): ReplayScoreVerificationResult;
144
183
  export declare function minimizeDeterministicReplayDesync(expected: DeterministicReplayBundle, actual: DeterministicReplayBundle): DeterministicReplayBundleMismatch | undefined;
145
184
  export declare function minimizeDeterministicReplayDesyncBundle(expected: DeterministicReplayBundle, actual: DeterministicReplayBundle): DeterministicReplayDesyncMinimization | undefined;
146
185
  export declare function createDeterministicReplayDebugTables(events: readonly RecordedDebugEvent[]): DeterministicReplayDebugTables;
@@ -386,6 +386,103 @@ export function verifyDeterministicSelfContainedFrameRuntimeReplayBundle(bundle,
386
386
  beforeFrame: deterministicReplayRuntimeBeforeFrame(manifest.beforeFrameId),
387
387
  });
388
388
  }
389
+ /**
390
+ * Code-free score re-run (M8.5). Re-runs the TRUSTED built-in runtime (from the
391
+ * deploy-time manifest) over the CLIENT's recorded inputs and reads the score
392
+ * the runtime produces — no game `step`/`extractScore` and no vm sandbox. The
393
+ * runtime's initial state is seeded from the trusted descriptor; the client's
394
+ * `replay.initialState`/checksums are ignored. Rejects `'replay-invalid'` when
395
+ * the replay's identity fields don't match the manifest or the inputs cannot be
396
+ * re-run, `'score-mismatch'` on a clean re-run whose score differs from
397
+ * `submittedScore`. Fails closed: any thrown error becomes `'replay-invalid'`.
398
+ */
399
+ export function verifyReplayScoreAgainstTrustedRuntime(input) {
400
+ const { manifest, replay, submittedScore } = input;
401
+ if (replay.deterministicVersion !== manifest.deterministicVersion ||
402
+ replay.inputSchemaId !== manifest.inputSchemaId ||
403
+ replay.stateSchemaId !== manifest.stateSchemaId) {
404
+ return { ok: false, reason: 'replay-invalid' };
405
+ }
406
+ let verifiedScore;
407
+ try {
408
+ verifiedScore = runTrustedReplayRuntimeScore(manifest, replay);
409
+ }
410
+ catch {
411
+ return { ok: false, reason: 'replay-invalid' };
412
+ }
413
+ if (verifiedScore === submittedScore) {
414
+ return { ok: true, verifiedScore };
415
+ }
416
+ return { ok: false, reason: 'score-mismatch', verifiedScore };
417
+ }
418
+ function runTrustedReplayRuntimeScore(manifest, replay) {
419
+ const runtime = manifest.sparse === true
420
+ ? createDeterministicSparseSetFrameRuntime(manifest.descriptor, manifest.seed)
421
+ : createDeterministicFrameRuntime(manifest.descriptor, manifest.seed);
422
+ seedTrustedReplayRuntimeState(runtime, manifest.descriptor, manifest.systemId);
423
+ const systems = deterministicReplayRuntimeSystems(manifest.systemId);
424
+ const beforeFrame = deterministicReplayRuntimeBeforeFrame(manifest.beforeFrameId);
425
+ const inputByFrame = new Map(replay.inputFrames.map((frame) => [frame.frame, frame.inputs]));
426
+ const commandByFrame = new Map((replay.commandHistory ?? []).map((frame) => [frame.frame, frame.commands]));
427
+ let maxFrame = -1;
428
+ for (const frame of inputByFrame.keys()) {
429
+ maxFrame = frame > maxFrame ? frame : maxFrame;
430
+ }
431
+ for (const frame of commandByFrame.keys()) {
432
+ maxFrame = frame > maxFrame ? frame : maxFrame;
433
+ }
434
+ for (let frame = 0; frame <= maxFrame; frame += 1) {
435
+ beforeFrame?.(runtime, frame, inputByFrame.get(frame) ?? [], commandByFrame.get(frame) ?? []);
436
+ runtime.step(systems);
437
+ }
438
+ return readTrustedReplayRuntimeScore(runtime, manifest.systemId);
439
+ }
440
+ function seedTrustedReplayRuntimeState(runtime, descriptor, systemId) {
441
+ if (systemId === 'project-game-state-runtime-replay-v1') {
442
+ runtime.setSingleton('gameState', trustedReplayRuntimeDefaultGameState(descriptor));
443
+ runtime.setSingleton('runtimeReplayState', { value: 0, inputDelta: 0, commandDelta: 0 });
444
+ return;
445
+ }
446
+ runtime.setSingleton('score', { value: 0, inputDelta: 0, commandDelta: 0 });
447
+ }
448
+ function readTrustedReplayRuntimeScore(runtime, systemId) {
449
+ const singletons = runtime.snapshot().singletonComponents;
450
+ if (systemId === 'project-game-state-runtime-replay-v1') {
451
+ const gameState = singletons.gameState;
452
+ if (gameState === undefined || typeof gameState.score !== 'number') {
453
+ throw new Error('Trusted replay runtime produced no gameState.score');
454
+ }
455
+ return gameState.score;
456
+ }
457
+ const score = singletons.score;
458
+ /* istanbul ignore next -- defensive: the trusted score runtime always keeps a numeric score singleton. */
459
+ if (score === undefined || typeof score.value !== 'number') {
460
+ throw new Error('Trusted replay runtime produced no score.value');
461
+ }
462
+ return score.value;
463
+ }
464
+ function trustedReplayRuntimeDefaultGameState(descriptor) {
465
+ const gameState = descriptor.components.find((component) => component.id === 'gameState' && component.singleton === true);
466
+ if (gameState === undefined) {
467
+ return {};
468
+ }
469
+ return Object.fromEntries(gameState.fields.map((field) => [
470
+ field.name,
471
+ field.defaultValue ?? trustedReplayRuntimeDefaultValueForKind(field.kind),
472
+ ]));
473
+ }
474
+ function trustedReplayRuntimeDefaultValueForKind(kind) {
475
+ if (kind.endsWith('[]')) {
476
+ return [];
477
+ }
478
+ if (kind === 'bool') {
479
+ return false;
480
+ }
481
+ if (kind === 'string' || kind === 'asset-ref' || kind === 'entity-ref') {
482
+ return '';
483
+ }
484
+ return 0;
485
+ }
389
486
  function findDuplicateFrameRecord(bundle) {
390
487
  return findDuplicateFrame(bundle.inputHistory.map((frame) => frame.frame), 'inputHistory')
391
488
  ?? findDuplicateFrame(bundle.commandHistory.map((frame) => frame.frame), 'commandHistory')
@@ -1237,23 +1334,13 @@ function deterministicReplayRuntimeBeforeFrame(beforeFrameId) {
1237
1334
  return injectProjectGameStateRuntimeReplayFrame;
1238
1335
  }
1239
1336
  }
1240
- function injectScoreRuntimeReplayFrame(runtime, _frame, inputs, commands) {
1241
- const inputDelta = inputs.reduce((sum, input) => isReplayRecord(input) && typeof input.dx === 'number' ? sum + input.dx : sum, 0);
1242
- const commandDelta = commands.reduce((sum, command) => {
1243
- if (!isReplayRecord(command) || !isReplayRecord(command.payload) || typeof command.payload.power !== 'number') {
1244
- return sum;
1245
- }
1246
- return sum + command.payload.power;
1247
- }, 0);
1248
- const score = runtime.snapshot().singletonComponents.score ?? { value: 0 };
1249
- runtime.setSingleton('score', {
1250
- value: Number(score.value ?? 0),
1251
- inputDelta,
1252
- commandDelta,
1253
- });
1254
- }
1255
- function injectProjectGameStateRuntimeReplayFrame(runtime, frame, inputs, commands) {
1256
- const inputDelta = inputs.reduce((sum, input) => {
1337
+ // The built-in-runtime input/command delta rules. These MUST stay identical to
1338
+ // the copies certify runs with (cli.ts `projectReplayInputDelta` /
1339
+ // `projectReplayCommandDelta`) the trusted server re-run reads the score those
1340
+ // rules produce, so any divergence would falsely reject an honest replay whose
1341
+ // inputs use `moveX`/`points` (or commands use `slot`) instead of `dx`/`power`.
1342
+ function replayRuntimeInputDelta(inputs) {
1343
+ return inputs.reduce((sum, input) => {
1257
1344
  if (!isReplayRecord(input)) {
1258
1345
  return sum;
1259
1346
  }
@@ -1263,9 +1350,14 @@ function injectProjectGameStateRuntimeReplayFrame(runtime, frame, inputs, comman
1263
1350
  if (typeof input.moveX === 'number') {
1264
1351
  return sum + input.moveX;
1265
1352
  }
1353
+ if (typeof input.points === 'number') {
1354
+ return sum + input.points;
1355
+ }
1266
1356
  return sum;
1267
1357
  }, 0);
1268
- const commandDelta = commands.reduce((sum, command) => {
1358
+ }
1359
+ function replayRuntimeCommandDelta(commands) {
1360
+ return commands.reduce((sum, command) => {
1269
1361
  if (!isReplayRecord(command) || !isReplayRecord(command.payload)) {
1270
1362
  return sum;
1271
1363
  }
@@ -1277,6 +1369,20 @@ function injectProjectGameStateRuntimeReplayFrame(runtime, frame, inputs, comman
1277
1369
  }
1278
1370
  return sum;
1279
1371
  }, 0);
1372
+ }
1373
+ function injectScoreRuntimeReplayFrame(runtime, _frame, inputs, commands) {
1374
+ const inputDelta = replayRuntimeInputDelta(inputs);
1375
+ const commandDelta = replayRuntimeCommandDelta(commands);
1376
+ const score = runtime.snapshot().singletonComponents.score ?? { value: 0 };
1377
+ runtime.setSingleton('score', {
1378
+ value: Number(score.value ?? 0),
1379
+ inputDelta,
1380
+ commandDelta,
1381
+ });
1382
+ }
1383
+ function injectProjectGameStateRuntimeReplayFrame(runtime, frame, inputs, commands) {
1384
+ const inputDelta = replayRuntimeInputDelta(inputs);
1385
+ const commandDelta = replayRuntimeCommandDelta(commands);
1280
1386
  const state = runtime.snapshot().singletonComponents.runtimeReplayState ?? { value: 0 };
1281
1387
  runtime.setSingleton('runtimeReplayState', {
1282
1388
  value: Number(state.value ?? 0),
@@ -1454,8 +1560,8 @@ export const __replayBundleCoverageProof = {
1454
1560
  builtInWrites.push(value);
1455
1561
  },
1456
1562
  };
1457
- deterministicReplayRuntimeBeforeFrame('score-input-command-delta-v1')?.(fakeRuntime, 0, [{}], [{}]);
1458
- deterministicReplayRuntimeBeforeFrame('project-game-state-input-command-delta-v1')?.(fakeRuntime, 0, [{ moveX: 2 }, {}], [{ payload: { slot: 1 } }, {}]);
1563
+ deterministicReplayRuntimeBeforeFrame('score-input-command-delta-v1')?.(fakeRuntime, 0, [{ dx: 1 }, 5, { points: 2 }], [{ payload: { power: 3 } }, 7]);
1564
+ deterministicReplayRuntimeBeforeFrame('project-game-state-input-command-delta-v1')?.(fakeRuntime, 0, [{ moveX: 2 }, {}], [{ payload: { slot: 1 } }, { payload: {} }]);
1459
1565
  const semanticFallbackBaseline = canonicalStringify({ frame: -1, positions: ['bad'], commandScore: 0 });
1460
1566
  const semanticFallbackFrame = canonicalStringify({ frame: 0, positions: [2], commandScore: 0 });
1461
1567
  const semanticFallback = verifySemanticReplay(createDeterministicReplayBundle({
@@ -658,6 +658,9 @@ function addSceneMetrics(sampleId, frame, seed, metrics) {
658
658
  add('teamScores', frame % 19 === 0);
659
659
  add('priorityResolutions', true);
660
660
  return;
661
+ // Intentionally kinematic (counter-based mechanics, not simulated). The
662
+ // physics-driven raycast-wheel vehicle lives in physics3d-vehicle.ts and is
663
+ // certified by the physics-rigid-vehicle fixture + builtin-rigid arena car.
661
664
  case 'vehicle':
662
665
  add('steeringFrames', true);
663
666
  add('accelerationFrames', frame % 2 === 0);
@@ -1,43 +1,17 @@
1
- import { createOfflineSession } from './offline-session.js';
2
1
  import { createSchemaArtifacts } from './schema-artifacts.js';
2
+ import { buildDeterministicSession } from './session-build.js';
3
3
  export function createSyncplayOfflineRuntime(config) {
4
+ // Kept for getFrame()'s checksum projection; session construction is delegated
5
+ // to the shared builder so every consumer wires schema identity identically.
4
6
  const artifacts = createSchemaArtifacts(config.descriptor);
5
7
  const listeners = new Set();
6
8
  const playerCount = config.playerCount ?? config.descriptor.maxPlayers;
7
- if (playerCount > config.descriptor.maxPlayers) {
8
- throw new Error(`Offline runtime playerCount ${playerCount} exceeds descriptor maxPlayers ${config.descriptor.maxPlayers}`);
9
- }
10
- const session = createOfflineSession({
11
- tickRate: config.descriptor.tickRate,
9
+ const session = buildDeterministicSession({
10
+ descriptor: config.descriptor,
12
11
  playerCount,
13
12
  initialState: config.initialState,
14
- step: config.step,
15
13
  defaultInput: config.defaultInput,
16
- // Use the content-hashed identity ids, not the author labels — replay
17
- // compatibility must track the actual field shapes.
18
- inputSchemaId: artifacts.descriptor.replayIdentity.inputSchemaId,
19
- stateSchemaId: artifacts.descriptor.replayIdentity.stateSchemaId,
20
- deterministicVersion: config.descriptor.deterministicVersion,
21
- canonicalizeInput(input) {
22
- return artifacts.canonicalizeInput(input);
23
- },
24
- // FULL declared state for replay initial-state and snapshots: the session
25
- // RESTORES from these (rollback, late-join hydration), so they must carry
26
- // every declared field. The checksum projection stays checksum-only below —
27
- // wiring it here wiped every checksummed:false field to undefined on the
28
- // first rollback, silently diverging from peers that never rolled back.
29
- canonicalizeState(state) {
30
- return artifacts.canonicalizeFullState(state);
31
- },
32
- serializeState(state) {
33
- return artifacts.serializeFullState(state);
34
- },
35
- deserializeState(bytes) {
36
- return artifacts.deserializeFullState(bytes);
37
- },
38
- checksum(state) {
39
- return artifacts.checksumState(state);
40
- },
14
+ step: config.step,
41
15
  randomSeed: config.randomSeed,
42
16
  snapshotBufferSize: config.snapshotBufferSize,
43
17
  checksumIntervalFrames: config.checksumIntervalFrames,
@@ -0,0 +1,47 @@
1
+ import { type NetworkedClientTransport } from './networked-client.js';
2
+ import type { DeterministicGameDescriptor } from './schema.js';
3
+ import type { EncodedInput } from './input-authority.js';
4
+ import type { DeterministicStep, ReplayFile } from './types.js';
5
+ export type SyncplaySessionMode = 'offline' | 'networked';
6
+ export interface SyncplaySessionConfig<State, Input> {
7
+ readonly descriptor: DeterministicGameDescriptor;
8
+ readonly initialState: State;
9
+ readonly defaultInput: Input;
10
+ readonly step: DeterministicStep<State, Input>;
11
+ /** Encode a game input to the wire form the authority relays. */
12
+ readonly encodeInput: (input: Input) => EncodedInput;
13
+ /** Decode a wire input; MUST map the neutral substitute (canonical null) to your default. */
14
+ readonly decodeInput: (encoded: EncodedInput) => Input;
15
+ /** Slots while offline (defaults to descriptor.maxPlayers). Networked player count comes from the server. */
16
+ readonly playerCount?: number;
17
+ /** Offline-only seed. Networked mode uses the server-supplied seed. */
18
+ readonly randomSeed?: number;
19
+ readonly maxPredictionTicks?: number;
20
+ }
21
+ export interface SyncplaySession<State, Input> {
22
+ readonly mode: SyncplaySessionMode;
23
+ /** Own slot: 0 while offline; server-assigned slot once networked. */
24
+ readonly slot: number;
25
+ /** The room code once networked (undefined while offline). */
26
+ readonly roomCode: string | undefined;
27
+ getState(): State;
28
+ setLocalInput(input: Input): void;
29
+ /** Advance by wall-clock delta: steps the offline sim, or paced-pumps the networked client. */
30
+ advance(deltaMs: number): void;
31
+ subscribe(listener: (state: State) => void): () => void;
32
+ /**
33
+ * Transition offline → networked against a live room. RESETS the simulation to
34
+ * the room's server-supplied seed at tick 0 (in-progress solo state is not
35
+ * carried over — see SYNCPLAY.md). Resolves once the server sends session-start.
36
+ */
37
+ openRoom(transport: OpenRoomTransport): Promise<void>;
38
+ exportReplay(): ReplayFile;
39
+ dispose(): void;
40
+ }
41
+ /** A room transport plus its shareable code (SyncplayRoomTransport satisfies this). */
42
+ export interface OpenRoomTransport extends NetworkedClientTransport {
43
+ readonly roomCode?: string;
44
+ /** Leave the room / close the socket. Called on dispose so a promoted session frees its seat. */
45
+ close?(): void;
46
+ }
47
+ export declare function createSyncplaySession<State, Input>(config: SyncplaySessionConfig<State, Input>): SyncplaySession<State, Input>;
@@ -0,0 +1,153 @@
1
+ import { buildDeterministicSession } from './session-build.js';
2
+ import { createNetworkedSyncplayClient, } from './networked-client.js';
3
+ export function createSyncplaySession(config) {
4
+ const offlinePlayerCount = config.playerCount ?? config.descriptor.maxPlayers;
5
+ const listeners = new Set();
6
+ let disposed = false;
7
+ let mode = 'offline';
8
+ let session = buildDeterministicSession({
9
+ descriptor: config.descriptor,
10
+ playerCount: offlinePlayerCount,
11
+ initialState: config.initialState,
12
+ defaultInput: config.defaultInput,
13
+ step: config.step,
14
+ randomSeed: config.randomSeed,
15
+ });
16
+ let localInput = config.defaultInput;
17
+ let slot = 0;
18
+ let roomCode;
19
+ let client;
20
+ let networkTransport;
21
+ let opening = false;
22
+ let now = 0;
23
+ // Resolves the moment dispose() runs, so an in-flight openRoom stops awaiting
24
+ // session-start instead of hanging forever when the room never greets it.
25
+ let signalDisposed;
26
+ const disposedSignal = new Promise((resolve) => {
27
+ signalDisposed = resolve;
28
+ });
29
+ const assertLive = () => {
30
+ if (disposed)
31
+ throw new Error('SyncplaySession is disposed');
32
+ };
33
+ const notify = () => {
34
+ const state = session.getState();
35
+ for (const l of listeners)
36
+ l(state);
37
+ };
38
+ const api = {
39
+ get mode() {
40
+ return mode;
41
+ },
42
+ get slot() {
43
+ return slot;
44
+ },
45
+ get roomCode() {
46
+ return roomCode;
47
+ },
48
+ getState() {
49
+ assertLive();
50
+ return session.getState();
51
+ },
52
+ setLocalInput(input) {
53
+ assertLive();
54
+ localInput = input;
55
+ },
56
+ advance(deltaMs) {
57
+ assertLive();
58
+ if (mode === 'offline') {
59
+ session.setInput(slot, localInput);
60
+ if (session.update(deltaMs) > 0)
61
+ notify();
62
+ }
63
+ else {
64
+ now += deltaMs;
65
+ client.pumpPaced(now);
66
+ notify();
67
+ }
68
+ },
69
+ subscribe(listener) {
70
+ listeners.add(listener);
71
+ listener(session.getState());
72
+ return () => {
73
+ listeners.delete(listener);
74
+ };
75
+ },
76
+ async openRoom(transport) {
77
+ assertLive();
78
+ // `opening` closes the window a concurrent call could slip through before
79
+ // `mode` flips (it only flips after the await): a session promotes once.
80
+ if (mode !== 'offline' || opening) {
81
+ throw new Error('SyncplaySession.openRoom already called — a session promotes to a room once');
82
+ }
83
+ opening = true;
84
+ // Publish the in-flight transport immediately so dispose() can tear it
85
+ // down even before session-start arrives.
86
+ networkTransport = transport;
87
+ let promoted = false;
88
+ try {
89
+ // Client construction is inside the try: it wires the transport and can
90
+ // decode a synchronously-delivered session-start, so a throw here must
91
+ // still clear `opening` and free the transport (else the session wedges
92
+ // — every later openRoom would hit the guard).
93
+ const built = createNetworkedSyncplayClient({
94
+ transport,
95
+ createSession: ({ seed, playerCount }) => {
96
+ session = buildDeterministicSession({
97
+ descriptor: config.descriptor,
98
+ playerCount,
99
+ initialState: config.initialState,
100
+ step: config.step,
101
+ defaultInput: config.defaultInput,
102
+ randomSeed: seed,
103
+ });
104
+ return session;
105
+ },
106
+ localInputForTick: () => localInput,
107
+ encodeInput: config.encodeInput,
108
+ decodeInput: config.decodeInput,
109
+ maxPredictionTicks: config.maxPredictionTicks,
110
+ });
111
+ // Lose the race to dispose() rather than hang if the room never greets.
112
+ await Promise.race([built.whenReady(), disposedSignal]);
113
+ // Disposed while awaiting session-start: dispose() already closed the
114
+ // transport — don't switch a dead object into networked mode.
115
+ if (disposed) {
116
+ return;
117
+ }
118
+ client = built;
119
+ slot = built.slot;
120
+ roomCode = transport.roomCode;
121
+ mode = 'networked';
122
+ promoted = true;
123
+ }
124
+ catch (error) {
125
+ // Open failed BEFORE promotion: free the seat and reset so the caller
126
+ // can retry. Once promoted, the catch is unreachable — see notify below.
127
+ transport.close?.();
128
+ networkTransport = undefined;
129
+ throw error;
130
+ }
131
+ finally {
132
+ opening = false;
133
+ }
134
+ // Notify OUTSIDE the try: a throwing listener must not tear down an
135
+ // already-promoted session (it's networked; the join succeeded).
136
+ if (promoted)
137
+ notify();
138
+ },
139
+ exportReplay() {
140
+ assertLive();
141
+ return (client ?? session).exportReplay();
142
+ },
143
+ dispose() {
144
+ disposed = true;
145
+ listeners.clear();
146
+ // Closes both a promoted transport and one still awaiting session-start.
147
+ networkTransport?.close?.();
148
+ networkTransport = undefined;
149
+ signalDisposed?.();
150
+ },
151
+ };
152
+ return api;
153
+ }
@@ -0,0 +1,21 @@
1
+ import type { DeterministicGameDescriptor } from './schema.js';
2
+ import type { DeterministicStep, OfflineSession, ReplayRecordingMode } from './types.js';
3
+ export interface BuildDeterministicSessionConfig<State, Input> {
4
+ readonly descriptor: DeterministicGameDescriptor;
5
+ readonly playerCount: number;
6
+ readonly initialState: State;
7
+ readonly defaultInput: Input;
8
+ readonly step: DeterministicStep<State, Input>;
9
+ readonly randomSeed?: number;
10
+ readonly snapshotBufferSize?: number;
11
+ readonly checksumIntervalFrames?: number;
12
+ readonly maxCatchUpFrames?: number;
13
+ readonly replayRecordingMode?: ReplayRecordingMode;
14
+ readonly runtimeGuards?: boolean;
15
+ }
16
+ /**
17
+ * Construct an OfflineSession from a validated descriptor, wiring the
18
+ * content-hashed schema identity and full-state (de)serialization the same way
19
+ * for every consumer (offline runtime, unified session, networked createSession).
20
+ */
21
+ export declare function buildDeterministicSession<State, Input>(config: BuildDeterministicSessionConfig<State, Input>): OfflineSession<State, Input>;
@@ -0,0 +1,39 @@
1
+ import { createOfflineSession } from './offline-session.js';
2
+ import { createSchemaArtifacts } from './schema-artifacts.js';
3
+ /**
4
+ * Construct an OfflineSession from a validated descriptor, wiring the
5
+ * content-hashed schema identity and full-state (de)serialization the same way
6
+ * for every consumer (offline runtime, unified session, networked createSession).
7
+ */
8
+ export function buildDeterministicSession(config) {
9
+ const artifacts = createSchemaArtifacts(config.descriptor);
10
+ if (config.playerCount > config.descriptor.maxPlayers) {
11
+ throw new Error(`Session playerCount ${config.playerCount} exceeds descriptor maxPlayers ${config.descriptor.maxPlayers}`);
12
+ }
13
+ return createOfflineSession({
14
+ tickRate: config.descriptor.tickRate,
15
+ playerCount: config.playerCount,
16
+ initialState: config.initialState,
17
+ step: config.step,
18
+ defaultInput: config.defaultInput,
19
+ // Use the content-hashed identity ids, not the author labels — replay
20
+ // compatibility must track the actual field shapes.
21
+ inputSchemaId: artifacts.descriptor.replayIdentity.inputSchemaId,
22
+ stateSchemaId: artifacts.descriptor.replayIdentity.stateSchemaId,
23
+ deterministicVersion: config.descriptor.deterministicVersion,
24
+ canonicalizeInput: (input) => artifacts.canonicalizeInput(input),
25
+ // FULL declared state for replay initial-state and snapshots (rollback,
26
+ // late-join hydration restore from these): they must carry every declared
27
+ // field, not just the checksummed projection.
28
+ canonicalizeState: (state) => artifacts.canonicalizeFullState(state),
29
+ serializeState: (state) => artifacts.serializeFullState(state),
30
+ deserializeState: (bytes) => artifacts.deserializeFullState(bytes),
31
+ checksum: (state) => artifacts.checksumState(state),
32
+ randomSeed: config.randomSeed,
33
+ snapshotBufferSize: config.snapshotBufferSize,
34
+ checksumIntervalFrames: config.checksumIntervalFrames,
35
+ maxCatchUpFrames: config.maxCatchUpFrames,
36
+ replayRecordingMode: config.replayRecordingMode,
37
+ runtimeGuards: config.runtimeGuards,
38
+ });
39
+ }