Files
syncer/packages/engine/src/client.ts
Syncer Deploy e93c1b026f
Some checks failed
build / image (push) Failing after 28s
deploy Syncer to a2acloud Kubernetes
2026-08-28 10:46:42 -03:00

186 lines
4.7 KiB
TypeScript

import { NetworkClock } from "./network-clock.js";
import type {
ClientStateReport,
GameDefinition,
InputContext,
InputPacket,
PlayerId,
StateSnapshot,
} from "./types.js";
interface PredictedInput<Input> {
packet: InputPacket<Input>;
applied: boolean;
}
export class PredictedEngine<State, Input> {
readonly game: GameDefinition<State, Input>;
readonly networkClock = new NetworkClock();
private state: State;
private playerId: PlayerId | null = null;
private currentTick = 0;
private nextInputSequence = 1;
private acknowledgedSequence = 0;
private pendingInputs: PredictedInput<Input>[] = [];
constructor(game: GameDefinition<State, Input>) {
this.game = game;
this.state = game.createInitialState();
}
get tick(): number {
return this.currentTick;
}
get localPlayerId(): PlayerId | null {
return this.playerId;
}
get currentState(): Readonly<State> {
return this.state;
}
get initialized(): boolean {
return this.playerId !== null;
}
initialize(playerId: PlayerId, snapshot: StateSnapshot<State>): void {
this.playerId = playerId;
this.currentTick = snapshot.tick;
this.state = this.game.cloneState(snapshot.state);
this.pendingInputs = [];
this.acknowledgedSequence = 0;
}
createInput(input: Input, leadTicks?: number): InputPacket<Input> {
if (this.playerId === null) {
throw new Error("Client engine has not received a welcome snapshot");
}
const targetTick =
this.currentTick +
(leadTicks ??
this.networkClock.recommendedInputLeadTicks(this.game.tickRateHz));
const packet: InputPacket<Input> = {
sequence: this.nextInputSequence,
targetTick,
observedTick: this.currentTick,
input,
};
const context = this.inputContext(packet);
if (!this.game.validateInput(input, context)) {
throw new Error("Refusing to send invalid local input");
}
this.nextInputSequence = (this.nextInputSequence + 1) >>> 0;
this.pendingInputs.push({ packet, applied: false });
return packet;
}
acknowledge(sequence: number): void {
this.acknowledgedSequence = Math.max(this.acknowledgedSequence, sequence);
this.pendingInputs = this.pendingInputs.filter(
(pending) => pending.packet.sequence > this.acknowledgedSequence,
);
}
reject(sequence: number): void {
this.pendingInputs = this.pendingInputs.filter(
(pending) => pending.packet.sequence !== sequence,
);
}
step(): void {
if (this.playerId === null) {
return;
}
this.currentTick += 1;
this.applyDueInputs();
this.game.step(this.state, {
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
});
this.assertValidState();
}
reconcile(snapshot: StateSnapshot<State>): void {
if (
this.playerId === null ||
snapshot.tick < this.currentTick - this.game.tickRateHz * 2
) {
return;
}
const predictedTick = Math.max(this.currentTick, snapshot.tick);
this.state = this.game.cloneState(snapshot.state);
this.currentTick = snapshot.tick;
for (const pending of this.pendingInputs) {
pending.applied = false;
}
this.applyDueInputs();
while (this.currentTick < predictedTick) {
this.currentTick += 1;
this.applyDueInputs();
this.game.step(this.state, {
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
});
}
this.assertValidState();
}
createStateReport(): ClientStateReport<State> {
return {
tick: this.currentTick,
state: this.game.cloneState(this.state),
};
}
private applyDueInputs(): void {
for (const pending of this.pendingInputs) {
if (!pending.applied && pending.packet.targetTick <= this.currentTick) {
this.game.applyInput(
this.state,
pending.packet.input,
this.inputContext(pending.packet),
);
pending.applied = true;
}
}
}
private inputContext(packet: InputPacket<Input>): InputContext {
if (this.playerId === null) {
throw new Error("Client engine has not been initialized");
}
return {
playerId: this.playerId,
sequence: packet.sequence,
targetTick: packet.targetTick,
observedTick: packet.observedTick ?? packet.targetTick,
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
};
}
private assertValidState(): void {
if (
this.game.validateState &&
!this.game.validateState(this.state, {
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
})
) {
throw new Error(
`Client prediction produced invalid state at tick ${this.currentTick}`,
);
}
}
}