311 lines
8.4 KiB
TypeScript
311 lines
8.4 KiB
TypeScript
import type {
|
|
ClientStateContext,
|
|
ClientStateReport,
|
|
GameDefinition,
|
|
InputContext,
|
|
InputPacket,
|
|
PlayerId,
|
|
StateSnapshot,
|
|
ValidationResult,
|
|
} from "./types.js";
|
|
|
|
export interface ServerEngineOptions {
|
|
historyTicks?: number;
|
|
/**
|
|
* Maximum transport delay accepted for an input. Late inputs are applied on
|
|
* the next simulation step; they never rewrite authoritative history.
|
|
*/
|
|
maxPastTicks?: number;
|
|
maxFutureTicks?: number;
|
|
}
|
|
|
|
export interface InputDecision {
|
|
accepted: boolean;
|
|
reason?: "unknown-player" | "duplicate" | "invalid" | "past" | "future";
|
|
}
|
|
|
|
export interface Acknowledgement {
|
|
playerId: PlayerId;
|
|
sequence: number;
|
|
}
|
|
|
|
export interface ServerStepResult {
|
|
tick: number;
|
|
acknowledgements: Acknowledgement[];
|
|
validations: ValidationResult[];
|
|
snapshotDue: boolean;
|
|
}
|
|
|
|
interface QueuedInput<Input> {
|
|
playerId: PlayerId;
|
|
packet: InputPacket<Input>;
|
|
}
|
|
|
|
interface QueuedReport<State> {
|
|
playerId: PlayerId;
|
|
report: ClientStateReport<State>;
|
|
}
|
|
|
|
export class AuthoritativeEngine<State, Input> {
|
|
readonly game: GameDefinition<State, Input>;
|
|
private readonly historyTicks: number;
|
|
private readonly maxPastTicks: number;
|
|
private readonly maxFutureTicks: number;
|
|
private readonly snapshotEveryTicks: number;
|
|
private readonly players = new Set<PlayerId>();
|
|
private readonly lastReceivedSequence = new Map<PlayerId, number>();
|
|
private readonly acknowledgedSequence = new Map<PlayerId, number>();
|
|
private readonly history = new Map<number, State>();
|
|
private queuedInputs: QueuedInput<Input>[] = [];
|
|
private queuedReports: QueuedReport<State>[] = [];
|
|
private state: State;
|
|
private currentTick = 0;
|
|
|
|
constructor(
|
|
game: GameDefinition<State, Input>,
|
|
options: ServerEngineOptions = {},
|
|
) {
|
|
this.game = game;
|
|
this.historyTicks = options.historyTicks ?? game.tickRateHz * 2;
|
|
this.maxPastTicks = options.maxPastTicks ?? game.tickRateHz;
|
|
this.maxFutureTicks = options.maxFutureTicks ?? game.tickRateHz;
|
|
this.snapshotEveryTicks = game.tickRateHz / game.snapshotRateHz;
|
|
this.state = game.createInitialState();
|
|
this.storeHistory();
|
|
}
|
|
|
|
get tick(): number {
|
|
return this.currentTick;
|
|
}
|
|
|
|
get currentState(): Readonly<State> {
|
|
return this.state;
|
|
}
|
|
|
|
addPlayer(playerId: PlayerId): void {
|
|
if (this.players.has(playerId)) {
|
|
return;
|
|
}
|
|
|
|
this.players.add(playerId);
|
|
this.game.addPlayer?.(this.state, { playerId, tick: this.currentTick });
|
|
this.assertValidState();
|
|
this.storeHistory();
|
|
}
|
|
|
|
removePlayer(playerId: PlayerId): void {
|
|
if (!this.players.delete(playerId)) {
|
|
return;
|
|
}
|
|
|
|
this.game.removePlayer?.(this.state, {
|
|
playerId,
|
|
tick: this.currentTick,
|
|
});
|
|
this.lastReceivedSequence.delete(playerId);
|
|
this.acknowledgedSequence.delete(playerId);
|
|
this.queuedInputs = this.queuedInputs.filter(
|
|
(queued) => queued.playerId !== playerId,
|
|
);
|
|
this.queuedReports = this.queuedReports.filter(
|
|
(queued) => queued.playerId !== playerId,
|
|
);
|
|
this.assertValidState();
|
|
this.storeHistory();
|
|
}
|
|
|
|
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision {
|
|
if (!this.players.has(playerId)) {
|
|
return { accepted: false, reason: "unknown-player" };
|
|
}
|
|
|
|
const previousSequence = this.lastReceivedSequence.get(playerId) ?? 0;
|
|
if (packet.sequence <= previousSequence) {
|
|
return { accepted: false, reason: "duplicate" };
|
|
}
|
|
|
|
if (packet.targetTick < this.currentTick - this.maxPastTicks) {
|
|
return { accepted: false, reason: "past" };
|
|
}
|
|
|
|
if (packet.targetTick > this.currentTick + this.maxFutureTicks) {
|
|
return { accepted: false, reason: "future" };
|
|
}
|
|
|
|
const context = this.inputContext(playerId, packet);
|
|
if (!this.game.validateInput(packet.input, context)) {
|
|
return { accepted: false, reason: "invalid" };
|
|
}
|
|
|
|
this.lastReceivedSequence.set(playerId, packet.sequence);
|
|
this.queuedInputs.push({ playerId, packet });
|
|
return { accepted: true };
|
|
}
|
|
|
|
submitStateReport(
|
|
playerId: PlayerId,
|
|
report: ClientStateReport<State>,
|
|
): ValidationResult | null {
|
|
if (report.tick > this.currentTick + this.maxFutureTicks) {
|
|
return {
|
|
playerId,
|
|
tick: report.tick,
|
|
valid: false,
|
|
reason: "too-far-ahead",
|
|
};
|
|
}
|
|
|
|
if (report.tick > this.currentTick) {
|
|
this.queuedReports = this.queuedReports.filter(
|
|
(queued) => queued.playerId !== playerId,
|
|
);
|
|
this.queuedReports.push({ playerId, report });
|
|
return null;
|
|
}
|
|
|
|
return this.validateReport(playerId, report);
|
|
}
|
|
|
|
step(): ServerStepResult {
|
|
this.currentTick += 1;
|
|
const deltaSeconds = 1 / this.game.tickRateHz;
|
|
const acknowledgementByPlayer = new Map<PlayerId, number>();
|
|
const dueInputs: QueuedInput<Input>[] = [];
|
|
const futureInputs: QueuedInput<Input>[] = [];
|
|
|
|
for (const queued of this.queuedInputs) {
|
|
(queued.packet.targetTick <= this.currentTick
|
|
? dueInputs
|
|
: futureInputs
|
|
).push(queued);
|
|
}
|
|
this.queuedInputs = futureInputs;
|
|
|
|
dueInputs.sort(
|
|
(left, right) =>
|
|
left.packet.targetTick - right.packet.targetTick ||
|
|
left.playerId - right.playerId ||
|
|
left.packet.sequence - right.packet.sequence,
|
|
);
|
|
|
|
for (const queued of dueInputs) {
|
|
const { playerId, packet } = queued;
|
|
this.game.applyInput(
|
|
this.state,
|
|
packet.input,
|
|
this.inputContext(playerId, packet),
|
|
);
|
|
|
|
const previousAck = this.acknowledgedSequence.get(playerId) ?? 0;
|
|
if (packet.sequence > previousAck) {
|
|
this.acknowledgedSequence.set(playerId, packet.sequence);
|
|
acknowledgementByPlayer.set(playerId, packet.sequence);
|
|
}
|
|
}
|
|
|
|
this.game.step(this.state, {
|
|
tick: this.currentTick,
|
|
deltaSeconds,
|
|
});
|
|
this.assertValidState();
|
|
this.storeHistory();
|
|
|
|
const validations: ValidationResult[] = [];
|
|
const futureReports: QueuedReport<State>[] = [];
|
|
for (const queued of this.queuedReports) {
|
|
if (queued.report.tick <= this.currentTick) {
|
|
validations.push(
|
|
this.validateReport(queued.playerId, queued.report),
|
|
);
|
|
} else {
|
|
futureReports.push(queued);
|
|
}
|
|
}
|
|
this.queuedReports = futureReports;
|
|
|
|
return {
|
|
tick: this.currentTick,
|
|
acknowledgements: [...acknowledgementByPlayer].map(
|
|
([playerId, sequence]) => ({ playerId, sequence }),
|
|
),
|
|
validations,
|
|
snapshotDue: this.currentTick % this.snapshotEveryTicks === 0,
|
|
};
|
|
}
|
|
|
|
createSnapshot(serverTime: number): StateSnapshot<State> {
|
|
return {
|
|
tick: this.currentTick,
|
|
serverTime,
|
|
state: this.game.cloneState(this.state),
|
|
};
|
|
}
|
|
|
|
private inputContext(
|
|
playerId: PlayerId,
|
|
packet: InputPacket<Input>,
|
|
): InputContext {
|
|
return {
|
|
playerId,
|
|
sequence: packet.sequence,
|
|
targetTick: packet.targetTick,
|
|
observedTick: packet.observedTick ?? packet.targetTick,
|
|
tick: this.currentTick,
|
|
deltaSeconds: 1 / this.game.tickRateHz,
|
|
};
|
|
}
|
|
|
|
private validateReport(
|
|
playerId: PlayerId,
|
|
report: ClientStateReport<State>,
|
|
): ValidationResult {
|
|
const authoritative = this.history.get(report.tick);
|
|
if (!authoritative) {
|
|
return {
|
|
playerId,
|
|
tick: report.tick,
|
|
valid: false,
|
|
reason: "outside-history",
|
|
};
|
|
}
|
|
|
|
const context: ClientStateContext = { playerId, tick: report.tick };
|
|
const valid = this.game.validateClientState
|
|
? this.game.validateClientState(authoritative, report.state, context)
|
|
: (this.game.validateState?.(report.state, {
|
|
tick: report.tick,
|
|
deltaSeconds: 1 / this.game.tickRateHz,
|
|
}) ?? true);
|
|
|
|
return {
|
|
playerId,
|
|
tick: report.tick,
|
|
valid,
|
|
...(valid ? {} : { reason: "mismatch" as const }),
|
|
};
|
|
}
|
|
|
|
private assertValidState(): void {
|
|
if (
|
|
this.game.validateState &&
|
|
!this.game.validateState(this.state, {
|
|
tick: this.currentTick,
|
|
deltaSeconds: 1 / this.game.tickRateHz,
|
|
})
|
|
) {
|
|
throw new Error(`Game produced invalid state at tick ${this.currentTick}`);
|
|
}
|
|
}
|
|
|
|
private storeHistory(): void {
|
|
this.history.set(this.currentTick, this.game.cloneState(this.state));
|
|
|
|
const oldestTick = this.currentTick - this.historyTicks;
|
|
for (const tick of this.history.keys()) {
|
|
if (tick < oldestTick) {
|
|
this.history.delete(tick);
|
|
}
|
|
}
|
|
}
|
|
}
|