import type { DefinedNetworkedGame } from "./define-networked-game.js"; import type { NetworkedServerStepResult } from "./networked-server.js"; import type { SnapshotBatch } from "./networked-types.js"; import type { InputDecision } from "./server.js"; import { type ReplayRecording, type ReplayStateHash, type TimeTravelAuthoritativeEngine, type TimeTravelNetworkedGame, type TimeTravelServerOptions, } from "./time-travel.js"; import type { ClientStateReport, InputPacket, PlayerId, StateSnapshot, ValidationResult, } from "./types.js"; export interface ProjectedReplayFrame { tick: number; state: ClientState; events: PerceptionEvent[]; } export interface ReplayTicket { ticketId: number; requesterId: PlayerId; perspectiveId: PlayerId; issuedAtTick: number; fromTick: number; toTick: number; playbackRate: number; frames: Array>; } export interface ReplayTicketPlan { requesterId: PlayerId; perspectiveId: PlayerId; fromTick: number; toTick: number; playbackRate?: number; } export interface ReplayTriggerContext { tick: number; authorityState: Readonly; connectedPlayerIds: readonly PlayerId[]; } export interface ReplayAuthorizationContext extends ReplayTicketPlan { currentTick: number; authorityState: Readonly; connectedPlayerIds: readonly PlayerId[]; } export interface ReplayTransportDefinition { /** Maximum age of the projected frame ring and private deterministic log. */ historySeconds: number; /** Baseline frame rate. Event ticks are captured even between baseline frames. */ captureRateHz?: number; /** Perspectives worth retaining. Defaults to connected network players. */ listPerspectives?( authorityState: Readonly, connectedPlayerIds: readonly PlayerId[], ): Iterable; /** Trusted server hook that can create automatic tickets from authority events. */ createTickets?( event: Readonly, context: ReplayTriggerContext, ): ReplayTicketPlan | readonly ReplayTicketPlan[] | null; /** Every automatic or manual ticket passes through this policy. */ authorizeReplay?( context: ReplayAuthorizationContext, ): boolean; maxPendingTicketsPerPlayer?: number; } interface NormalizedReplayTransportDefinition extends ReplayTransportDefinition { historyTicks: number; captureEveryTicks: number; maxPendingTicketsPerPlayer: number; } export type ReplayTransportNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash extends ReplayStateHash, > = Omit< TimeTravelNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash >, "createServer" > & { createServer( options?: TimeTravelServerOptions, ): ReplayTransportAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash >; }; /** * Keeps client-safe historical projections around a private authoritative * engine. Raw authority checkpoints never cross this boundary. */ export class ReplayTransportAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash extends ReplayStateHash, > { readonly game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >; private readonly engine: TimeTravelAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash >; private readonly replay: NormalizedReplayTransportDefinition< AuthorityState, AuthorityEvent >; private readonly history = new Map< PlayerId, Array> >(); private readonly pendingTickets = new Map< PlayerId, Array> >(); private nextTicketId = 1; constructor( game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, engine: TimeTravelAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash >, replay: NormalizedReplayTransportDefinition< AuthorityState, AuthorityEvent >, ) { this.game = game; this.engine = engine; this.replay = replay; } get tick(): number { return this.engine.tick; } get currentState(): Readonly { return this.engine.currentState; } get playerIds(): PlayerId[] { return this.engine.playerIds; } addPlayer(playerId: PlayerId): void { this.engine.addPlayer(playerId); this.capture([], true); } removePlayer(playerId: PlayerId): void { this.engine.removePlayer(playerId); this.pendingTickets.delete(playerId); this.capture([], true); } submitInput(playerId: PlayerId, packet: InputPacket): InputDecision { return this.engine.submitInput(playerId, packet); } submitStateReport( playerId: PlayerId, report: ClientStateReport, ): ValidationResult | null { return this.engine.submitStateReport(playerId, report); } step(): NetworkedServerStepResult { const result = this.engine.step(); const shouldCapture = result.tick % this.replay.captureEveryTicks === 0 || result.events.length > 0; if (shouldCapture) this.capture(result.events, true); this.pruneHistory(); if (this.replay.createTickets) { const context: ReplayTriggerContext = { tick: result.tick, authorityState: this.currentState, connectedPlayerIds: this.playerIds, }; for (const event of result.events) { const plans = this.replay.createTickets(event, context); if (!plans) continue; for (const plan of Array.isArray(plans) ? plans : [plans]) { this.issueReplay(plan); } } } return result; } createSnapshot( playerId: PlayerId, serverTime: number, ): StateSnapshot { return this.engine.createSnapshot(playerId, serverTime); } createSnapshotBatches(serverTime: number): SnapshotBatch[] { 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 this.engine.exportRecording(); } /** Trusted-server API. The configured authorization policy still applies. */ issueReplay( plan: ReplayTicketPlan, ): ReplayTicket | null { if (!this.playerIds.includes(plan.requesterId)) return null; if (!validTick(plan.fromTick) || !validTick(plan.toTick)) return null; if (plan.toTick < plan.fromTick || plan.toTick > this.tick) return null; const authorization: ReplayAuthorizationContext = { ...plan, currentTick: this.tick, authorityState: this.currentState, connectedPlayerIds: this.playerIds, }; const authorized = this.replay.authorizeReplay ? this.replay.authorizeReplay(authorization) : plan.requesterId === plan.perspectiveId; if (!authorized) return null; const perspectiveHistory = this.history.get(plan.perspectiveId); if (!perspectiveHistory) return null; const selected = perspectiveHistory.filter( ({ tick }) => tick >= plan.fromTick && tick <= plan.toTick, ); if (selected.length === 0) return null; const first = selected[0]!; const last = selected[selected.length - 1]!; const ticket: ReplayTicket = { ticketId: this.allocateTicketId(), requesterId: plan.requesterId, perspectiveId: plan.perspectiveId, issuedAtTick: this.tick, fromTick: first.tick, toTick: last.tick, playbackRate: clamp(plan.playbackRate ?? 1, 0.1, 4), frames: selected.map((frame) => this.cloneFrame(frame)), }; const queue = this.pendingTickets.get(plan.requesterId) ?? []; queue.push(ticket); while (queue.length > this.replay.maxPendingTicketsPerPlayer) queue.shift(); this.pendingTickets.set(plan.requesterId, queue); return ticket; } drainReplayTickets( playerId: PlayerId, ): Array> { const tickets = this.pendingTickets.get(playerId) ?? []; this.pendingTickets.delete(playerId); return tickets; } private capture(events: readonly AuthorityEvent[], replaceSameTick: boolean): void { const connected = this.playerIds; const perspectives = this.replay.listPerspectives ? this.replay.listPerspectives(this.currentState, connected) : connected; const uniquePerspectives = new Set(perspectives); for (const perspectiveId of uniquePerspectives) { if (!validPlayerId(perspectiveId)) continue; const snapshot = this.engine.createSnapshot(perspectiveId, 0); const perceptions = this.engine.createPerceptions(perspectiveId, events); const frame: ProjectedReplayFrame = { tick: this.tick, state: this.cloneState(snapshot.state), events: perceptions.map((event) => this.cloneEvent(event)), }; const frames = this.history.get(perspectiveId) ?? []; if (replaceSameTick && frames.at(-1)?.tick === this.tick) { frames[frames.length - 1] = frame; } else { frames.push(frame); } this.history.set(perspectiveId, frames); } } private pruneHistory(): void { const earliestTick = Math.max(0, this.tick - this.replay.historyTicks); for (const [perspectiveId, frames] of this.history) { const firstRetained = frames.findIndex(({ tick }) => tick >= earliestTick); if (firstRetained === -1) { this.history.delete(perspectiveId); continue; } if (firstRetained > 0) frames.splice(0, firstRetained); } } private cloneFrame( frame: ProjectedReplayFrame, ): ProjectedReplayFrame { return { tick: frame.tick, state: this.cloneState(frame.state), events: frame.events.map((event) => this.cloneEvent(event)), }; } private cloneState(state: ClientState): ClientState { return this.game.codecs.state.decode(this.game.codecs.state.encode(state)); } private cloneEvent(event: PerceptionEvent): PerceptionEvent { const codec = this.game.codecs.event; if (!codec) { throw new Error("Replay perceptions require an event codec"); } return codec.decode(codec.encode(event)); } private allocateTicketId(): number { const ticketId = this.nextTicketId; this.nextTicketId = this.nextTicketId === 0xffff_ffff ? 1 : this.nextTicketId + 1; return ticketId; } } export function withReplayTransport< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash extends ReplayStateHash, >( game: TimeTravelNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash >, definition: ReplayTransportDefinition, ): ReplayTransportNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash > { const replay = normalizeReplayDefinition(game, definition); return Object.freeze({ ...game, createServer(options: TimeTravelServerOptions = {}) { const engine = game.createServer({ ...options, recordingHistoryTicks: options.recordingHistoryTicks ?? replay.historyTicks, }); return new ReplayTransportAuthoritativeEngine(engine.game, engine, replay); }, }); } function normalizeReplayDefinition( game: { tickRateHz: number; snapshotRateHz: number }, definition: ReplayTransportDefinition, ): NormalizedReplayTransportDefinition { if (!Number.isFinite(definition.historySeconds) || definition.historySeconds <= 0) { throw new RangeError("historySeconds must be positive"); } const captureRateHz = definition.captureRateHz ?? game.snapshotRateHz; if ( !Number.isInteger(captureRateHz) || captureRateHz <= 0 || game.tickRateHz % captureRateHz !== 0 ) { throw new RangeError("captureRateHz must divide tickRateHz evenly"); } const maxPendingTicketsPerPlayer = definition.maxPendingTicketsPerPlayer ?? 2; if (!Number.isInteger(maxPendingTicketsPerPlayer) || maxPendingTicketsPerPlayer <= 0) { throw new RangeError("maxPendingTicketsPerPlayer must be a positive integer"); } return { ...definition, historyTicks: Math.ceil(definition.historySeconds * game.tickRateHz), captureEveryTicks: game.tickRateHz / captureRateHz, maxPendingTicketsPerPlayer, }; } function validPlayerId(value: number): boolean { return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff; } function validTick(value: number): boolean { return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff; } function clamp(value: number, minimum: number, maximum: number): number { return Math.max(minimum, Math.min(maximum, value)); }