@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.
- package/README.md +53 -12
- package/dist/authority-room.d.ts +11 -0
- package/dist/authority-room.js +43 -0
- package/dist/browser.d.ts +11 -1
- package/dist/browser.js +5 -0
- package/dist/checksum-basis.d.ts +4 -0
- package/dist/checksum-basis.js +17 -0
- package/dist/cjs/authority-room.js +43 -0
- package/dist/cjs/browser.js +18 -2
- package/dist/cjs/checksum-basis.js +22 -0
- package/dist/cjs/collider-cooking.js +834 -0
- package/dist/cjs/deterministic-session.js +2 -0
- package/dist/cjs/engine-complete.js +1 -4
- package/dist/cjs/engine-parity-matrix.js +41 -9
- package/dist/cjs/engine-parity.js +2 -2
- package/dist/cjs/index.js +24 -2
- package/dist/cjs/input-authority.js +34 -2
- package/dist/cjs/instant-replay.js +4 -9
- package/dist/cjs/local-authority-server-runner.js +3 -1
- package/dist/cjs/local-authority-wire.js +20 -1
- package/dist/cjs/match-log.js +27 -0
- package/dist/cjs/movement3d.js +17 -1
- package/dist/cjs/networked-client.js +70 -20
- package/dist/cjs/node.js +23 -1
- package/dist/cjs/offline-session.js +52 -24
- package/dist/cjs/physics-cert.js +8 -3
- package/dist/cjs/physics3d-heightfield.js +51 -0
- package/dist/cjs/physics3d-hull.js +381 -0
- package/dist/cjs/physics3d-joints.js +255 -49
- package/dist/cjs/physics3d-mass.js +131 -0
- package/dist/cjs/physics3d-recipes.js +197 -0
- package/dist/cjs/physics3d-shared.js +11 -1
- package/dist/cjs/physics3d-solver.js +845 -98
- package/dist/cjs/physics3d-stacking.js +154 -0
- package/dist/cjs/physics3d-toi.js +274 -0
- package/dist/cjs/physics3d-vehicle.js +35 -10
- package/dist/cjs/physics3d.js +601 -38
- package/dist/cjs/replay.js +25 -1
- package/dist/cjs/runtime-support.js +71 -0
- package/dist/cjs/sdk-offline.js +9 -1
- package/dist/cjs/sdk-session.js +8 -0
- package/dist/cjs/secret-authority.js +647 -0
- package/dist/cjs/secret-client.js +615 -0
- package/dist/cjs/secret-command-classification.js +8 -0
- package/dist/cjs/secret-config.js +211 -0
- package/dist/cjs/secret-crypto.js +208 -0
- package/dist/cjs/secret-protocol.js +238 -0
- package/dist/cjs/session-build.js +7 -4
- package/dist/cjs/session-promotion.js +5 -5
- package/dist/cjs/synctest.js +49 -0
- package/dist/cjs/testing.js +4 -1
- package/dist/cjs/tools/static-checker.js +61 -13
- package/dist/cli.js +193 -48
- package/dist/collider-cooking.d.ts +141 -0
- package/dist/collider-cooking.js +829 -0
- package/dist/deterministic-primitives.d.ts +1 -0
- package/dist/deterministic-session.js +2 -0
- package/dist/engine-complete.js +1 -4
- package/dist/engine-parity-matrix.js +41 -9
- package/dist/engine-parity.js +2 -2
- package/dist/index.d.ts +12 -1
- package/dist/index.js +6 -0
- package/dist/input-authority.d.ts +3 -0
- package/dist/input-authority.js +35 -3
- package/dist/instant-replay.d.ts +2 -2
- package/dist/instant-replay.js +4 -9
- package/dist/local-authority-server-runner.d.ts +1 -0
- package/dist/local-authority-server-runner.js +2 -1
- package/dist/local-authority-wire.js +20 -1
- package/dist/match-log.d.ts +4 -1
- package/dist/match-log.js +27 -0
- package/dist/movement3d.js +18 -2
- package/dist/networked-client.d.ts +8 -0
- package/dist/networked-client.js +70 -20
- package/dist/node.d.ts +7 -0
- package/dist/node.js +6 -0
- package/dist/offline-session.js +53 -25
- package/dist/physics-cert.js +8 -3
- package/dist/physics3d-heightfield.d.ts +17 -0
- package/dist/physics3d-heightfield.js +47 -0
- package/dist/physics3d-hull.d.ts +53 -0
- package/dist/physics3d-hull.js +376 -0
- package/dist/physics3d-joints.d.ts +13 -1
- package/dist/physics3d-joints.js +256 -50
- package/dist/physics3d-mass.d.ts +11 -0
- package/dist/physics3d-mass.js +126 -0
- package/dist/physics3d-recipes.d.ts +29 -0
- package/dist/physics3d-recipes.js +193 -0
- package/dist/physics3d-shared.d.ts +1 -0
- package/dist/physics3d-shared.js +10 -1
- package/dist/physics3d-solver.d.ts +78 -2
- package/dist/physics3d-solver.js +842 -98
- package/dist/physics3d-stacking.d.ts +26 -0
- package/dist/physics3d-stacking.js +147 -0
- package/dist/physics3d-toi.d.ts +79 -0
- package/dist/physics3d-toi.js +267 -0
- package/dist/physics3d-vehicle.js +35 -10
- package/dist/physics3d.d.ts +28 -3
- package/dist/physics3d.js +601 -40
- package/dist/replay.d.ts +5 -0
- package/dist/replay.js +25 -1
- package/dist/reports.d.ts +6 -1
- package/dist/runtime-support.d.ts +60 -0
- package/dist/runtime-support.js +66 -0
- package/dist/sdk-offline.d.ts +4 -0
- package/dist/sdk-offline.js +9 -1
- package/dist/sdk-session.d.ts +4 -0
- package/dist/sdk-session.js +8 -0
- package/dist/secret-authority.d.ts +90 -0
- package/dist/secret-authority.js +643 -0
- package/dist/secret-client.d.ts +89 -0
- package/dist/secret-client.js +611 -0
- package/dist/secret-command-classification.d.ts +1 -0
- package/dist/secret-command-classification.js +5 -0
- package/dist/secret-config.d.ts +45 -0
- package/dist/secret-config.js +206 -0
- package/dist/secret-crypto.d.ts +29 -0
- package/dist/secret-crypto.js +190 -0
- package/dist/secret-protocol.d.ts +181 -0
- package/dist/secret-protocol.js +230 -0
- package/dist/session-build.d.ts +16 -0
- package/dist/session-build.js +7 -4
- package/dist/session-promotion.js +5 -5
- package/dist/synctest.d.ts +1 -1
- package/dist/synctest.js +49 -0
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +1 -0
- package/dist/tools/static-checker.d.ts +11 -0
- package/dist/tools/static-checker.js +60 -13
- package/dist/types.d.ts +21 -2
- 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
|
+
}
|
package/dist/session-build.d.ts
CHANGED
|
@@ -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
|
package/dist/session-build.js
CHANGED
|
@@ -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.
|
|
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.
|
|
40
|
+
stateBytes: bytesToBase64(session.getSnapshotBytes(snapshot.frame)),
|
|
40
41
|
randomState: snapshot.randomState,
|
|
41
|
-
stateChecksum: snapshot.
|
|
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
|
-
|
|
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;
|
package/dist/synctest.d.ts
CHANGED
|
@@ -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
|
|
280
|
+
export async function runDeterminismCheck(options) {
|
|
281
281
|
const mode = options.mode ?? 'warn';
|
|
282
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
1303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|