1135 lines
29 KiB
TypeScript
1135 lines
29 KiB
TypeScript
import type { DefinedNetworkedGame } from "./define-networked-game.js";
|
|
import {
|
|
NetworkedAuthoritativeEngine,
|
|
type NetworkedServerStepResult,
|
|
type NetworkedSimulationCheckpoint,
|
|
} from "./networked-server.js";
|
|
import type { NetworkedGameDefinition, SnapshotBatch } from "./networked-types.js";
|
|
import type {
|
|
ClientStateReport,
|
|
InputPacket,
|
|
PlayerId,
|
|
StateSnapshot,
|
|
ValidationResult,
|
|
} from "./types.js";
|
|
import type { InputDecision, ServerEngineOptions } from "./server.js";
|
|
|
|
export type ReplayStateHash = string | number;
|
|
|
|
export type ReplayCommand<Input> =
|
|
| {
|
|
kind: "add-player";
|
|
atTick: number;
|
|
order: number;
|
|
playerId: PlayerId;
|
|
}
|
|
| {
|
|
kind: "remove-player";
|
|
atTick: number;
|
|
order: number;
|
|
playerId: PlayerId;
|
|
}
|
|
| {
|
|
kind: "input";
|
|
atTick: number;
|
|
order: number;
|
|
playerId: PlayerId;
|
|
packet: InputPacket<Input>;
|
|
};
|
|
|
|
export interface ReplayVerification<StateHash extends ReplayStateHash> {
|
|
tick: number;
|
|
hash: StateHash;
|
|
}
|
|
|
|
export interface ReplayCheckpoint<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
StateHash extends ReplayStateHash,
|
|
> {
|
|
tick: number;
|
|
commandCount: number;
|
|
hash: StateHash;
|
|
simulation: NetworkedSimulationCheckpoint<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent
|
|
>;
|
|
authorityEvents: AuthorityEvent[];
|
|
}
|
|
|
|
/**
|
|
* A recording contains private authority checkpoints and must remain on trusted
|
|
* infrastructure. Use ReplaySession.viewAs() for a privacy-filtered client view.
|
|
*/
|
|
export interface ReplayRecording<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
Seed,
|
|
StateHash extends ReplayStateHash,
|
|
> {
|
|
version: 1;
|
|
seed: Seed;
|
|
tickRateHz: number;
|
|
snapshotRateHz: number;
|
|
/** First seekable tick. Rolling recordings may start after tick zero. */
|
|
startTick: number;
|
|
durationTicks: number;
|
|
serverOptions: ServerEngineOptions;
|
|
commands: ReplayCommand<Input>[];
|
|
verifications: ReplayVerification<StateHash>[];
|
|
checkpoints: Array<
|
|
ReplayCheckpoint<AuthorityState, Input, AuthorityEvent, StateHash>
|
|
>;
|
|
}
|
|
|
|
export interface TimeTravelDefinition<
|
|
AuthorityState,
|
|
Seed,
|
|
StateHash extends ReplayStateHash,
|
|
> {
|
|
createSeed(): Seed;
|
|
initializeState?(state: AuthorityState, seed: Seed): void;
|
|
cloneSeed?(seed: Seed): Seed;
|
|
hashState(state: Readonly<AuthorityState>): StateHash;
|
|
checkpointIntervalTicks?: number;
|
|
verificationIntervalTicks?: number;
|
|
}
|
|
|
|
export interface TimeTravelServerOptions<Seed> extends ServerEngineOptions {
|
|
replaySeed?: Seed;
|
|
/**
|
|
* Retain only this many recent ticks in the private authority recording.
|
|
* The actual window can be up to one checkpoint interval larger.
|
|
*/
|
|
recordingHistoryTicks?: number;
|
|
}
|
|
|
|
export interface ReplayFrame<
|
|
AuthorityState,
|
|
AuthorityEvent,
|
|
StateHash extends ReplayStateHash,
|
|
> {
|
|
tick: number;
|
|
state: AuthorityState;
|
|
authorityEvents: AuthorityEvent[];
|
|
hash: StateHash;
|
|
}
|
|
|
|
export class ReplayDivergenceError<
|
|
StateHash extends ReplayStateHash,
|
|
> extends Error {
|
|
readonly tick: number;
|
|
readonly expected: StateHash;
|
|
readonly actual: StateHash;
|
|
|
|
constructor(tick: number, expected: StateHash, actual: StateHash) {
|
|
super(
|
|
`Replay diverged at tick ${tick}: expected ${String(expected)}, received ${String(actual)}`,
|
|
);
|
|
this.name = "ReplayDivergenceError";
|
|
this.tick = tick;
|
|
this.expected = expected;
|
|
this.actual = actual;
|
|
}
|
|
}
|
|
|
|
interface NormalizedTimeTravelDefinition<
|
|
AuthorityState,
|
|
Seed,
|
|
StateHash extends ReplayStateHash,
|
|
> extends TimeTravelDefinition<AuthorityState, Seed, StateHash> {
|
|
checkpointIntervalTicks: number;
|
|
verificationIntervalTicks: number;
|
|
}
|
|
|
|
interface ReplayTimeline<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
Seed,
|
|
StateHash extends ReplayStateHash,
|
|
> {
|
|
seed: Seed;
|
|
serverOptions: ServerEngineOptions;
|
|
commands: ReplayCommand<Input>[];
|
|
verifications: ReplayVerification<StateHash>[];
|
|
checkpoints: Array<
|
|
ReplayCheckpoint<AuthorityState, Input, AuthorityEvent, StateHash>
|
|
>;
|
|
}
|
|
|
|
export type TimeTravelNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
Seed,
|
|
StateHash extends ReplayStateHash,
|
|
> = Omit<
|
|
DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
"createServer"
|
|
> & {
|
|
createServer(
|
|
options?: TimeTravelServerOptions<Seed>,
|
|
): TimeTravelAuthoritativeEngine<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
Seed,
|
|
StateHash
|
|
>;
|
|
createReplay(
|
|
recording: ReplayRecording<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
Seed,
|
|
StateHash
|
|
>,
|
|
): ReplaySession<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
Seed,
|
|
StateHash
|
|
>;
|
|
};
|
|
|
|
export class TimeTravelAuthoritativeEngine<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
Seed,
|
|
StateHash extends ReplayStateHash,
|
|
> {
|
|
readonly game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>;
|
|
private readonly engine: NetworkedAuthoritativeEngine<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>;
|
|
private readonly timeTravel: NormalizedTimeTravelDefinition<
|
|
AuthorityState,
|
|
Seed,
|
|
StateHash
|
|
>;
|
|
private readonly seed: Seed;
|
|
private readonly serverOptions: ServerEngineOptions;
|
|
private readonly recordingHistoryTicks: number | null;
|
|
private commands: ReplayCommand<Input>[];
|
|
private verifications: ReplayVerification<StateHash>[];
|
|
private checkpoints: Array<
|
|
ReplayCheckpoint<AuthorityState, Input, AuthorityEvent, StateHash>
|
|
>;
|
|
private nextOrder: number;
|
|
|
|
constructor(
|
|
game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
engine: NetworkedAuthoritativeEngine<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
timeTravel: NormalizedTimeTravelDefinition<
|
|
AuthorityState,
|
|
Seed,
|
|
StateHash
|
|
>,
|
|
seed: Seed,
|
|
serverOptions: ServerEngineOptions,
|
|
recordingHistoryTicks: number | null,
|
|
timeline?: ReplayTimeline<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
Seed,
|
|
StateHash
|
|
>,
|
|
) {
|
|
this.game = game;
|
|
this.engine = engine;
|
|
this.timeTravel = timeTravel;
|
|
this.seed = cloneSeed(timeTravel, seed);
|
|
this.serverOptions = { ...serverOptions };
|
|
this.recordingHistoryTicks = recordingHistoryTicks;
|
|
|
|
if (timeline) {
|
|
this.commands = timeline.commands.map((command) =>
|
|
cloneCommand(game, command),
|
|
);
|
|
this.verifications = timeline.verifications.map((entry) => ({ ...entry }));
|
|
this.checkpoints = timeline.checkpoints.map((checkpoint) =>
|
|
cloneReplayCheckpoint(game, checkpoint),
|
|
);
|
|
this.nextOrder =
|
|
this.commands.reduce((maximum, command) => Math.max(maximum, command.order), -1) + 1;
|
|
} else {
|
|
this.commands = [];
|
|
const hash = timeTravel.hashState(engine.currentState);
|
|
this.verifications = [{ tick: engine.tick, hash }];
|
|
this.checkpoints = [
|
|
{
|
|
tick: engine.tick,
|
|
commandCount: 0,
|
|
hash,
|
|
simulation: engine.createSimulationCheckpoint(),
|
|
authorityEvents: [],
|
|
},
|
|
];
|
|
this.nextOrder = 0;
|
|
}
|
|
}
|
|
|
|
get tick(): number {
|
|
return this.engine.tick;
|
|
}
|
|
|
|
get currentState(): Readonly<AuthorityState> {
|
|
return this.engine.currentState;
|
|
}
|
|
|
|
get playerIds(): PlayerId[] {
|
|
return this.engine.playerIds;
|
|
}
|
|
|
|
addPlayer(playerId: PlayerId): void {
|
|
const existed = this.engine.playerIds.includes(playerId);
|
|
this.engine.addPlayer(playerId);
|
|
if (!existed && this.engine.playerIds.includes(playerId)) {
|
|
this.recordCommand({
|
|
kind: "add-player",
|
|
atTick: this.tick,
|
|
order: this.nextOrder,
|
|
playerId,
|
|
});
|
|
}
|
|
}
|
|
|
|
removePlayer(playerId: PlayerId): void {
|
|
const existed = this.engine.playerIds.includes(playerId);
|
|
this.engine.removePlayer(playerId);
|
|
if (existed && !this.engine.playerIds.includes(playerId)) {
|
|
this.recordCommand({
|
|
kind: "remove-player",
|
|
atTick: this.tick,
|
|
order: this.nextOrder,
|
|
playerId,
|
|
});
|
|
}
|
|
}
|
|
|
|
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision {
|
|
const decision = this.engine.submitInput(playerId, packet);
|
|
if (decision.accepted) {
|
|
this.recordCommand({
|
|
kind: "input",
|
|
atTick: this.tick,
|
|
order: this.nextOrder,
|
|
playerId,
|
|
packet: cloneInputPacket(this.game, packet),
|
|
});
|
|
}
|
|
return decision;
|
|
}
|
|
|
|
submitStateReport(
|
|
playerId: PlayerId,
|
|
report: ClientStateReport<ClientState>,
|
|
): ValidationResult | null {
|
|
return this.engine.submitStateReport(playerId, report);
|
|
}
|
|
|
|
step(): NetworkedServerStepResult<AuthorityEvent> {
|
|
const result = this.engine.step();
|
|
const hash = this.timeTravel.hashState(this.engine.currentState);
|
|
|
|
if (result.tick % this.timeTravel.verificationIntervalTicks === 0) {
|
|
this.verifications.push({ tick: result.tick, hash });
|
|
}
|
|
|
|
if (result.tick % this.timeTravel.checkpointIntervalTicks === 0) {
|
|
this.checkpoints.push({
|
|
tick: result.tick,
|
|
commandCount: this.commands.length,
|
|
hash,
|
|
simulation: this.engine.createSimulationCheckpoint(),
|
|
authorityEvents: [...result.events],
|
|
});
|
|
}
|
|
|
|
this.pruneRecording();
|
|
|
|
return result;
|
|
}
|
|
|
|
createSnapshot(
|
|
playerId: PlayerId,
|
|
serverTime: number,
|
|
): StateSnapshot<ClientState> {
|
|
return this.engine.createSnapshot(playerId, serverTime);
|
|
}
|
|
|
|
createSnapshotBatches(serverTime: number): SnapshotBatch<ClientState>[] {
|
|
return this.engine.createSnapshotBatches(serverTime);
|
|
}
|
|
|
|
createPerceptions(
|
|
playerId: PlayerId,
|
|
events: readonly AuthorityEvent[],
|
|
): PerceptionEvent[] {
|
|
return this.engine.createPerceptions(playerId, events);
|
|
}
|
|
|
|
exportRecording(): ReplayRecording<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
Seed,
|
|
StateHash
|
|
> {
|
|
return cloneRecording(this.game, this.timeTravel, {
|
|
version: 1,
|
|
seed: this.seed,
|
|
tickRateHz: this.game.tickRateHz,
|
|
snapshotRateHz: this.game.snapshotRateHz,
|
|
startTick: this.checkpoints[0]?.tick ?? this.tick,
|
|
durationTicks: this.tick,
|
|
serverOptions: this.serverOptions,
|
|
commands: this.commands,
|
|
verifications: this.verifications,
|
|
checkpoints: this.checkpoints,
|
|
});
|
|
}
|
|
|
|
private recordCommand(command: ReplayCommand<Input>): void {
|
|
this.commands.push(cloneCommand(this.game, command));
|
|
this.nextOrder += 1;
|
|
}
|
|
|
|
private pruneRecording(): void {
|
|
if (this.recordingHistoryTicks === null) return;
|
|
const cutoff = this.tick - this.recordingHistoryTicks;
|
|
if (cutoff <= 0 || this.checkpoints.length < 2) return;
|
|
|
|
let baselineIndex = 0;
|
|
for (let index = 1; index < this.checkpoints.length; index += 1) {
|
|
const checkpoint = this.checkpoints[index]!;
|
|
if (checkpoint.tick > cutoff) break;
|
|
baselineIndex = index;
|
|
}
|
|
if (baselineIndex === 0) return;
|
|
|
|
const baseline = this.checkpoints[baselineIndex]!;
|
|
const removedCommandCount = baseline.commandCount;
|
|
this.commands = this.commands.slice(removedCommandCount);
|
|
this.verifications = this.verifications.filter(
|
|
({ tick }) => tick >= baseline.tick,
|
|
);
|
|
this.checkpoints = this.checkpoints.slice(baselineIndex).map(
|
|
(checkpoint) => ({
|
|
...checkpoint,
|
|
commandCount: checkpoint.commandCount - removedCommandCount,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
export class ReplaySession<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
Seed,
|
|
StateHash extends ReplayStateHash,
|
|
> {
|
|
private readonly game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>;
|
|
private readonly timeTravel: NormalizedTimeTravelDefinition<
|
|
AuthorityState,
|
|
Seed,
|
|
StateHash
|
|
>;
|
|
private readonly recording: ReplayRecording<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
Seed,
|
|
StateHash
|
|
>;
|
|
private readonly verifications: Map<number, StateHash>;
|
|
private engine: NetworkedAuthoritativeEngine<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>;
|
|
private commandIndex = 0;
|
|
private latestAuthorityEvents: AuthorityEvent[] = [];
|
|
|
|
constructor(
|
|
game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
timeTravel: NormalizedTimeTravelDefinition<
|
|
AuthorityState,
|
|
Seed,
|
|
StateHash
|
|
>,
|
|
recording: ReplayRecording<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
Seed,
|
|
StateHash
|
|
>,
|
|
) {
|
|
validateRecording(game, recording);
|
|
this.game = game;
|
|
this.timeTravel = timeTravel;
|
|
this.recording = cloneRecording(game, timeTravel, recording);
|
|
this.verifications = new Map(
|
|
this.recording.verifications.map(({ tick, hash }) => [tick, hash]),
|
|
);
|
|
this.engine = createSeededEngine(
|
|
game,
|
|
timeTravel,
|
|
this.recording.seed,
|
|
this.recording.serverOptions,
|
|
);
|
|
this.seek(this.recording.startTick);
|
|
}
|
|
|
|
get tick(): number {
|
|
return this.engine.tick;
|
|
}
|
|
|
|
get durationTicks(): number {
|
|
return this.recording.durationTicks;
|
|
}
|
|
|
|
get startTick(): number {
|
|
return this.recording.startTick;
|
|
}
|
|
|
|
get currentState(): Readonly<AuthorityState> {
|
|
return this.engine.currentState;
|
|
}
|
|
|
|
seek(
|
|
targetTick: number,
|
|
): ReplayFrame<AuthorityState, AuthorityEvent, StateHash> {
|
|
if (
|
|
!Number.isInteger(targetTick) ||
|
|
targetTick < this.recording.startTick ||
|
|
targetTick > this.recording.durationTicks
|
|
) {
|
|
throw new RangeError(
|
|
`target tick must be between ${this.recording.startTick} and ${this.recording.durationTicks}`,
|
|
);
|
|
}
|
|
|
|
const checkpoint = this.findCheckpoint(targetTick);
|
|
this.engine = createSeededEngine(
|
|
this.game,
|
|
this.timeTravel,
|
|
this.recording.seed,
|
|
this.recording.serverOptions,
|
|
);
|
|
this.engine.restoreSimulationCheckpoint(checkpoint.simulation);
|
|
this.commandIndex = checkpoint.commandCount;
|
|
this.latestAuthorityEvents = [...checkpoint.authorityEvents];
|
|
this.assertHash(checkpoint.tick, checkpoint.hash);
|
|
this.applyCommandsAtCurrentTick();
|
|
|
|
while (this.engine.tick < targetTick) {
|
|
const result = this.engine.step();
|
|
this.latestAuthorityEvents = [...result.events];
|
|
const expected = this.verifications.get(result.tick);
|
|
if (expected !== undefined) this.assertHash(result.tick, expected);
|
|
this.applyCommandsAtCurrentTick();
|
|
}
|
|
|
|
return this.frame();
|
|
}
|
|
|
|
frame(): ReplayFrame<AuthorityState, AuthorityEvent, StateHash> {
|
|
return {
|
|
tick: this.engine.tick,
|
|
state: this.game.server.cloneState(this.engine.currentState),
|
|
authorityEvents: [...this.latestAuthorityEvents],
|
|
hash: this.timeTravel.hashState(this.engine.currentState),
|
|
};
|
|
}
|
|
|
|
viewAs(
|
|
playerId: PlayerId,
|
|
serverTime = 0,
|
|
): StateSnapshot<ClientState> {
|
|
if (!this.engine.playerIds.includes(playerId)) {
|
|
throw new RangeError(`player ${playerId} does not exist at tick ${this.tick}`);
|
|
}
|
|
return this.engine.createSnapshot(playerId, serverTime);
|
|
}
|
|
|
|
perceptionsAs(playerId: PlayerId): PerceptionEvent[] {
|
|
if (!this.engine.playerIds.includes(playerId)) {
|
|
throw new RangeError(`player ${playerId} does not exist at tick ${this.tick}`);
|
|
}
|
|
return this.engine.createPerceptions(playerId, this.latestAuthorityEvents);
|
|
}
|
|
|
|
branch(
|
|
atTick = this.tick,
|
|
): TimeTravelAuthoritativeEngine<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
Seed,
|
|
StateHash
|
|
> {
|
|
this.seek(atTick);
|
|
const branchCheckpoint: ReplayCheckpoint<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
StateHash
|
|
> = {
|
|
tick: atTick,
|
|
commandCount: this.commandIndex,
|
|
hash: this.timeTravel.hashState(this.engine.currentState),
|
|
simulation: this.engine.createSimulationCheckpoint(),
|
|
authorityEvents: [...this.latestAuthorityEvents],
|
|
};
|
|
const branchEngine = createSeededEngine(
|
|
this.game,
|
|
this.timeTravel,
|
|
this.recording.seed,
|
|
this.recording.serverOptions,
|
|
);
|
|
branchEngine.restoreSimulationCheckpoint(branchCheckpoint.simulation);
|
|
|
|
return new TimeTravelAuthoritativeEngine(
|
|
this.game,
|
|
branchEngine,
|
|
this.timeTravel,
|
|
this.recording.seed,
|
|
this.recording.serverOptions,
|
|
null,
|
|
{
|
|
seed: this.recording.seed,
|
|
serverOptions: this.recording.serverOptions,
|
|
commands: this.recording.commands.slice(0, this.commandIndex),
|
|
verifications: this.recording.verifications.filter(
|
|
({ tick }) => tick <= atTick,
|
|
),
|
|
checkpoints: [
|
|
...this.recording.checkpoints.filter(({ tick }) => tick < atTick),
|
|
branchCheckpoint,
|
|
],
|
|
},
|
|
);
|
|
}
|
|
|
|
private findCheckpoint(
|
|
targetTick: number,
|
|
): ReplayCheckpoint<AuthorityState, Input, AuthorityEvent, StateHash> {
|
|
let selected = this.recording.checkpoints[0];
|
|
if (!selected) throw new Error("Replay has no initial checkpoint");
|
|
|
|
for (const checkpoint of this.recording.checkpoints) {
|
|
if (checkpoint.tick <= targetTick && checkpoint.tick >= selected.tick) {
|
|
selected = checkpoint;
|
|
}
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
private applyCommandsAtCurrentTick(): void {
|
|
while (this.commandIndex < this.recording.commands.length) {
|
|
const command = this.recording.commands[this.commandIndex]!;
|
|
if (command.atTick > this.engine.tick) return;
|
|
if (command.atTick < this.engine.tick) {
|
|
throw new Error(
|
|
`Replay command ${command.order} at tick ${command.atTick} is behind playback tick ${this.engine.tick}`,
|
|
);
|
|
}
|
|
|
|
switch (command.kind) {
|
|
case "add-player":
|
|
this.engine.addPlayer(command.playerId);
|
|
break;
|
|
case "remove-player":
|
|
this.engine.removePlayer(command.playerId);
|
|
break;
|
|
case "input": {
|
|
const decision = this.engine.submitInput(command.playerId, command.packet);
|
|
if (!decision.accepted) {
|
|
throw new Error(
|
|
`Recorded input ${command.packet.sequence} was rejected during replay: ${decision.reason ?? "unknown"}`,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
this.commandIndex += 1;
|
|
}
|
|
}
|
|
|
|
private assertHash(tick: number, expected: StateHash): void {
|
|
const actual = this.timeTravel.hashState(this.engine.currentState);
|
|
if (!Object.is(expected, actual)) {
|
|
throw new ReplayDivergenceError(tick, expected, actual);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function withTimeTravel<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
Seed,
|
|
StateHash extends ReplayStateHash,
|
|
>(
|
|
game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
definition: TimeTravelDefinition<AuthorityState, Seed, StateHash>,
|
|
): TimeTravelNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
Seed,
|
|
StateHash
|
|
> {
|
|
const timeTravel = normalizeDefinition(game.tickRateHz, definition);
|
|
|
|
return Object.freeze({
|
|
...game,
|
|
createServer(options: TimeTravelServerOptions<Seed> = {}) {
|
|
const {
|
|
replaySeed,
|
|
recordingHistoryTicks: requestedRecordingHistoryTicks,
|
|
...serverOptions
|
|
} = options;
|
|
const recordingHistoryTicks = normalizeRecordingHistoryTicks(
|
|
requestedRecordingHistoryTicks,
|
|
);
|
|
const seed = replaySeed === undefined
|
|
? timeTravel.createSeed()
|
|
: replaySeed;
|
|
const engine = createSeededEngine(
|
|
game,
|
|
timeTravel,
|
|
seed,
|
|
serverOptions,
|
|
);
|
|
return new TimeTravelAuthoritativeEngine(
|
|
game,
|
|
engine,
|
|
timeTravel,
|
|
seed,
|
|
serverOptions,
|
|
recordingHistoryTicks,
|
|
);
|
|
},
|
|
createReplay(
|
|
recording: ReplayRecording<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
Seed,
|
|
StateHash
|
|
>,
|
|
) {
|
|
return new ReplaySession(game, timeTravel, recording);
|
|
},
|
|
});
|
|
}
|
|
|
|
function normalizeDefinition<AuthorityState, Seed, StateHash extends ReplayStateHash>(
|
|
tickRateHz: number,
|
|
definition: TimeTravelDefinition<AuthorityState, Seed, StateHash>,
|
|
): NormalizedTimeTravelDefinition<AuthorityState, Seed, StateHash> {
|
|
const checkpointIntervalTicks =
|
|
definition.checkpointIntervalTicks ?? tickRateHz * 2;
|
|
const verificationIntervalTicks = definition.verificationIntervalTicks ?? 1;
|
|
|
|
for (const [name, value] of [
|
|
["checkpointIntervalTicks", checkpointIntervalTicks],
|
|
["verificationIntervalTicks", verificationIntervalTicks],
|
|
] as const) {
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
throw new RangeError(`${name} must be a positive integer`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...definition,
|
|
checkpointIntervalTicks,
|
|
verificationIntervalTicks,
|
|
};
|
|
}
|
|
|
|
function normalizeRecordingHistoryTicks(value: number | undefined): number | null {
|
|
if (value === undefined) return null;
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
throw new RangeError("recordingHistoryTicks must be a positive integer");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function createSeededEngine<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
Seed,
|
|
StateHash extends ReplayStateHash,
|
|
>(
|
|
game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
timeTravel: NormalizedTimeTravelDefinition<AuthorityState, Seed, StateHash>,
|
|
seed: Seed,
|
|
serverOptions: ServerEngineOptions,
|
|
): NetworkedAuthoritativeEngine<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
> {
|
|
const definition: NetworkedGameDefinition<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
> = {
|
|
tickRateHz: game.tickRateHz,
|
|
snapshotRateHz: game.snapshotRateHz,
|
|
server: {
|
|
...game.server,
|
|
createInitialState() {
|
|
const state = game.server.createInitialState();
|
|
timeTravel.initializeState?.(state, cloneSeed(timeTravel, seed));
|
|
return state;
|
|
},
|
|
},
|
|
client: game.client,
|
|
replication: game.replication,
|
|
validateInput: game.validateInput,
|
|
...(game.inputStream ? { inputStream: game.inputStream } : {}),
|
|
codecs: game.codecs,
|
|
};
|
|
return new NetworkedAuthoritativeEngine(definition, serverOptions);
|
|
}
|
|
|
|
function cloneSeed<AuthorityState, Seed, StateHash extends ReplayStateHash>(
|
|
timeTravel: TimeTravelDefinition<AuthorityState, Seed, StateHash>,
|
|
seed: Seed,
|
|
): Seed {
|
|
return timeTravel.cloneSeed ? timeTravel.cloneSeed(seed) : seed;
|
|
}
|
|
|
|
function cloneInputPacket<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
>(
|
|
game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
packet: InputPacket<Input>,
|
|
): InputPacket<Input> {
|
|
const encoded = game.codecs.input.encode(packet.input);
|
|
return {
|
|
sequence: packet.sequence,
|
|
targetTick: packet.targetTick,
|
|
observedTick: packet.observedTick ?? packet.targetTick,
|
|
input: game.codecs.input.decode(encoded),
|
|
};
|
|
}
|
|
|
|
function cloneCommand<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
>(
|
|
game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
command: ReplayCommand<Input>,
|
|
): ReplayCommand<Input> {
|
|
return command.kind === "input"
|
|
? { ...command, packet: cloneInputPacket(game, command.packet) }
|
|
: { ...command };
|
|
}
|
|
|
|
function cloneSimulationCheckpoint<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
>(
|
|
game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
checkpoint: NetworkedSimulationCheckpoint<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent
|
|
>,
|
|
): NetworkedSimulationCheckpoint<AuthorityState, Input, AuthorityEvent> {
|
|
return {
|
|
tick: checkpoint.tick,
|
|
state: game.server.cloneState(checkpoint.state),
|
|
playerIds: [...checkpoint.playerIds],
|
|
lastReceivedSequences: checkpoint.lastReceivedSequences.map(
|
|
([playerId, sequence]) => [playerId, sequence] as const,
|
|
),
|
|
acknowledgedSequences: checkpoint.acknowledgedSequences.map(
|
|
([playerId, sequence]) => [playerId, sequence] as const,
|
|
),
|
|
queuedInputs: checkpoint.queuedInputs.map(({ playerId, packet }) => ({
|
|
playerId,
|
|
packet: cloneInputPacket(game, packet),
|
|
})),
|
|
inputStreamStates: checkpoint.inputStreamStates.map((stream) => ({
|
|
...stream,
|
|
clientInput: game.codecs.input.decode(
|
|
game.codecs.input.encode(stream.clientInput),
|
|
),
|
|
appliedInput: game.codecs.input.decode(
|
|
game.codecs.input.encode(stream.appliedInput),
|
|
),
|
|
})),
|
|
pendingEvents: [...checkpoint.pendingEvents],
|
|
};
|
|
}
|
|
|
|
function cloneReplayCheckpoint<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
StateHash extends ReplayStateHash,
|
|
>(
|
|
game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
checkpoint: ReplayCheckpoint<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
StateHash
|
|
>,
|
|
): ReplayCheckpoint<AuthorityState, Input, AuthorityEvent, StateHash> {
|
|
return {
|
|
tick: checkpoint.tick,
|
|
commandCount: checkpoint.commandCount,
|
|
hash: checkpoint.hash,
|
|
simulation: cloneSimulationCheckpoint(game, checkpoint.simulation),
|
|
authorityEvents: [...checkpoint.authorityEvents],
|
|
};
|
|
}
|
|
|
|
function cloneRecording<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
Seed,
|
|
StateHash extends ReplayStateHash,
|
|
>(
|
|
game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
timeTravel: TimeTravelDefinition<AuthorityState, Seed, StateHash>,
|
|
recording: ReplayRecording<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
Seed,
|
|
StateHash
|
|
>,
|
|
): ReplayRecording<AuthorityState, Input, AuthorityEvent, Seed, StateHash> {
|
|
return {
|
|
version: 1,
|
|
seed: cloneSeed(timeTravel, recording.seed),
|
|
tickRateHz: recording.tickRateHz,
|
|
snapshotRateHz: recording.snapshotRateHz,
|
|
startTick: recording.startTick,
|
|
durationTicks: recording.durationTicks,
|
|
serverOptions: { ...recording.serverOptions },
|
|
commands: recording.commands.map((command) => cloneCommand(game, command)),
|
|
verifications: recording.verifications.map((entry) => ({ ...entry })),
|
|
checkpoints: recording.checkpoints.map((checkpoint) =>
|
|
cloneReplayCheckpoint(game, checkpoint),
|
|
),
|
|
};
|
|
}
|
|
|
|
function validateRecording<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
Seed,
|
|
StateHash extends ReplayStateHash,
|
|
>(
|
|
game: DefinedNetworkedGame<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
recording: ReplayRecording<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
Seed,
|
|
StateHash
|
|
>,
|
|
): void {
|
|
if (recording.version !== 1) throw new RangeError("Unsupported replay version");
|
|
if (
|
|
recording.tickRateHz !== game.tickRateHz ||
|
|
recording.snapshotRateHz !== game.snapshotRateHz
|
|
) {
|
|
throw new RangeError("Replay tick rates do not match this game");
|
|
}
|
|
if (!Number.isInteger(recording.durationTicks) || recording.durationTicks < 0) {
|
|
throw new RangeError("Replay duration must be a non-negative integer");
|
|
}
|
|
if (
|
|
!Number.isInteger(recording.startTick) ||
|
|
recording.startTick < 0 ||
|
|
recording.startTick > recording.durationTicks
|
|
) {
|
|
throw new RangeError("Replay start tick must be in the recorded range");
|
|
}
|
|
if (
|
|
recording.checkpoints.length === 0 ||
|
|
recording.checkpoints[0]?.tick !== recording.startTick
|
|
) {
|
|
throw new RangeError("Replay must begin with a checkpoint at startTick");
|
|
}
|
|
|
|
let previousOrder = -1;
|
|
let previousTick = recording.startTick;
|
|
for (const command of recording.commands) {
|
|
if (
|
|
!Number.isInteger(command.atTick) ||
|
|
command.atTick < previousTick ||
|
|
command.atTick < recording.startTick ||
|
|
command.atTick > recording.durationTicks ||
|
|
!Number.isInteger(command.order) ||
|
|
command.order <= previousOrder
|
|
) {
|
|
throw new RangeError("Replay commands are not in canonical order");
|
|
}
|
|
previousTick = command.atTick;
|
|
previousOrder = command.order;
|
|
}
|
|
|
|
for (const checkpoint of recording.checkpoints) {
|
|
if (
|
|
checkpoint.tick !== checkpoint.simulation.tick ||
|
|
checkpoint.tick < 0 ||
|
|
checkpoint.tick > recording.durationTicks ||
|
|
checkpoint.commandCount < 0 ||
|
|
checkpoint.commandCount > recording.commands.length
|
|
) {
|
|
throw new RangeError("Replay contains an invalid checkpoint");
|
|
}
|
|
}
|
|
}
|