@series-inc/rundot-syncplay 5.23.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +610 -0
- package/dist/animation.d.ts +119 -0
- package/dist/animation.js +488 -0
- package/dist/asset-cooking.d.ts +82 -0
- package/dist/asset-cooking.js +364 -0
- package/dist/authority-room.d.ts +217 -0
- package/dist/authority-room.js +701 -0
- package/dist/bot-documents.d.ts +115 -0
- package/dist/bot-documents.js +549 -0
- package/dist/bot-primitives.d.ts +152 -0
- package/dist/bot-primitives.js +472 -0
- package/dist/browser.d.ts +57 -0
- package/dist/browser.js +53 -0
- package/dist/canonical.d.ts +12 -0
- package/dist/canonical.js +288 -0
- package/dist/certification.d.ts +455 -0
- package/dist/certification.js +1855 -0
- package/dist/chaos-certification.d.ts +56 -0
- package/dist/chaos-certification.js +1397 -0
- package/dist/cjs/animation.js +495 -0
- package/dist/cjs/asset-cooking.js +372 -0
- package/dist/cjs/authority-room.js +704 -0
- package/dist/cjs/bot-documents.js +556 -0
- package/dist/cjs/bot-primitives.js +482 -0
- package/dist/cjs/browser.js +96 -0
- package/dist/cjs/canonical.js +299 -0
- package/dist/cjs/certification.js +1860 -0
- package/dist/cjs/chaos-certification.js +1402 -0
- package/dist/cjs/collections.js +36 -0
- package/dist/cjs/collision.js +96 -0
- package/dist/cjs/commands.js +987 -0
- package/dist/cjs/config-bundle.js +491 -0
- package/dist/cjs/creator.js +30 -0
- package/dist/cjs/deterministic-primitives.js +134 -0
- package/dist/cjs/deterministic-session.js +1410 -0
- package/dist/cjs/ecs-lite.js +155 -0
- package/dist/cjs/effects-adapter.js +97 -0
- package/dist/cjs/engine-complete.js +2297 -0
- package/dist/cjs/engine-core-proof.js +70 -0
- package/dist/cjs/engine-parity-matrix.js +874 -0
- package/dist/cjs/engine-parity.js +3183 -0
- package/dist/cjs/engine-runtime.js +3141 -0
- package/dist/cjs/errors.js +22 -0
- package/dist/cjs/events.js +247 -0
- package/dist/cjs/examples/counter-duel.js +157 -0
- package/dist/cjs/examples/fixed-arena.js +368 -0
- package/dist/cjs/index.js +229 -0
- package/dist/cjs/input-authority.js +150 -0
- package/dist/cjs/input-button.js +256 -0
- package/dist/cjs/input-timeline.js +418 -0
- package/dist/cjs/instant-replay.js +66 -0
- package/dist/cjs/lag-compensation3d.js +837 -0
- package/dist/cjs/local-authority-server-runner.js +1121 -0
- package/dist/cjs/local-authority-wire.js +1497 -0
- package/dist/cjs/local-authority.js +817 -0
- package/dist/cjs/local-ws-adapter.js +150 -0
- package/dist/cjs/match-log.js +61 -0
- package/dist/cjs/math.js +157 -0
- package/dist/cjs/mock-network-adapter.js +26 -0
- package/dist/cjs/movement.js +703 -0
- package/dist/cjs/movement3d.js +1246 -0
- package/dist/cjs/multiclient-authority.js +308 -0
- package/dist/cjs/multiplayer-service.js +434 -0
- package/dist/cjs/navigation.js +347 -0
- package/dist/cjs/navmesh.js +1437 -0
- package/dist/cjs/networked-client.js +691 -0
- package/dist/cjs/networked-input-authority.js +443 -0
- package/dist/cjs/node.js +47 -0
- package/dist/cjs/number-lab.js +78 -0
- package/dist/cjs/offline-session.js +370 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/pause-resume.js +43 -0
- package/dist/cjs/physics-cert.js +426 -0
- package/dist/cjs/physics2d.js +751 -0
- package/dist/cjs/physics3d.js +1996 -0
- package/dist/cjs/prediction-culling.js +203 -0
- package/dist/cjs/protocol-lab.js +1299 -0
- package/dist/cjs/r3f-render-adapter.js +209 -0
- package/dist/cjs/random.js +59 -0
- package/dist/cjs/replay-bundle.js +1598 -0
- package/dist/cjs/replay-inspect.js +236 -0
- package/dist/cjs/replay-scoring.js +68 -0
- package/dist/cjs/replay.js +356 -0
- package/dist/cjs/reports.js +181 -0
- package/dist/cjs/rollback-history.js +251 -0
- package/dist/cjs/runtime-guards.js +111 -0
- package/dist/cjs/sample-lab.js +145 -0
- package/dist/cjs/sample-scenes.js +752 -0
- package/dist/cjs/schema-artifacts.js +359 -0
- package/dist/cjs/schema-codegen.js +544 -0
- package/dist/cjs/schema-dsl-binary.js +655 -0
- package/dist/cjs/schema.js +319 -0
- package/dist/cjs/sdk-offline.js +99 -0
- package/dist/cjs/session-promotion.js +74 -0
- package/dist/cjs/session-snapshot.js +265 -0
- package/dist/cjs/session-wire.js +216 -0
- package/dist/cjs/signals.js +103 -0
- package/dist/cjs/sparse-set.js +169 -0
- package/dist/cjs/synctest.js +136 -0
- package/dist/cjs/system-lifecycle.js +286 -0
- package/dist/cjs/timers.js +61 -0
- package/dist/cjs/tools/static-checker.js +1468 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/venus-mp-budget.js +125 -0
- package/dist/cjs/ws-frame.js +255 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +11379 -0
- package/dist/collections.d.ts +9 -0
- package/dist/collections.js +30 -0
- package/dist/collision.d.ts +25 -0
- package/dist/collision.js +85 -0
- package/dist/commands.d.ts +137 -0
- package/dist/commands.js +977 -0
- package/dist/config-bundle.d.ts +89 -0
- package/dist/config-bundle.js +483 -0
- package/dist/creator.d.ts +20 -0
- package/dist/creator.js +11 -0
- package/dist/deterministic-primitives.d.ts +59 -0
- package/dist/deterministic-primitives.js +126 -0
- package/dist/deterministic-session.d.ts +197 -0
- package/dist/deterministic-session.js +1401 -0
- package/dist/ecs-lite.d.ts +26 -0
- package/dist/ecs-lite.js +152 -0
- package/dist/effects-adapter.d.ts +21 -0
- package/dist/effects-adapter.js +94 -0
- package/dist/engine-complete.d.ts +45 -0
- package/dist/engine-complete.js +2292 -0
- package/dist/engine-core-proof.d.ts +26 -0
- package/dist/engine-core-proof.js +66 -0
- package/dist/engine-parity-matrix.d.ts +300 -0
- package/dist/engine-parity-matrix.js +869 -0
- package/dist/engine-parity.d.ts +74 -0
- package/dist/engine-parity.js +3178 -0
- package/dist/engine-runtime.d.ts +351 -0
- package/dist/engine-runtime.js +3123 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +17 -0
- package/dist/events.d.ts +45 -0
- package/dist/events.js +242 -0
- package/dist/examples/counter-duel.d.ts +43 -0
- package/dist/examples/counter-duel.js +150 -0
- package/dist/examples/fixed-arena.d.ts +110 -0
- package/dist/examples/fixed-arena.js +356 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +66 -0
- package/dist/input-authority.d.ts +82 -0
- package/dist/input-authority.js +147 -0
- package/dist/input-button.d.ts +36 -0
- package/dist/input-button.js +249 -0
- package/dist/input-timeline.d.ts +65 -0
- package/dist/input-timeline.js +412 -0
- package/dist/instant-replay.d.ts +59 -0
- package/dist/instant-replay.js +62 -0
- package/dist/lag-compensation3d.d.ts +146 -0
- package/dist/lag-compensation3d.js +827 -0
- package/dist/local-authority-server-runner.d.ts +79 -0
- package/dist/local-authority-server-runner.js +1118 -0
- package/dist/local-authority-wire.d.ts +142 -0
- package/dist/local-authority-wire.js +1477 -0
- package/dist/local-authority.d.ts +160 -0
- package/dist/local-authority.js +810 -0
- package/dist/local-ws-adapter.d.ts +14 -0
- package/dist/local-ws-adapter.js +146 -0
- package/dist/match-log.d.ts +74 -0
- package/dist/match-log.js +57 -0
- package/dist/math.d.ts +41 -0
- package/dist/math.js +154 -0
- package/dist/mock-network-adapter.d.ts +13 -0
- package/dist/mock-network-adapter.js +23 -0
- package/dist/movement.d.ts +155 -0
- package/dist/movement.js +694 -0
- package/dist/movement3d.d.ts +195 -0
- package/dist/movement3d.js +1238 -0
- package/dist/multiclient-authority.d.ts +30 -0
- package/dist/multiclient-authority.js +305 -0
- package/dist/multiplayer-service.d.ts +153 -0
- package/dist/multiplayer-service.js +429 -0
- package/dist/navigation.d.ts +91 -0
- package/dist/navigation.js +338 -0
- package/dist/navmesh.d.ts +236 -0
- package/dist/navmesh.js +1423 -0
- package/dist/networked-client.d.ts +124 -0
- package/dist/networked-client.js +688 -0
- package/dist/networked-input-authority.d.ts +51 -0
- package/dist/networked-input-authority.js +439 -0
- package/dist/node.d.ts +22 -0
- package/dist/node.js +19 -0
- package/dist/number-lab.d.ts +15 -0
- package/dist/number-lab.js +75 -0
- package/dist/offline-session.d.ts +2 -0
- package/dist/offline-session.js +367 -0
- package/dist/pause-resume.d.ts +48 -0
- package/dist/pause-resume.js +40 -0
- package/dist/physics-cert.d.ts +40 -0
- package/dist/physics-cert.js +389 -0
- package/dist/physics2d.d.ts +142 -0
- package/dist/physics2d.js +741 -0
- package/dist/physics3d.d.ts +285 -0
- package/dist/physics3d.js +1979 -0
- package/dist/prediction-culling.d.ts +34 -0
- package/dist/prediction-culling.js +200 -0
- package/dist/protocol-lab.d.ts +115 -0
- package/dist/protocol-lab.js +1295 -0
- package/dist/r3f-render-adapter.d.ts +61 -0
- package/dist/r3f-render-adapter.js +203 -0
- package/dist/random.d.ts +13 -0
- package/dist/random.js +55 -0
- package/dist/replay-bundle.d.ts +150 -0
- package/dist/replay-bundle.js +1586 -0
- package/dist/replay-inspect.d.ts +60 -0
- package/dist/replay-inspect.js +228 -0
- package/dist/replay-scoring.d.ts +28 -0
- package/dist/replay-scoring.js +65 -0
- package/dist/replay.d.ts +17 -0
- package/dist/replay.js +351 -0
- package/dist/reports.d.ts +176 -0
- package/dist/reports.js +174 -0
- package/dist/rollback-history.d.ts +46 -0
- package/dist/rollback-history.js +247 -0
- package/dist/runtime-guards.d.ts +1 -0
- package/dist/runtime-guards.js +108 -0
- package/dist/sample-lab.d.ts +32 -0
- package/dist/sample-lab.js +134 -0
- package/dist/sample-scenes.d.ts +86 -0
- package/dist/sample-scenes.js +745 -0
- package/dist/schema-artifacts.d.ts +49 -0
- package/dist/schema-artifacts.js +356 -0
- package/dist/schema-codegen.d.ts +21 -0
- package/dist/schema-codegen.js +538 -0
- package/dist/schema-dsl-binary.d.ts +75 -0
- package/dist/schema-dsl-binary.js +648 -0
- package/dist/schema.d.ts +87 -0
- package/dist/schema.js +315 -0
- package/dist/sdk-offline.d.ts +34 -0
- package/dist/sdk-offline.js +96 -0
- package/dist/session-promotion.d.ts +26 -0
- package/dist/session-promotion.js +70 -0
- package/dist/session-snapshot.d.ts +95 -0
- package/dist/session-snapshot.js +255 -0
- package/dist/session-wire.d.ts +89 -0
- package/dist/session-wire.js +211 -0
- package/dist/signals.d.ts +31 -0
- package/dist/signals.js +99 -0
- package/dist/sparse-set.d.ts +34 -0
- package/dist/sparse-set.js +165 -0
- package/dist/synctest.d.ts +31 -0
- package/dist/synctest.js +133 -0
- package/dist/system-lifecycle.d.ts +72 -0
- package/dist/system-lifecycle.js +281 -0
- package/dist/timers.d.ts +17 -0
- package/dist/timers.js +53 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/static-checker.d.ts +31 -0
- package/dist/tools/static-checker.js +1462 -0
- package/dist/tools/vite-plugin.d.ts +69 -0
- package/dist/tools/vite-plugin.js +203 -0
- package/dist/types.d.ts +148 -0
- package/dist/types.js +1 -0
- package/dist/venus-mp-budget.d.ts +29 -0
- package/dist/venus-mp-budget.js +122 -0
- package/dist/ws-frame.d.ts +49 -0
- package/dist/ws-frame.js +247 -0
- package/package.json +169 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { canonicalize, cloneCanonical, decodeCanonicalBytes, defaultChecksum, encodeCanonicalBytes, } from './canonical.js';
|
|
2
|
+
import { SyncplayError, invariant } from './errors.js';
|
|
3
|
+
import { createDeterministicMath } from './math.js';
|
|
4
|
+
import { DeterministicRandom } from './random.js';
|
|
5
|
+
import { withRuntimeDeterminismGuards } from './runtime-guards.js';
|
|
6
|
+
export function createOfflineSession(config) {
|
|
7
|
+
validateConfig(config);
|
|
8
|
+
const checksumInterval = config.checksumIntervalFrames ?? 1;
|
|
9
|
+
const snapshotBufferSize = config.snapshotBufferSize ?? 256;
|
|
10
|
+
const replayRecordingMode = config.replayRecordingMode ?? 'memory';
|
|
11
|
+
const randomSeed = config.randomSeed ?? 1;
|
|
12
|
+
const fixedFrameMs = 1000 / config.tickRate;
|
|
13
|
+
const math = createDeterministicMath();
|
|
14
|
+
const initialState = cloneCanonical(config.initialState);
|
|
15
|
+
const mutable = {
|
|
16
|
+
frame: 0,
|
|
17
|
+
accumulatedMs: 0,
|
|
18
|
+
state: cloneCanonical(initialState),
|
|
19
|
+
inputs: Array.from({ length: config.playerCount }, () => cloneInput(config, config.defaultInput)),
|
|
20
|
+
inputFlags: Array.from({ length: config.playerCount }, (_, slot) => defaultInputFlags(slot)),
|
|
21
|
+
random: new DeterministicRandom(randomSeed),
|
|
22
|
+
inputFrames: [],
|
|
23
|
+
commandFrames: [],
|
|
24
|
+
pendingCommandFrames: new Map(),
|
|
25
|
+
checksums: [],
|
|
26
|
+
snapshots: new Map(),
|
|
27
|
+
snapshotOrder: [],
|
|
28
|
+
};
|
|
29
|
+
let hydratedFromExternalSnapshot = false;
|
|
30
|
+
const session = {
|
|
31
|
+
get currentFrame() {
|
|
32
|
+
return mutable.frame;
|
|
33
|
+
},
|
|
34
|
+
schemaIdentity: {
|
|
35
|
+
inputSchemaId: config.inputSchemaId,
|
|
36
|
+
stateSchemaId: config.stateSchemaId,
|
|
37
|
+
deterministicVersion: config.deterministicVersion,
|
|
38
|
+
},
|
|
39
|
+
get accumulatedMs() {
|
|
40
|
+
return mutable.accumulatedMs;
|
|
41
|
+
},
|
|
42
|
+
setInput(slot, input) {
|
|
43
|
+
if (!Number.isInteger(slot) || slot < 0 || slot >= config.playerCount) {
|
|
44
|
+
throw new SyncplayError(`Input slot ${slot} is outside playerCount ${config.playerCount}`, {
|
|
45
|
+
code: 'session.invalid-slot',
|
|
46
|
+
frame: mutable.frame,
|
|
47
|
+
slot,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
mutable.inputs[slot] = cloneInput(config, input);
|
|
51
|
+
mutable.inputFlags[slot] = { ...mutable.inputFlags[slot], playerPresent: true, repeatedPrevious: false };
|
|
52
|
+
},
|
|
53
|
+
setInputFlags(slot, flags) {
|
|
54
|
+
if (!Number.isInteger(slot) || slot < 0 || slot >= config.playerCount) {
|
|
55
|
+
throw new SyncplayError(`Input flag slot ${slot} is outside playerCount ${config.playerCount}`, {
|
|
56
|
+
code: 'session.invalid-slot',
|
|
57
|
+
frame: mutable.frame,
|
|
58
|
+
slot,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
mutable.inputFlags[slot] = normalizeInputFlags(slot, { ...mutable.inputFlags[slot], ...flags });
|
|
62
|
+
},
|
|
63
|
+
recordCommands(commands) {
|
|
64
|
+
if (commands.length === 0) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const frameCommands = mutable.pendingCommandFrames.get(mutable.frame) ?? [];
|
|
68
|
+
for (const command of commands) {
|
|
69
|
+
frameCommands.push(canonicalize(config.canonicalizeCommand?.(command) ?? command));
|
|
70
|
+
}
|
|
71
|
+
mutable.pendingCommandFrames.set(mutable.frame, frameCommands);
|
|
72
|
+
},
|
|
73
|
+
update(deltaMs) {
|
|
74
|
+
if (!Number.isFinite(deltaMs) || deltaMs < 0) {
|
|
75
|
+
throw new SyncplayError('deltaMs must be a finite non-negative number', {
|
|
76
|
+
code: 'session.invalid-delta',
|
|
77
|
+
frame: mutable.frame,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const nextAccumulatedMs = mutable.accumulatedMs + deltaMs;
|
|
81
|
+
// The 1e-9 epsilon rescues products floating just under an integer, but
|
|
82
|
+
// can leave accumulatedMs a hair negative after the subtraction below —
|
|
83
|
+
// a subsequent near-zero delta would then floor to -1, skip a frame's
|
|
84
|
+
// worth of accumulator with no step, and break the >= 0 contract.
|
|
85
|
+
const framesToStep = Math.max(0, Math.floor((nextAccumulatedMs * config.tickRate + 1e-9) / 1000));
|
|
86
|
+
const maxCatchUpFrames = config.maxCatchUpFrames ?? 240;
|
|
87
|
+
if (framesToStep > maxCatchUpFrames) {
|
|
88
|
+
throw new SyncplayError(`deltaMs would require ${framesToStep} catch-up frames, above max ${maxCatchUpFrames}`, { code: 'session.catch-up-limit', frame: mutable.frame });
|
|
89
|
+
}
|
|
90
|
+
if (framesToStep === 0) {
|
|
91
|
+
mutable.accumulatedMs = nextAccumulatedMs;
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
mutable.accumulatedMs = nextAccumulatedMs;
|
|
95
|
+
runFrames(config, mutable, checksumInterval, snapshotBufferSize, replayRecordingMode, math, framesToStep);
|
|
96
|
+
mutable.accumulatedMs = Math.max(0, mutable.accumulatedMs - framesToStep * fixedFrameMs);
|
|
97
|
+
return framesToStep;
|
|
98
|
+
},
|
|
99
|
+
stepFrames(count) {
|
|
100
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
101
|
+
throw new SyncplayError('stepFrames count must be a non-negative integer', {
|
|
102
|
+
code: 'session.invalid-frame-count',
|
|
103
|
+
frame: mutable.frame,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
runFrames(config, mutable, checksumInterval, snapshotBufferSize, replayRecordingMode, math, count);
|
|
107
|
+
},
|
|
108
|
+
getState() {
|
|
109
|
+
return cloneCanonical(mutable.state);
|
|
110
|
+
},
|
|
111
|
+
exportReplay() {
|
|
112
|
+
if (hydratedFromExternalSnapshot) {
|
|
113
|
+
// A hydrated session's history starts at the snapshot frame, not tick 0 —
|
|
114
|
+
// exporting would silently produce an unverifiable replay.
|
|
115
|
+
throw new SyncplayError('Replay export requires a session that started from tick 0', {
|
|
116
|
+
code: 'session.replay-not-from-origin',
|
|
117
|
+
frame: mutable.frame,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (replayRecordingMode !== 'memory') {
|
|
121
|
+
throw new SyncplayError('Replay export requires replayRecordingMode="memory"', {
|
|
122
|
+
code: 'session.replay-not-retained',
|
|
123
|
+
frame: mutable.frame,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
formatVersion: 1,
|
|
128
|
+
deterministicVersion: config.deterministicVersion,
|
|
129
|
+
tickRate: config.tickRate,
|
|
130
|
+
playerCount: config.playerCount,
|
|
131
|
+
inputSchemaId: config.inputSchemaId,
|
|
132
|
+
stateSchemaId: config.stateSchemaId,
|
|
133
|
+
engineIdentityHash: config.engineIdentityHash ?? defaultChecksum({
|
|
134
|
+
deterministicVersion: config.deterministicVersion,
|
|
135
|
+
inputSchemaId: config.inputSchemaId,
|
|
136
|
+
stateSchemaId: config.stateSchemaId,
|
|
137
|
+
}),
|
|
138
|
+
assetDbHash: config.assetDbHash ?? defaultChecksum('asset-db:none'),
|
|
139
|
+
configHash: config.configHash ?? defaultChecksum({
|
|
140
|
+
tickRate: config.tickRate,
|
|
141
|
+
playerCount: config.playerCount,
|
|
142
|
+
checksumIntervalFrames: checksumInterval,
|
|
143
|
+
snapshotBufferSize,
|
|
144
|
+
}),
|
|
145
|
+
metadata: config.replayMetadata ?? { package: 'rundot-syncplay' },
|
|
146
|
+
randomSeed,
|
|
147
|
+
initialState: canonicalize(config.canonicalizeState?.(initialState) ?? initialState),
|
|
148
|
+
inputFrames: mutable.inputFrames.map((inputFrame) => cloneReplayRecord(inputFrame)),
|
|
149
|
+
commandHistory: mutable.commandFrames.map((commandFrame) => cloneReplayRecord(commandFrame)),
|
|
150
|
+
checksums: mutable.checksums.map((checksum) => cloneReplayRecord(checksum)),
|
|
151
|
+
debugEvents: [
|
|
152
|
+
...mutable.commandFrames.map((commandFrame) => ({
|
|
153
|
+
frame: commandFrame.frame,
|
|
154
|
+
type: 'commands',
|
|
155
|
+
payload: canonicalize({ count: commandFrame.commands.length }),
|
|
156
|
+
})),
|
|
157
|
+
...mutable.checksums.map((checksum) => ({
|
|
158
|
+
frame: checksum.frame,
|
|
159
|
+
type: 'checksum',
|
|
160
|
+
payload: canonicalize({ checksum: checksum.checksum }),
|
|
161
|
+
})),
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
getSnapshot(frame) {
|
|
166
|
+
const snapshot = mutable.snapshots.get(frame);
|
|
167
|
+
return snapshot === undefined ? undefined : cloneSnapshot(snapshot);
|
|
168
|
+
},
|
|
169
|
+
restoreToFrame(frame) {
|
|
170
|
+
const snapshot = mutable.snapshots.get(frame);
|
|
171
|
+
if (snapshot === undefined) {
|
|
172
|
+
throw new SyncplayError(`No snapshot exists for frame ${frame}`, {
|
|
173
|
+
code: 'session.missing-snapshot',
|
|
174
|
+
frame,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
mutable.frame = snapshot.frame;
|
|
178
|
+
mutable.state = cloneCanonical(snapshot.state);
|
|
179
|
+
mutable.inputs = snapshot.inputs.map((input) => cloneInput(config, input));
|
|
180
|
+
mutable.inputFlags = Array.from({ length: config.playerCount }, (_, slot) => defaultInputFlags(slot));
|
|
181
|
+
mutable.random.restore({ state: snapshot.randomState });
|
|
182
|
+
mutable.accumulatedMs = 0;
|
|
183
|
+
mutable.inputFrames = mutable.inputFrames.filter((inputFrame) => inputFrame.frame < frame);
|
|
184
|
+
mutable.commandFrames = mutable.commandFrames.filter((commandFrame) => commandFrame.frame < frame);
|
|
185
|
+
mutable.pendingCommandFrames = new Map([...mutable.pendingCommandFrames.entries()].filter(([pendingFrame]) => pendingFrame < frame));
|
|
186
|
+
mutable.checksums = mutable.checksums.filter((checksum) => checksum.frame <= frame);
|
|
187
|
+
pruneSnapshotsAfterFrame(mutable, frame);
|
|
188
|
+
},
|
|
189
|
+
restoreSnapshot(snapshot) {
|
|
190
|
+
invariant(Number.isInteger(snapshot.frame) && snapshot.frame >= 0, 'External snapshot frame must be a non-negative integer', 'session.invalid-snapshot-frame');
|
|
191
|
+
invariant(snapshot.inputs.length === config.playerCount, 'External snapshot inputs must cover every player slot', 'session.invalid-snapshot-inputs');
|
|
192
|
+
mutable.frame = snapshot.frame;
|
|
193
|
+
mutable.state = deserializeState(config, snapshot.stateBytes);
|
|
194
|
+
mutable.inputs = snapshot.inputs.map((input) => cloneInput(config, input));
|
|
195
|
+
mutable.inputFlags = Array.from({ length: config.playerCount }, (_, slot) => defaultInputFlags(slot));
|
|
196
|
+
mutable.random.restore({ state: snapshot.randomState });
|
|
197
|
+
mutable.accumulatedMs = 0;
|
|
198
|
+
mutable.inputFrames = [];
|
|
199
|
+
mutable.commandFrames = [];
|
|
200
|
+
mutable.pendingCommandFrames = new Map();
|
|
201
|
+
mutable.checksums = [];
|
|
202
|
+
mutable.snapshots = new Map();
|
|
203
|
+
mutable.snapshotOrder = [];
|
|
204
|
+
hydratedFromExternalSnapshot = true;
|
|
205
|
+
recordSnapshot(config, mutable, snapshotBufferSize);
|
|
206
|
+
},
|
|
207
|
+
getSnapshotFrames() {
|
|
208
|
+
return [...mutable.snapshotOrder];
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
recordSnapshot(config, mutable, snapshotBufferSize);
|
|
212
|
+
return session;
|
|
213
|
+
}
|
|
214
|
+
function runFrames(config, mutable, checksumInterval, snapshotBufferSize, replayRecordingMode, math, count) {
|
|
215
|
+
for (let index = 0; index < count; index += 1) {
|
|
216
|
+
const frame = mutable.frame;
|
|
217
|
+
const commandFrame = readPendingCommandFrame(mutable, frame);
|
|
218
|
+
const commands = commandFrame?.commands ?? [];
|
|
219
|
+
const inputFlags = mutable.inputFlags.map((flags, slot) => normalizeInputFlags(slot, {
|
|
220
|
+
...flags,
|
|
221
|
+
commandInput: commands.some((command) => commandTargetsSlot(command, slot)),
|
|
222
|
+
}));
|
|
223
|
+
const effectiveInputs = mutable.inputs.map((input, slot) => inputFlags[slot].playerPresent ? cloneInput(config, input) : cloneInput(config, config.defaultInput));
|
|
224
|
+
const inputFrame = {
|
|
225
|
+
frame,
|
|
226
|
+
inputs: effectiveInputs.map((input) => canonicalize(config.canonicalizeInput?.(input) ?? input)),
|
|
227
|
+
inputFlags,
|
|
228
|
+
};
|
|
229
|
+
config.onInputFrameRecorded?.(inputFrame);
|
|
230
|
+
if (replayRecordingMode === 'memory') {
|
|
231
|
+
mutable.inputFrames.push(inputFrame);
|
|
232
|
+
}
|
|
233
|
+
if (commandFrame !== undefined) {
|
|
234
|
+
config.onCommandFrameRecorded?.(commandFrame);
|
|
235
|
+
if (replayRecordingMode === 'memory') {
|
|
236
|
+
mutable.commandFrames.push(commandFrame);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const stepContext = {
|
|
240
|
+
frame,
|
|
241
|
+
dtTicks: 1,
|
|
242
|
+
random: mutable.random,
|
|
243
|
+
math,
|
|
244
|
+
commands,
|
|
245
|
+
};
|
|
246
|
+
mutable.state =
|
|
247
|
+
config.runtimeGuards === true
|
|
248
|
+
? withRuntimeDeterminismGuards(frame, () => config.step(mutable.state, effectiveInputs, frame, stepContext))
|
|
249
|
+
: config.step(mutable.state, effectiveInputs, frame, stepContext);
|
|
250
|
+
mutable.frame += 1;
|
|
251
|
+
const checksum = getChecksum(config, mutable.state);
|
|
252
|
+
if (mutable.frame % checksumInterval === 0) {
|
|
253
|
+
const checksumRecord = { frame: mutable.frame, checksum };
|
|
254
|
+
config.onChecksumRecorded?.(checksumRecord);
|
|
255
|
+
if (replayRecordingMode === 'memory') {
|
|
256
|
+
mutable.checksums.push(checksumRecord);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
recordSnapshot(config, mutable, snapshotBufferSize, checksum);
|
|
260
|
+
mutable.inputFlags = inputFlags.map((flags, slot) => ({ ...defaultInputFlags(slot), repeatedPrevious: flags.playerPresent }));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function readPendingCommandFrame(mutable, frame) {
|
|
264
|
+
const commands = mutable.pendingCommandFrames.get(frame);
|
|
265
|
+
mutable.pendingCommandFrames.delete(frame);
|
|
266
|
+
if (commands === undefined || commands.length === 0) {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
frame,
|
|
271
|
+
commands: commands.map((command) => cloneReplayRecord(command)),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function cloneReplayRecord(value) {
|
|
275
|
+
return JSON.parse(JSON.stringify(value));
|
|
276
|
+
}
|
|
277
|
+
function defaultInputFlags(slot) {
|
|
278
|
+
return {
|
|
279
|
+
slot,
|
|
280
|
+
playerPresent: true,
|
|
281
|
+
repeatedPrevious: false,
|
|
282
|
+
replacedByServer: false,
|
|
283
|
+
commandInput: false,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function normalizeInputFlags(slot, flags) {
|
|
287
|
+
return {
|
|
288
|
+
slot,
|
|
289
|
+
playerPresent: flags.playerPresent === true,
|
|
290
|
+
repeatedPrevious: flags.repeatedPrevious === true,
|
|
291
|
+
replacedByServer: flags.replacedByServer === true,
|
|
292
|
+
commandInput: flags.commandInput === true,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function commandTargetsSlot(command, slot) {
|
|
296
|
+
if (typeof command !== 'object' || command === null || Array.isArray(command)) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
const record = command;
|
|
300
|
+
return record.slot === slot;
|
|
301
|
+
}
|
|
302
|
+
function validateConfig(config) {
|
|
303
|
+
invariant([10, 20, 30, 60].includes(config.tickRate), 'tickRate must be one of 10, 20, 30, or 60', 'config.tick-rate');
|
|
304
|
+
invariant(Number.isInteger(config.playerCount) && config.playerCount >= 1 && config.playerCount <= 128, 'playerCount must be an integer between 1 and 128', 'config.player-count');
|
|
305
|
+
invariant(config.inputSchemaId.length > 0, 'inputSchemaId is required', 'config.input-schema');
|
|
306
|
+
invariant(config.stateSchemaId.length > 0, 'stateSchemaId is required', 'config.state-schema');
|
|
307
|
+
invariant(config.deterministicVersion.length > 0, 'deterministicVersion is required', 'config.version');
|
|
308
|
+
invariant(config.checksumIntervalFrames === undefined ||
|
|
309
|
+
(Number.isInteger(config.checksumIntervalFrames) && config.checksumIntervalFrames > 0), 'checksumIntervalFrames must be a positive integer', 'config.checksum-interval');
|
|
310
|
+
invariant(config.snapshotBufferSize === undefined ||
|
|
311
|
+
(Number.isInteger(config.snapshotBufferSize) && config.snapshotBufferSize >= 1), 'snapshotBufferSize must be a positive integer', 'config.snapshot-buffer');
|
|
312
|
+
}
|
|
313
|
+
function pruneSnapshotsAfterFrame(mutable, frame) {
|
|
314
|
+
mutable.snapshotOrder = mutable.snapshotOrder.filter((snapshotFrame) => {
|
|
315
|
+
if (snapshotFrame > frame) {
|
|
316
|
+
mutable.snapshots.delete(snapshotFrame);
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
function getChecksum(config, state) {
|
|
323
|
+
return String(config.checksum?.(state) ?? defaultChecksum(config.canonicalizeState?.(state) ?? state));
|
|
324
|
+
}
|
|
325
|
+
function cloneInput(config, input) {
|
|
326
|
+
return cloneCanonical(input);
|
|
327
|
+
}
|
|
328
|
+
function serializeState(config, state) {
|
|
329
|
+
return config.serializeState?.(state) ?? encodeCanonicalBytes(config.canonicalizeState?.(state) ?? state);
|
|
330
|
+
}
|
|
331
|
+
function deserializeState(config, bytes) {
|
|
332
|
+
return config.deserializeState?.(bytes) ?? decodeCanonicalBytes(bytes);
|
|
333
|
+
}
|
|
334
|
+
function recordSnapshot(config, mutable, snapshotBufferSize, checksum) {
|
|
335
|
+
const stateBytes = serializeState(config, mutable.state);
|
|
336
|
+
const snapshot = {
|
|
337
|
+
frame: mutable.frame,
|
|
338
|
+
inputs: mutable.inputs.map((input) => cloneInput(config, input)),
|
|
339
|
+
checksum: checksum ?? getChecksum(config, mutable.state),
|
|
340
|
+
state: deserializeState(config, stateBytes),
|
|
341
|
+
stateBytes,
|
|
342
|
+
randomState: mutable.random.snapshot().state,
|
|
343
|
+
};
|
|
344
|
+
const alreadyStored = mutable.snapshots.has(snapshot.frame);
|
|
345
|
+
mutable.snapshots.set(snapshot.frame, snapshot);
|
|
346
|
+
/* istanbul ignore else -- public session flow records at most one snapshot per frame. */
|
|
347
|
+
if (!alreadyStored) {
|
|
348
|
+
mutable.snapshotOrder.push(snapshot.frame);
|
|
349
|
+
}
|
|
350
|
+
while (mutable.snapshotOrder.length > snapshotBufferSize) {
|
|
351
|
+
const evictedFrame = mutable.snapshotOrder.shift();
|
|
352
|
+
/* istanbul ignore else -- shift cannot be undefined immediately after the length check above. */
|
|
353
|
+
if (evictedFrame !== undefined) {
|
|
354
|
+
mutable.snapshots.delete(evictedFrame);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function cloneSnapshot(snapshot) {
|
|
359
|
+
return {
|
|
360
|
+
frame: snapshot.frame,
|
|
361
|
+
inputs: snapshot.inputs.map((input) => cloneCanonical(input)),
|
|
362
|
+
checksum: snapshot.checksum,
|
|
363
|
+
state: cloneCanonical(snapshot.state),
|
|
364
|
+
stateBytes: new Uint8Array(snapshot.stateBytes),
|
|
365
|
+
randomState: snapshot.randomState,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { DeterministicStep } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Pause/resume as an explicit deterministic state machine.
|
|
4
|
+
*
|
|
5
|
+
* Pause must be deterministic, so it rides the input stream: the controller
|
|
6
|
+
* wraps a game's step so that a rising edge on the designated pause-toggle
|
|
7
|
+
* input transitions running → paused (and back). Ticks still advance while
|
|
8
|
+
* paused — the wrapped sim becomes identity except for pause bookkeeping — so
|
|
9
|
+
* every peer, replay, and rollback observes the exact same pause window.
|
|
10
|
+
*
|
|
11
|
+
* The machine's state lives INSIDE the wrapped game state (`state.pause`), so
|
|
12
|
+
* it snapshots, rolls back, and re-simulates for free with the rest of the
|
|
13
|
+
* session state. All fields are canonical values.
|
|
14
|
+
*
|
|
15
|
+
* Semantics per tick:
|
|
16
|
+
* - A slot toggles on the RISING edge of `isPauseToggle` (holding the button
|
|
17
|
+
* across ticks does not re-toggle).
|
|
18
|
+
* - Simultaneous rising edges accumulate: an odd number flips the status, an
|
|
19
|
+
* even number cancels out (two players pausing on the same tick is a no-op).
|
|
20
|
+
* - A tick that transitions to `paused` is already paused (inner step skipped);
|
|
21
|
+
* a tick that transitions to `running` already runs the inner step.
|
|
22
|
+
* - `pausedFrames` counts ticks the inner step was skipped; `toggleCount`
|
|
23
|
+
* counts every rising edge observed.
|
|
24
|
+
*/
|
|
25
|
+
export type DeterministicPauseStatus = 'running' | 'paused';
|
|
26
|
+
export interface DeterministicPauseState {
|
|
27
|
+
readonly status: DeterministicPauseStatus;
|
|
28
|
+
readonly pausedFrames: number;
|
|
29
|
+
readonly toggleCount: number;
|
|
30
|
+
/** Per-slot toggle-input level from the previous tick — the edge detector's memory. */
|
|
31
|
+
readonly toggleHeld: readonly boolean[];
|
|
32
|
+
}
|
|
33
|
+
export interface PauseWrappedState<GameState> {
|
|
34
|
+
readonly pause: DeterministicPauseState;
|
|
35
|
+
readonly game: GameState;
|
|
36
|
+
}
|
|
37
|
+
export interface PauseControllerOptions<GameState, Input> {
|
|
38
|
+
/** Whether this slot's input holds the pause toggle down this tick. */
|
|
39
|
+
readonly isPauseToggle: (input: Readonly<Input>, slot: number) => boolean;
|
|
40
|
+
/** The game step to wrap. */
|
|
41
|
+
readonly wrap: DeterministicStep<GameState, Input>;
|
|
42
|
+
}
|
|
43
|
+
export interface DeterministicPauseController<GameState, Input> {
|
|
44
|
+
wrapInitialState(gameState: GameState): PauseWrappedState<GameState>;
|
|
45
|
+
readonly step: DeterministicStep<PauseWrappedState<GameState>, Input>;
|
|
46
|
+
isPaused(state: Readonly<PauseWrappedState<GameState>>): boolean;
|
|
47
|
+
}
|
|
48
|
+
export declare function createPauseController<GameState, Input>(options: PauseControllerOptions<GameState, Input>): DeterministicPauseController<GameState, Input>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function createPauseController(options) {
|
|
2
|
+
const step = (state, inputs, frame, ctx) => {
|
|
3
|
+
const previousHeld = state.pause.toggleHeld;
|
|
4
|
+
let risingEdges = 0;
|
|
5
|
+
const toggleHeld = inputs.map((input, slot) => {
|
|
6
|
+
const held = options.isPauseToggle(input, slot) === true;
|
|
7
|
+
if (held && previousHeld[slot] !== true) {
|
|
8
|
+
risingEdges += 1;
|
|
9
|
+
}
|
|
10
|
+
return held;
|
|
11
|
+
});
|
|
12
|
+
const flips = risingEdges % 2 === 1;
|
|
13
|
+
const status = flips
|
|
14
|
+
? state.pause.status === 'running' ? 'paused' : 'running'
|
|
15
|
+
: state.pause.status;
|
|
16
|
+
const toggleCount = state.pause.toggleCount + risingEdges;
|
|
17
|
+
if (status === 'paused') {
|
|
18
|
+
return {
|
|
19
|
+
pause: { status, pausedFrames: state.pause.pausedFrames + 1, toggleCount, toggleHeld },
|
|
20
|
+
game: state.game,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
pause: { status, pausedFrames: state.pause.pausedFrames, toggleCount, toggleHeld },
|
|
25
|
+
game: options.wrap(state.game, inputs, frame, ctx),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
wrapInitialState(gameState) {
|
|
30
|
+
return {
|
|
31
|
+
pause: { status: 'running', pausedFrames: 0, toggleCount: 0, toggleHeld: [] },
|
|
32
|
+
game: gameState,
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
step,
|
|
36
|
+
isPaused(state) {
|
|
37
|
+
return state.pause.status === 'paused';
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type PhysicsCertEngine = 'builtin-fixed' | 'rapier2d-compat';
|
|
2
|
+
export type PhysicsCertShape = 'fixed-arena' | 'slot-load';
|
|
3
|
+
export interface PhysicsCertOptions {
|
|
4
|
+
readonly engine: PhysicsCertEngine;
|
|
5
|
+
readonly shape: PhysicsCertShape;
|
|
6
|
+
readonly players: number;
|
|
7
|
+
readonly frames: number;
|
|
8
|
+
readonly seed: number;
|
|
9
|
+
readonly snapshotEvery: number;
|
|
10
|
+
}
|
|
11
|
+
export interface PhysicsCertResult {
|
|
12
|
+
readonly engine: PhysicsCertEngine;
|
|
13
|
+
readonly engineVersion: string;
|
|
14
|
+
readonly adapterVersion: string;
|
|
15
|
+
readonly shape: PhysicsCertShape;
|
|
16
|
+
readonly players: number;
|
|
17
|
+
readonly bodies: number;
|
|
18
|
+
readonly frames: number;
|
|
19
|
+
readonly snapshotEvery: number;
|
|
20
|
+
readonly seed: number;
|
|
21
|
+
readonly uninterruptedChecksum: string;
|
|
22
|
+
readonly restoredChecksum: string;
|
|
23
|
+
readonly collisionEvents: number;
|
|
24
|
+
readonly collisionEventsStable: boolean;
|
|
25
|
+
readonly queryOrderStable: boolean;
|
|
26
|
+
readonly snapshotHashStable: boolean;
|
|
27
|
+
readonly finalRestoreStable: boolean;
|
|
28
|
+
readonly stepMsP95: string;
|
|
29
|
+
readonly stepMsMax: string;
|
|
30
|
+
readonly snapshotMsP95: string;
|
|
31
|
+
readonly restoreMsP95: string;
|
|
32
|
+
readonly snapshotBytesP95: number;
|
|
33
|
+
readonly heapUsedBytes: number;
|
|
34
|
+
readonly knownIssueRapier910: 'not-applicable' | 'not-reproduced' | 'reproduced';
|
|
35
|
+
readonly packageName?: string;
|
|
36
|
+
readonly packageImportPath?: string;
|
|
37
|
+
readonly result: 'pass' | 'fail';
|
|
38
|
+
}
|
|
39
|
+
export declare function runPhysicsCert(options: PhysicsCertOptions): PhysicsCertResult;
|
|
40
|
+
export declare function runRapierPhysicsCert(options: PhysicsCertOptions): Promise<PhysicsCertResult>;
|