@series-inc/rundot-syncplay 5.23.0 → 5.24.0-beta.1

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 (131) hide show
  1. package/README.md +53 -12
  2. package/dist/authority-room.d.ts +11 -0
  3. package/dist/authority-room.js +43 -0
  4. package/dist/browser.d.ts +11 -1
  5. package/dist/browser.js +5 -0
  6. package/dist/checksum-basis.d.ts +4 -0
  7. package/dist/checksum-basis.js +17 -0
  8. package/dist/cjs/authority-room.js +43 -0
  9. package/dist/cjs/browser.js +18 -2
  10. package/dist/cjs/checksum-basis.js +22 -0
  11. package/dist/cjs/collider-cooking.js +834 -0
  12. package/dist/cjs/deterministic-session.js +2 -0
  13. package/dist/cjs/engine-complete.js +1 -4
  14. package/dist/cjs/engine-parity-matrix.js +41 -9
  15. package/dist/cjs/engine-parity.js +2 -2
  16. package/dist/cjs/index.js +24 -2
  17. package/dist/cjs/input-authority.js +34 -2
  18. package/dist/cjs/instant-replay.js +4 -9
  19. package/dist/cjs/local-authority-server-runner.js +3 -1
  20. package/dist/cjs/local-authority-wire.js +20 -1
  21. package/dist/cjs/match-log.js +27 -0
  22. package/dist/cjs/movement3d.js +17 -1
  23. package/dist/cjs/networked-client.js +70 -20
  24. package/dist/cjs/node.js +23 -1
  25. package/dist/cjs/offline-session.js +52 -24
  26. package/dist/cjs/physics-cert.js +8 -3
  27. package/dist/cjs/physics3d-heightfield.js +51 -0
  28. package/dist/cjs/physics3d-hull.js +381 -0
  29. package/dist/cjs/physics3d-joints.js +255 -49
  30. package/dist/cjs/physics3d-mass.js +131 -0
  31. package/dist/cjs/physics3d-recipes.js +197 -0
  32. package/dist/cjs/physics3d-shared.js +11 -1
  33. package/dist/cjs/physics3d-solver.js +845 -98
  34. package/dist/cjs/physics3d-stacking.js +154 -0
  35. package/dist/cjs/physics3d-toi.js +274 -0
  36. package/dist/cjs/physics3d-vehicle.js +35 -10
  37. package/dist/cjs/physics3d.js +601 -38
  38. package/dist/cjs/replay.js +25 -1
  39. package/dist/cjs/runtime-support.js +71 -0
  40. package/dist/cjs/sdk-offline.js +9 -1
  41. package/dist/cjs/sdk-session.js +8 -0
  42. package/dist/cjs/secret-authority.js +647 -0
  43. package/dist/cjs/secret-client.js +615 -0
  44. package/dist/cjs/secret-command-classification.js +8 -0
  45. package/dist/cjs/secret-config.js +211 -0
  46. package/dist/cjs/secret-crypto.js +208 -0
  47. package/dist/cjs/secret-protocol.js +238 -0
  48. package/dist/cjs/session-build.js +7 -4
  49. package/dist/cjs/session-promotion.js +5 -5
  50. package/dist/cjs/synctest.js +49 -0
  51. package/dist/cjs/testing.js +4 -1
  52. package/dist/cjs/tools/static-checker.js +61 -13
  53. package/dist/cli.js +193 -48
  54. package/dist/collider-cooking.d.ts +141 -0
  55. package/dist/collider-cooking.js +829 -0
  56. package/dist/deterministic-primitives.d.ts +1 -0
  57. package/dist/deterministic-session.js +2 -0
  58. package/dist/engine-complete.js +1 -4
  59. package/dist/engine-parity-matrix.js +41 -9
  60. package/dist/engine-parity.js +2 -2
  61. package/dist/index.d.ts +12 -1
  62. package/dist/index.js +6 -0
  63. package/dist/input-authority.d.ts +3 -0
  64. package/dist/input-authority.js +35 -3
  65. package/dist/instant-replay.d.ts +2 -2
  66. package/dist/instant-replay.js +4 -9
  67. package/dist/local-authority-server-runner.d.ts +1 -0
  68. package/dist/local-authority-server-runner.js +2 -1
  69. package/dist/local-authority-wire.js +20 -1
  70. package/dist/match-log.d.ts +4 -1
  71. package/dist/match-log.js +27 -0
  72. package/dist/movement3d.js +18 -2
  73. package/dist/networked-client.d.ts +8 -0
  74. package/dist/networked-client.js +70 -20
  75. package/dist/node.d.ts +7 -0
  76. package/dist/node.js +6 -0
  77. package/dist/offline-session.js +53 -25
  78. package/dist/physics-cert.js +8 -3
  79. package/dist/physics3d-heightfield.d.ts +17 -0
  80. package/dist/physics3d-heightfield.js +47 -0
  81. package/dist/physics3d-hull.d.ts +53 -0
  82. package/dist/physics3d-hull.js +376 -0
  83. package/dist/physics3d-joints.d.ts +13 -1
  84. package/dist/physics3d-joints.js +256 -50
  85. package/dist/physics3d-mass.d.ts +11 -0
  86. package/dist/physics3d-mass.js +126 -0
  87. package/dist/physics3d-recipes.d.ts +29 -0
  88. package/dist/physics3d-recipes.js +193 -0
  89. package/dist/physics3d-shared.d.ts +1 -0
  90. package/dist/physics3d-shared.js +10 -1
  91. package/dist/physics3d-solver.d.ts +78 -2
  92. package/dist/physics3d-solver.js +842 -98
  93. package/dist/physics3d-stacking.d.ts +26 -0
  94. package/dist/physics3d-stacking.js +147 -0
  95. package/dist/physics3d-toi.d.ts +79 -0
  96. package/dist/physics3d-toi.js +267 -0
  97. package/dist/physics3d-vehicle.js +35 -10
  98. package/dist/physics3d.d.ts +28 -3
  99. package/dist/physics3d.js +601 -40
  100. package/dist/replay.d.ts +5 -0
  101. package/dist/replay.js +25 -1
  102. package/dist/reports.d.ts +6 -1
  103. package/dist/runtime-support.d.ts +60 -0
  104. package/dist/runtime-support.js +66 -0
  105. package/dist/sdk-offline.d.ts +4 -0
  106. package/dist/sdk-offline.js +9 -1
  107. package/dist/sdk-session.d.ts +4 -0
  108. package/dist/sdk-session.js +8 -0
  109. package/dist/secret-authority.d.ts +90 -0
  110. package/dist/secret-authority.js +643 -0
  111. package/dist/secret-client.d.ts +89 -0
  112. package/dist/secret-client.js +611 -0
  113. package/dist/secret-command-classification.d.ts +1 -0
  114. package/dist/secret-command-classification.js +5 -0
  115. package/dist/secret-config.d.ts +45 -0
  116. package/dist/secret-config.js +206 -0
  117. package/dist/secret-crypto.d.ts +29 -0
  118. package/dist/secret-crypto.js +190 -0
  119. package/dist/secret-protocol.d.ts +181 -0
  120. package/dist/secret-protocol.js +230 -0
  121. package/dist/session-build.d.ts +16 -0
  122. package/dist/session-build.js +7 -4
  123. package/dist/session-promotion.js +5 -5
  124. package/dist/synctest.d.ts +1 -1
  125. package/dist/synctest.js +49 -0
  126. package/dist/testing.d.ts +2 -0
  127. package/dist/testing.js +1 -0
  128. package/dist/tools/static-checker.d.ts +11 -0
  129. package/dist/tools/static-checker.js +60 -13
  130. package/dist/types.d.ts +21 -2
  131. package/package.json +8 -3
@@ -0,0 +1,230 @@
1
+ import { canonicalStringify, canonicalize, encodeUtf8 } from './canonical.js';
2
+ export const syncplaySecretProtocol = 'rdm-secret/v1';
3
+ export const syncplaySecretRequestMaxBytes = 65_536;
4
+ export const syncplaySecretPrivateMessageMaxBytes = 262_144;
5
+ export function isSyncplaySecretCommand(value) {
6
+ if (!isRecord(value) || !isSecretCommandBase(value))
7
+ return false;
8
+ const base = ['kind', 'systemId', 'requestId', 'authorityId', 'receipt'];
9
+ if (value.kind === 'syncplay.choice.committed' || value.kind === 'syncplay.choice.revealed') {
10
+ return isExactRecord(value, [...base, 'roundId', 'slot'])
11
+ && isIdentifier(value.roundId, 128) && isNonNegativeInteger(value.slot);
12
+ }
13
+ if (value.kind === 'syncplay.choice.resolved') {
14
+ return isExactRecord(value, [...base, 'roundId', 'reveals', 'missingSlots', 'missingReveal'])
15
+ && isIdentifier(value.roundId, 128) && isRecord(value.reveals) && isCanonical(value.reveals)
16
+ && Array.isArray(value.missingSlots) && value.missingSlots.every(isNonNegativeInteger)
17
+ && isOneOf(value.missingReveal, ['omit', 'forfeit']);
18
+ }
19
+ if (value.kind === 'syncplay.random.committed') {
20
+ return isExactRecord(value, [...base, 'link', 'draws', 'min', 'max'])
21
+ && isHex(value.link, 64) && isNonNegativeInteger(value.draws)
22
+ && Number.isSafeInteger(value.min) && Number.isSafeInteger(value.max);
23
+ }
24
+ if (value.kind === 'syncplay.random.drawn') {
25
+ return isExactRecord(value, [...base, 'drawIndex', 'previousLink', 'preimage', 'value'])
26
+ && isNonNegativeInteger(value.drawIndex) && isHex(value.previousLink, 64) && isHex(value.preimage, 64)
27
+ && Number.isSafeInteger(value.value);
28
+ }
29
+ if (value.kind === 'syncplay.collection.committed') {
30
+ return isExactRecord(value, [...base, 'collectionKind', 'root', 'count'])
31
+ && isOneOf(value.collectionKind, ['deck', 'bag']) && isHex(value.root, 64)
32
+ && isNonNegativeInteger(value.count);
33
+ }
34
+ if (value.kind === 'syncplay.collection.drawn') {
35
+ return isExactRecord(value, [...base, 'collectionKind', 'ownerSlot', 'tokenIds', 'count', 'remaining'])
36
+ && isOneOf(value.collectionKind, ['deck', 'bag']) && isNonNegativeInteger(value.ownerSlot)
37
+ && Array.isArray(value.tokenIds) && value.tokenIds.every((tokenId) => isIdentifier(tokenId, 128))
38
+ && isNonNegativeInteger(value.count) && isNonNegativeInteger(value.remaining);
39
+ }
40
+ if (value.kind === 'syncplay.collection.revealed') {
41
+ return isExactRecord(value, [...base, 'collectionKind', 'action', 'ownerSlot', 'tokenId', 'value', 'leafNonce', 'leafIndex', 'proof', 'root'])
42
+ && isOneOf(value.collectionKind, ['deck', 'bag']) && isOneOf(value.action, ['reveal', 'play'])
43
+ && isNonNegativeInteger(value.ownerSlot) && isIdentifier(value.tokenId, 128) && isCanonical(value.value)
44
+ && isHex(value.leafNonce, 64) && isNonNegativeInteger(value.leafIndex)
45
+ && Array.isArray(value.proof) && value.proof.every((entry) => typeof entry === 'string' && /^[lr]:[0-9a-f]{64}$/.test(entry))
46
+ && isHex(value.root, 64);
47
+ }
48
+ if (value.kind === 'syncplay.role.assigned') {
49
+ return isExactRecord(value, [...base, 'slot', 'tokenId'])
50
+ && isNonNegativeInteger(value.slot) && isIdentifier(value.tokenId, 128);
51
+ }
52
+ if (value.kind === 'syncplay.role.revealed') {
53
+ return isExactRecord(value, [...base, 'slot', 'tokenId', 'value'])
54
+ && isNonNegativeInteger(value.slot) && isIdentifier(value.tokenId, 128) && isCanonical(value.value);
55
+ }
56
+ return false;
57
+ }
58
+ function isSecretCommandBase(value) {
59
+ if (!isIdentifier(value.systemId, 64) || !isIdentifier(value.requestId, 128)
60
+ || !isIdentifier(value.authorityId, 256) || !isSignedReceipt(value.receipt))
61
+ return false;
62
+ const receipt = value.receipt;
63
+ return receipt.body.systemId === value.systemId
64
+ && receipt.body.requestId === value.requestId
65
+ && receipt.body.authorityId === value.authorityId;
66
+ }
67
+ export function encodeSyncplaySecretRequest(input) {
68
+ const request = { protocol: syncplaySecretProtocol, ...input, payload: canonicalize(input.payload) };
69
+ if (!isSecretRequest(request))
70
+ throw new Error('Syncplay secret request is malformed');
71
+ return encodeWithinBudget(request, syncplaySecretRequestMaxBytes, 'request');
72
+ }
73
+ export function decodeSyncplaySecretRequest(encoded) {
74
+ return decodeWithinBudget(encoded, syncplaySecretRequestMaxBytes, isSecretRequest);
75
+ }
76
+ export function encodeSyncplaySecretPrivateMessage(input) {
77
+ const message = { protocol: syncplaySecretProtocol, ...input };
78
+ if (!isSecretPrivateMessage(message))
79
+ throw new Error('Syncplay secret private message is malformed');
80
+ return encodeWithinBudget(message, syncplaySecretPrivateMessageMaxBytes, 'private response');
81
+ }
82
+ export function decodeSyncplaySecretPrivateMessage(encoded) {
83
+ return decodeWithinBudget(encoded, syncplaySecretPrivateMessageMaxBytes, isSecretPrivateMessage);
84
+ }
85
+ function encodeWithinBudget(value, maxBytes, label) {
86
+ const encoded = canonicalStringify(value);
87
+ if (encodeUtf8(encoded).byteLength > maxBytes) {
88
+ throw new Error(`Syncplay secret ${label} exceeds byte budget`);
89
+ }
90
+ return encoded;
91
+ }
92
+ function decodeWithinBudget(encoded, maxBytes, validate) {
93
+ if (typeof encoded !== 'string')
94
+ return { ok: false, error: 'malformed-envelope' };
95
+ if (encodeUtf8(encoded).byteLength > maxBytes)
96
+ return { ok: false, error: 'oversized-payload' };
97
+ try {
98
+ const parsed = JSON.parse(encoded);
99
+ if (!validate(parsed))
100
+ return { ok: false, error: 'malformed-envelope' };
101
+ return { ok: true, value: parsed };
102
+ }
103
+ catch {
104
+ return { ok: false, error: 'malformed-envelope' };
105
+ }
106
+ }
107
+ function isSecretRequest(value) {
108
+ if (!isExactRecord(value, ['protocol', 'kind', 'requestId', 'requestSequence', 'systemId', 'payload']))
109
+ return false;
110
+ return value.protocol === syncplaySecretProtocol
111
+ && isOneOf(value.kind, ['choice-commit', 'choice-reveal', 'random-draw', 'collection-draw', 'collection-reveal', 'collection-play', 'role-reveal'])
112
+ && isIdentifier(value.requestId, 128)
113
+ && Number.isSafeInteger(value.requestSequence)
114
+ && value.requestSequence >= 0
115
+ && isIdentifier(value.systemId, 64)
116
+ && isCanonical(value.payload);
117
+ }
118
+ function isSecretPrivateMessage(value) {
119
+ if (!isRecord(value) || value.protocol !== syncplaySecretProtocol || typeof value.kind !== 'string')
120
+ return false;
121
+ if (value.kind === 'secret-error') {
122
+ return isExactRecord(value, ['protocol', 'kind', 'requestId', 'error'])
123
+ && isIdentifier(value.requestId, 128)
124
+ && isOneOf(value.error, secretErrorCodes);
125
+ }
126
+ if (value.kind === 'secret-receipt') {
127
+ if (!hasOnlyKeys(value, ['protocol', 'kind', 'requestId', 'receipt', 'result']))
128
+ return false;
129
+ return isIdentifier(value.requestId, 128) && isSignedReceipt(value.receipt)
130
+ && (!('result' in value) || isCanonical(value.result));
131
+ }
132
+ if (value.kind === 'secret-token') {
133
+ return isExactRecord(value, ['protocol', 'kind', 'requestId', 'token'])
134
+ && isIdentifier(value.requestId, 128) && isOwnedToken(value.token);
135
+ }
136
+ if (value.kind === 'secret-start') {
137
+ if (!hasOnlyKeys(value, ['protocol', 'kind', 'authorityId', 'configHash', 'playerId', 'slot', 'role', 'kid', 'publicKeySpki', 'systems', 'nextRequestSequence']))
138
+ return false;
139
+ const seatedFieldsValid = value.role === 'spectator'
140
+ ? value.playerId === undefined && value.slot === undefined
141
+ : value.role === 'player' && isIdentifier(value.playerId, 256) && isNonNegativeInteger(value.slot);
142
+ return seatedFieldsValid && isIdentifier(value.authorityId, 256) && isHex(value.configHash, 64)
143
+ && isIdentifier(value.kid, 128) && typeof value.publicKeySpki === 'string' && value.publicKeySpki.length > 0
144
+ && (value.nextRequestSequence === undefined || isNonNegativeInteger(value.nextRequestSequence))
145
+ && Array.isArray(value.systems) && value.systems.every(isManifestSystem);
146
+ }
147
+ return false;
148
+ }
149
+ const secretErrorCodes = [
150
+ 'secret-transport-unavailable', 'spectator-read-only', 'unknown-system', 'wrong-system-kind',
151
+ 'malformed-envelope', 'oversized-payload', 'request-conflict', 'request-history-expired', 'invalid-phase',
152
+ 'deadline-elapsed', 'missing-local-commitment', 'commitment-mismatch', 'random-exhausted', 'collection-empty',
153
+ 'hand-full', 'token-unknown', 'token-owner-mismatch', 'token-already-consumed', 'proof-invalid',
154
+ 'signing-unavailable', 'signature-invalid', 'restore-config-mismatch',
155
+ ];
156
+ function isManifestSystem(value) {
157
+ if (!isRecord(value) || !hasOnlyKeys(value, ['systemId', 'kind', 'min', 'max', 'count', 'proof']))
158
+ return false;
159
+ return isIdentifier(value.systemId, 64) && isOneOf(value.kind, ['choice', 'random', 'deck', 'bag', 'roles'])
160
+ && (value.min === undefined || Number.isSafeInteger(value.min))
161
+ && (value.max === undefined || Number.isSafeInteger(value.max))
162
+ && (value.count === undefined || isNonNegativeInteger(value.count))
163
+ && (value.proof === undefined || isManifestProof(value.proof));
164
+ }
165
+ function isManifestProof(value) {
166
+ if (!isRecord(value) || typeof value.kind !== 'string')
167
+ return false;
168
+ if (value.kind === 'random-context') {
169
+ return isExactRecord(value, ['kind', 'link', 'nextDrawIndex', 'receipt'])
170
+ && isHex(value.link, 64) && isNonNegativeInteger(value.nextDrawIndex) && isSignedReceipt(value.receipt);
171
+ }
172
+ const collectionKeys = value.assignedTokenId === undefined
173
+ ? ['kind', 'root', 'receipt']
174
+ : ['kind', 'root', 'assignedTokenId', 'receipt'];
175
+ return value.kind === 'collection-context'
176
+ && isExactRecord(value, collectionKeys)
177
+ && isHex(value.root, 64)
178
+ && (value.assignedTokenId === undefined || isIdentifier(value.assignedTokenId, 128))
179
+ && isSignedReceipt(value.receipt);
180
+ }
181
+ function isSignedReceipt(value) {
182
+ if (!isExactRecord(value, ['body', 'signature']) || typeof value.signature !== 'string')
183
+ return false;
184
+ const body = value.body;
185
+ return isExactRecord(body, ['protocol', 'domain', 'authorityId', 'systemId', 'requestId', 'playerId', 'slot', 'acceptedTick', 'commandHash', 'kid'])
186
+ && body.protocol === syncplaySecretProtocol && body.domain === 'run.syncplay.receipt/v1'
187
+ && isIdentifier(body.authorityId, 256) && isIdentifier(body.systemId, 64) && isIdentifier(body.requestId, 128)
188
+ && isIdentifier(body.playerId, 256) && isNonNegativeInteger(body.slot) && isNonNegativeInteger(body.acceptedTick)
189
+ && isHex(body.commandHash, 64) && isIdentifier(body.kid, 128);
190
+ }
191
+ function isOwnedToken(value) {
192
+ return isExactRecord(value, ['tokenId', 'systemId', 'ownerSlot', 'value', 'leafNonce', 'leafIndex', 'proof', 'root', 'receipt'])
193
+ && isIdentifier(value.tokenId, 128) && isIdentifier(value.systemId, 64) && isNonNegativeInteger(value.ownerSlot)
194
+ && isCanonical(value.value) && isHex(value.leafNonce, 64) && isNonNegativeInteger(value.leafIndex)
195
+ && Array.isArray(value.proof) && value.proof.every((entry) => typeof entry === 'string' && /^[lr]:[0-9a-f]{64}$/.test(entry))
196
+ && isHex(value.root, 64) && isSignedReceipt(value.receipt);
197
+ }
198
+ function isCanonical(value) {
199
+ try {
200
+ return canonicalStringify(value) === JSON.stringify(value);
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ function isRecord(value) {
207
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
208
+ }
209
+ function isExactRecord(value, keys) {
210
+ return isRecord(value) && Object.keys(value).length === keys.length && hasOnlyKeys(value, keys);
211
+ }
212
+ function hasOnlyKeys(value, keys) {
213
+ return Object.keys(value).every((key) => keys.includes(key)) && keys.every((key) => key in value || optionalKey(key));
214
+ }
215
+ function optionalKey(key) {
216
+ return key === 'result' || key === 'playerId' || key === 'slot' || key === 'min' || key === 'max' || key === 'count'
217
+ || key === 'proof' || key === 'nextRequestSequence';
218
+ }
219
+ function isIdentifier(value, maxLength) {
220
+ return typeof value === 'string' && value.length >= 1 && value.length <= maxLength;
221
+ }
222
+ function isNonNegativeInteger(value) {
223
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
224
+ }
225
+ function isHex(value, length) {
226
+ return typeof value === 'string' && value.length === length && /^[0-9a-f]+$/.test(value);
227
+ }
228
+ function isOneOf(value, values) {
229
+ return typeof value === 'string' && values.includes(value);
230
+ }
@@ -12,6 +12,22 @@ export interface BuildDeterministicSessionConfig<State, Input> {
12
12
  readonly maxCatchUpFrames?: number;
13
13
  readonly replayRecordingMode?: ReplayRecordingMode;
14
14
  readonly runtimeGuards?: boolean;
15
+ /**
16
+ * State-codec overrides (same signatures as DeterministicSessionConfig).
17
+ * Each field independently replaces its schema-artifact default; unlike the
18
+ * raw low-level config there is NO fallback chain — a lone canonicalizeState
19
+ * shapes replay initial state and canonical egress but never byte
20
+ * serialization. Override these when your state is not fully expressible as
21
+ * declared descriptor fields (large typed-array worlds, custom binary
22
+ * codecs, incremental hashes). serializeState/deserializeState must be
23
+ * provided together. If your custom serializer covers fields the descriptor
24
+ * does not declare, also provide canonicalizeState — the schema default
25
+ * drops undeclared fields from replay initial state and snapshot egress.
26
+ */
27
+ readonly checksum?: (state: State) => string | number;
28
+ readonly canonicalizeState?: (state: State) => unknown;
29
+ readonly serializeState?: (state: State) => Uint8Array;
30
+ readonly deserializeState?: (bytes: Uint8Array) => State;
15
31
  }
16
32
  /**
17
33
  * Construct an OfflineSession from a validated descriptor, wiring the
@@ -10,6 +10,9 @@ export function buildDeterministicSession(config) {
10
10
  if (config.playerCount > config.descriptor.maxPlayers) {
11
11
  throw new Error(`Session playerCount ${config.playerCount} exceeds descriptor maxPlayers ${config.descriptor.maxPlayers}`);
12
12
  }
13
+ if ((config.serializeState === undefined) !== (config.deserializeState === undefined)) {
14
+ throw new Error('serializeState and deserializeState must be provided together — a custom encoding one side cannot decode corrupts snapshot restore');
15
+ }
13
16
  return createOfflineSession({
14
17
  tickRate: config.descriptor.tickRate,
15
18
  playerCount: config.playerCount,
@@ -25,10 +28,10 @@ export function buildDeterministicSession(config) {
25
28
  // FULL declared state for replay initial-state and snapshots (rollback,
26
29
  // late-join hydration restore from these): they must carry every declared
27
30
  // 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),
31
+ canonicalizeState: config.canonicalizeState ?? ((state) => artifacts.canonicalizeFullState(state)),
32
+ serializeState: config.serializeState ?? ((state) => artifacts.serializeFullState(state)),
33
+ deserializeState: config.deserializeState ?? ((bytes) => artifacts.deserializeFullState(bytes)),
34
+ checksum: config.checksum ?? ((state) => artifacts.checksumState(state)),
32
35
  randomSeed: config.randomSeed,
33
36
  snapshotBufferSize: config.snapshotBufferSize,
34
37
  checksumIntervalFrames: config.checksumIntervalFrames,
@@ -1,4 +1,5 @@
1
1
  import { canonicalize } from './canonical.js';
2
+ import { computeWireStateChecksum } from './checksum-basis.js';
2
3
  import { SyncplayError, invariant } from './errors.js';
3
4
  import { createOfflineSession } from './offline-session.js';
4
5
  import { base64ToBytes, bytesToBase64, encodeDeterministicStateSnapshot, decodeDeterministicStateSnapshot, } from './session-snapshot.js';
@@ -28,7 +29,7 @@ import { base64ToBytes, bytesToBase64, encodeDeterministicStateSnapshot, decodeD
28
29
  export function exportPromotionSnapshot(session, frame) {
29
30
  const targetFrame = frame ?? session.currentFrame;
30
31
  invariant(Number.isInteger(targetFrame) && targetFrame >= 1, 'Promotion export requires a stepped frame (>= 1): the v1 snapshot envelope encodes tick = frame - 1', 'promotion.invalid-frame');
31
- const snapshot = session.getSnapshot(targetFrame);
32
+ const snapshot = session.peekSnapshot(targetFrame);
32
33
  if (snapshot === undefined) {
33
34
  throw new SyncplayError(`No snapshot buffered for frame ${targetFrame} — it may have been evicted from the rolling buffer`, { code: 'promotion.missing-snapshot', frame: targetFrame });
34
35
  }
@@ -36,9 +37,9 @@ export function exportPromotionSnapshot(session, frame) {
36
37
  v: 1,
37
38
  tick: targetFrame - 1,
38
39
  sessionFrame: targetFrame,
39
- stateBytes: bytesToBase64(snapshot.stateBytes),
40
+ stateBytes: bytesToBase64(session.getSnapshotBytes(snapshot.frame)),
40
41
  randomState: snapshot.randomState,
41
- stateChecksum: snapshot.checksum,
42
+ stateChecksum: session.getWireStateChecksum(snapshot.frame),
42
43
  // Session inputs are canonical by construction (cloneCanonical on setInput).
43
44
  confirmedInputs: snapshot.inputs.map((input) => canonicalize(input)),
44
45
  });
@@ -62,8 +63,7 @@ export function createSessionFromPromotionSnapshot(config, blob) {
62
63
  // over the raw recorded value (adversarial-review LOW).
63
64
  (recorded) => (config.hydrateInput !== undefined ? config.hydrateInput(recorded) : recorded)),
64
65
  });
65
- const restored = session.getSnapshot(session.currentFrame);
66
- if (restored === undefined || restored.checksum !== snapshot.stateChecksum) {
66
+ if (computeWireStateChecksum(config, session.getState()) !== snapshot.stateChecksum) {
67
67
  throw new SyncplayError('Promotion snapshot checksum mismatch after hydration — the game build or state bytes do not match the exporter', { code: 'promotion.checksum-mismatch', frame: snapshot.sessionFrame });
68
68
  }
69
69
  return session;
@@ -1,6 +1,6 @@
1
1
  import { DeterministicRandom } from './random.js';
2
2
  import type { DeterministicSessionConfig } from './types.js';
3
- export type SyncplaySynctestPhase = 'straight' | 'rollback' | 'replay';
3
+ export type SyncplaySynctestPhase = 'straight' | 'rollback' | 'replay' | 'aliasing' | 'hydration' | 'codec';
4
4
  export type SyncplaySynctestInputFuzz<Input> = (random: DeterministicRandom, slot: number, frame: number) => Input;
5
5
  export interface SyncplaySynctestConfig<State, Input> extends DeterministicSessionConfig<State, Input> {
6
6
  readonly frames?: number;
package/dist/synctest.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { defaultChecksum } from './canonical.js';
2
+ import { deserializeStateWith, serializeStateWith } from './checksum-basis.js';
1
3
  import { stableSortedKeys } from './collections.js';
2
4
  import { SyncplayError, invariant } from './errors.js';
3
5
  import { createOfflineSession } from './offline-session.js';
@@ -37,9 +39,56 @@ export function runSyncplaySynctest(config) {
37
39
  const schedule = buildInputSchedule(config.playerCount, frames, seed, inputFuzz);
38
40
  const straightChecksums = [];
39
41
  for (let frame = 0; frame < frames; frame += 1) {
42
+ const beforeSnapshot = straight.peekSnapshot(straight.currentFrame);
43
+ const beforeFingerprint = beforeSnapshot === undefined
44
+ ? undefined
45
+ : defaultChecksum(beforeSnapshot.state);
46
+ const external = straight.exportExternalSnapshot(straight.currentFrame);
47
+ const hydrated = createOfflineSession(sessionConfig);
48
+ hydrated.restoreSnapshot(external);
40
49
  applyScheduledInputs(straight, schedule[frame]);
41
50
  straight.stepFrames(1);
42
51
  straightChecksums.push(takeChecksum(lastChecksum, straight.currentFrame));
52
+ if (beforeSnapshot !== undefined && beforeFingerprint !== undefined) {
53
+ const afterFingerprint = defaultChecksum(beforeSnapshot.state);
54
+ if (afterFingerprint !== beforeFingerprint) {
55
+ return diverged(seedsRun, framesChecked, {
56
+ seed,
57
+ frame: beforeSnapshot.frame,
58
+ phase: 'aliasing',
59
+ straightChecksum: beforeFingerprint,
60
+ rollbackChecksum: afterFingerprint,
61
+ });
62
+ }
63
+ }
64
+ applyScheduledInputs(hydrated, schedule[frame]);
65
+ hydrated.stepFrames(1);
66
+ const straightAfter = defaultChecksum(straight.getState());
67
+ const hydratedAfter = defaultChecksum(hydrated.getState());
68
+ if (straightAfter !== hydratedAfter) {
69
+ return diverged(seedsRun, framesChecked, {
70
+ seed,
71
+ frame: external.frame,
72
+ phase: 'hydration',
73
+ straightChecksum: straightAfter,
74
+ rollbackChecksum: hydratedAfter,
75
+ });
76
+ }
77
+ if (sessionConfig.serializeState !== undefined) {
78
+ const live = straight.getState();
79
+ const roundTripped = deserializeStateWith(sessionConfig, serializeStateWith(sessionConfig, live));
80
+ const liveChecksum = defaultChecksum(live);
81
+ const roundTripChecksum = defaultChecksum(roundTripped);
82
+ if (liveChecksum !== roundTripChecksum) {
83
+ return diverged(seedsRun, framesChecked, {
84
+ seed,
85
+ frame: straight.currentFrame,
86
+ phase: 'codec',
87
+ straightChecksum: liveChecksum,
88
+ rollbackChecksum: roundTripChecksum,
89
+ });
90
+ }
91
+ }
43
92
  }
44
93
  const replay = straight.exportReplay();
45
94
  const rollback = createOfflineSession(sessionConfig);
package/dist/testing.d.ts CHANGED
@@ -38,6 +38,8 @@ export { runDeterministicNavmeshImportTraversalFixture, runDeterministicNavmeshL
38
38
  export type { DeterministicNavmeshImportTraversalFixtureResult, DeterministicNavmeshLayered3DFixtureResult, DeterministicNavmeshVelocityObstacleFixtureResult } from './navmesh.js';
39
39
  export { runDeterministicPhysics3DCallbackMaskFixture, runDeterministicPhysics3DDetailFixture, runDeterministicPhysics3DMaterialFixture, runDeterministicPhysics3DQueryOptionsFixture, runDeterministicPhysics3DStackFixture } from './physics3d.js';
40
40
  export type { DeterministicPhysics3DCallbackMaskFixtureResult, DeterministicPhysics3DDetailFixtureResult, DeterministicPhysics3DMaterialFixtureResult, DeterministicPhysics3DQueryOptionsFixtureResult, DeterministicPhysics3DStackFixtureResult } from './physics3d.js';
41
+ export { runDeterministicRagdoll3DFixture, runDeterministicRigidWheelVehicle3DFixture } from './physics3d-recipes.js';
42
+ export type { DeterministicRagdoll3DFixtureResult, DeterministicRigidWheelVehicle3DFixtureResult } from './physics3d-recipes.js';
41
43
  export { runDeterministicVehicle3DFixture } from './physics3d-vehicle.js';
42
44
  export type { DeterministicVehicle3DFixtureResult } from './physics3d-vehicle.js';
43
45
  export { runDeterministicRollbackHistoryFixture } from './rollback-history.js';
package/dist/testing.js CHANGED
@@ -37,6 +37,7 @@ export { runDeterministicLocalAuthorityWireFixture } from './local-authority-wir
37
37
  export { runDeterministicKcc3DAdversarialFixture, runDeterministicKcc3DCallbackFilterFixture, runDeterministicKcc3DCrowdFixture, runDeterministicKcc3DFilterForceFixture, runDeterministicKcc3DGeometryFixture } from './movement3d.js';
38
38
  export { runDeterministicNavmeshImportTraversalFixture, runDeterministicNavmeshLayered3DFixture, runDeterministicNavmeshVelocityObstacleFixture } from './navmesh.js';
39
39
  export { runDeterministicPhysics3DCallbackMaskFixture, runDeterministicPhysics3DDetailFixture, runDeterministicPhysics3DMaterialFixture, runDeterministicPhysics3DQueryOptionsFixture, runDeterministicPhysics3DStackFixture } from './physics3d.js';
40
+ export { runDeterministicRagdoll3DFixture, runDeterministicRigidWheelVehicle3DFixture } from './physics3d-recipes.js';
40
41
  export { runDeterministicVehicle3DFixture } from './physics3d-vehicle.js';
41
42
  export { runDeterministicRollbackHistoryFixture } from './rollback-history.js';
42
43
  export { runDeterministicDslBinaryFixture } from './schema-dsl-binary.js';
@@ -25,6 +25,17 @@ export interface DeterminismCheckOptions {
25
25
  readonly stateObjectName?: string;
26
26
  }
27
27
  export type DeterminismParserKind = 'typescript-ast' | 'regex-scanner';
28
+ export interface DeterminismUnresolvedImport {
29
+ readonly importer: string;
30
+ readonly specifier: string;
31
+ readonly line: number;
32
+ }
33
+ export interface DeterminismCheckRun {
34
+ readonly diagnostics: readonly DeterminismDiagnostic[];
35
+ readonly checkedFiles: readonly string[];
36
+ readonly unresolvedImports: readonly DeterminismUnresolvedImport[];
37
+ }
38
+ export declare function runDeterminismCheck(options: DeterminismCheckOptions): Promise<DeterminismCheckRun>;
28
39
  export declare function checkDeterminism(options: DeterminismCheckOptions): Promise<readonly DeterminismDiagnostic[]>;
29
40
  export declare function collectDeterminismSuppressions(paths: readonly string[]): Promise<readonly DeterminismSuppression[]>;
30
41
  export declare function checkSourceText(path: string, content: string, mode?: DeterminismCheckMode, options?: Pick<DeterminismCheckOptions, 'parserKind' | 'schemaStateFields' | 'stateObjectName'>): readonly DeterminismDiagnostic[];
@@ -277,19 +277,33 @@ const nodeBuiltinSpecifiers = new Set([
277
277
  'worker_threads',
278
278
  'zlib',
279
279
  ]);
280
- export async function checkDeterminism(options) {
280
+ export async function runDeterminismCheck(options) {
281
281
  const mode = options.mode ?? 'warn';
282
- const files = await collectFiles(options.paths, options.followImports ?? true, options.project);
282
+ const collected = await collectFiles(options.paths, options.followImports ?? true, options.project);
283
283
  const diagnostics = [];
284
284
  const schemaOptions = createSchemaOptions(options.schemaStateFields, options.stateObjectName);
285
- for (const file of files) {
285
+ for (const file of collected.files) {
286
286
  const content = await readFile(file, 'utf8');
287
287
  diagnostics.push(...checkFile(file, content, mode, schemaOptions, options.parserKind));
288
288
  }
289
- return diagnostics;
289
+ for (const unresolved of collected.unresolvedImports) {
290
+ diagnostics.push({
291
+ ruleId: 'determinism/unresolved-import',
292
+ severity: 'warn',
293
+ path: unresolved.importer,
294
+ line: unresolved.line,
295
+ column: 1,
296
+ message: `Import "${unresolved.specifier}" resolves to no file, so that part of the simulation graph was NOT determinism-checked.`,
297
+ suggestion: 'Fix the import path so the checker can traverse it, or remove the dead import.',
298
+ });
299
+ }
300
+ return { diagnostics, checkedFiles: collected.files, unresolvedImports: collected.unresolvedImports };
301
+ }
302
+ export async function checkDeterminism(options) {
303
+ return (await runDeterminismCheck(options)).diagnostics;
290
304
  }
291
305
  export async function collectDeterminismSuppressions(paths) {
292
- const files = await collectFiles(paths, true);
306
+ const files = (await collectFiles(paths, true)).files;
293
307
  const suppressions = [];
294
308
  for (const file of files) {
295
309
  const lines = (await readFile(file, 'utf8')).split(/\r?\n/);
@@ -1252,23 +1266,34 @@ function checkDeclaredStateFields(path, lineText, previousLine, sourceLine, mode
1252
1266
  }
1253
1267
  async function collectFiles(paths, followImports, project) {
1254
1268
  const files = [];
1269
+ const unresolvedImports = [];
1255
1270
  const visited = new Set();
1256
1271
  const resolver = await createImportResolver(project);
1257
1272
  for (const path of paths) {
1258
1273
  const entry = await stat(path);
1259
1274
  if (entry.isFile() && isCheckableFile(path)) {
1260
1275
  if (followImports) {
1261
- files.push(...(await collectFileWithImports(path, visited, resolver)));
1276
+ files.push(...(await collectFileWithImports(path, visited, resolver, unresolvedImports)));
1262
1277
  }
1263
1278
  else {
1264
1279
  files.push(path);
1265
1280
  }
1266
1281
  }
1267
1282
  if (entry.isDirectory()) {
1268
- files.push(...(await collectDirectory(path)));
1283
+ // With followImports, a directory's files also traverse their imports —
1284
+ // a dir whose files import shared code OUTSIDE the dir must not skip it.
1285
+ const directoryFiles = await collectDirectory(path);
1286
+ if (followImports) {
1287
+ for (const directoryFile of directoryFiles) {
1288
+ files.push(...(await collectFileWithImports(directoryFile, visited, resolver, unresolvedImports)));
1289
+ }
1290
+ }
1291
+ else {
1292
+ files.push(...directoryFiles);
1293
+ }
1269
1294
  }
1270
1295
  }
1271
- return Array.from(new Set(files)).sort();
1296
+ return { files: Array.from(new Set(files)).sort(), unresolvedImports };
1272
1297
  }
1273
1298
  async function collectDirectory(path) {
1274
1299
  const files = [];
@@ -1291,7 +1316,7 @@ async function collectDirectory(path) {
1291
1316
  function isCheckableFile(path) {
1292
1317
  return /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(path);
1293
1318
  }
1294
- async function collectFileWithImports(path, visited, resolver) {
1319
+ async function collectFileWithImports(path, visited, resolver, unresolvedImports) {
1295
1320
  const absolutePath = resolve(path);
1296
1321
  if (visited.has(absolutePath)) {
1297
1322
  return [];
@@ -1299,20 +1324,42 @@ async function collectFileWithImports(path, visited, resolver) {
1299
1324
  visited.add(absolutePath);
1300
1325
  const files = [path];
1301
1326
  const content = await readFile(path, 'utf8');
1302
- for (const specifier of findStaticImportSpecifiers(content)) {
1303
- const resolvedPath = await resolver.resolve(path, specifier);
1327
+ for (const found of findStaticImportSpecifiers(content)) {
1328
+ // Resolve with any ?query/#hash suffix stripped: './helper.ts?worker' is
1329
+ // real executed source and must be traversed; the bundler resolves the
1330
+ // bare path.
1331
+ const bare = found.specifier.replace(/[?#].*$/, '');
1332
+ const resolvedPath = await resolver.resolve(path, bare);
1304
1333
  if (resolvedPath !== undefined) {
1305
- files.push(...(await collectFileWithImports(resolvedPath, visited, resolver)));
1334
+ files.push(...(await collectFileWithImports(resolvedPath, visited, resolver, unresolvedImports)));
1335
+ }
1336
+ else if (isRelativeSpecifier(bare) && !(await specifierTargetsExistingFile(path, bare))) {
1337
+ unresolvedImports.push({ importer: path, specifier: found.specifier, line: found.line });
1306
1338
  }
1307
1339
  }
1308
1340
  return files;
1309
1341
  }
1342
+ function isRelativeSpecifier(specifier) {
1343
+ return specifier.startsWith('.') || specifier.startsWith('/');
1344
+ }
1345
+ // Vite-style asset imports ('./sprite.png', './style.css?inline') point at
1346
+ // real files that just aren't checkable source — only a specifier reaching NO
1347
+ // file is a hole in the checked graph.
1348
+ async function specifierTargetsExistingFile(importer, bareSpecifier) {
1349
+ try {
1350
+ return (await stat(resolve(dirname(importer), bareSpecifier))).isFile();
1351
+ }
1352
+ catch {
1353
+ return false;
1354
+ }
1355
+ }
1310
1356
  function findStaticImportSpecifiers(content) {
1311
1357
  const specifiers = [];
1312
1358
  const importPattern = /^\s*(?:import|export)\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/gm;
1313
1359
  let match = importPattern.exec(content);
1314
1360
  while (match !== null) {
1315
- specifiers.push(match[1]);
1361
+ const line = content.slice(0, match.index).split('\n').length;
1362
+ specifiers.push({ specifier: match[1], line });
1316
1363
  match = importPattern.exec(content);
1317
1364
  }
1318
1365
  return specifiers;
package/dist/types.d.ts CHANGED
@@ -10,6 +10,18 @@ export interface DeterministicStepContext {
10
10
  readonly math: DeterministicMath;
11
11
  readonly commands: readonly CanonicalValue[];
12
12
  }
13
+ /**
14
+ * The simulation step.
15
+ *
16
+ * OWNERSHIP CONTRACT (enforced by synctest's `aliasing` phase):
17
+ * - `step` MUST NOT mutate `state`, nor anything reachable from it. Copy any
18
+ * array, Map, Set or object before modifying it.
19
+ * - Sharing unmodified substructure with the prior state is expected and makes
20
+ * a persistent map an O(1) snapshot. Returning `state` unchanged is legal.
21
+ * - The engine retains the returned object in its snapshot ring by reference
22
+ * and hands it back on rollback without copying. Mutating a prior state can
23
+ * therefore corrupt retained snapshots and desync only peers that roll back.
24
+ */
13
25
  export type DeterministicStep<State, Input> = (state: Readonly<State>, inputs: readonly Readonly<Input>[], frame: number, ctx: DeterministicStepContext) => State;
14
26
  export interface DeterministicSessionConfig<State, Input> {
15
27
  readonly tickRate: TickRate;
@@ -98,9 +110,9 @@ export interface ExternalSessionSnapshot<Input> {
98
110
  export interface FrameSnapshot<State, Input> {
99
111
  readonly frame: number;
100
112
  readonly inputs: readonly Input[];
101
- readonly checksum: string;
113
+ /** Present only when `frame % checksumIntervalFrames === 0`. */
114
+ readonly checksum: string | undefined;
102
115
  readonly state: State;
103
- readonly stateBytes: Uint8Array;
104
116
  readonly randomState: number;
105
117
  }
106
118
  export interface SessionSchemaIdentity {
@@ -120,7 +132,14 @@ export interface OfflineSession<State, Input> {
120
132
  stepFrames(count: number): void;
121
133
  getState(): State;
122
134
  exportReplay(): ReplayFile;
135
+ /** Zero-copy access to the retained ring entry. Do not mutate it. */
136
+ peekSnapshot(frame: number): FrameSnapshot<State, Input> | undefined;
123
137
  getSnapshot(frame: number): FrameSnapshot<State, Input> | undefined;
138
+ getSnapshotBytes(frame: number): Uint8Array;
139
+ exportExternalSnapshot(frame: number): ExternalSessionSnapshot<Input>;
140
+ getRecordedChecksum(frame: number): string | undefined;
141
+ computeRecordedChecksum(frame: number): string;
142
+ getWireStateChecksum(frame: number): string;
124
143
  restoreToFrame(frame: number): void;
125
144
  /**
126
145
  * Hydrate from a snapshot exported by ANOTHER session (snapshot late-join):