This commit is contained in:
306
packages/engine/src/server.ts
Normal file
306
packages/engine/src/server.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type {
|
||||
ClientStateContext,
|
||||
ClientStateReport,
|
||||
GameDefinition,
|
||||
InputContext,
|
||||
InputPacket,
|
||||
PlayerId,
|
||||
StateSnapshot,
|
||||
ValidationResult,
|
||||
} from "./types.js";
|
||||
|
||||
export interface ServerEngineOptions {
|
||||
historyTicks?: number;
|
||||
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 ?? 2;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user