473 lines
13 KiB
TypeScript
473 lines
13 KiB
TypeScript
import type {
|
|
ClientStateReport,
|
|
InputContext,
|
|
InputPacket,
|
|
PlayerId,
|
|
StateSnapshot,
|
|
ValidationResult,
|
|
} from "./types.js";
|
|
import type {
|
|
NetworkedGameDefinition,
|
|
SnapshotBatch,
|
|
} from "./networked-types.js";
|
|
import type {
|
|
Acknowledgement,
|
|
InputDecision,
|
|
ServerEngineOptions,
|
|
} from "./server.js";
|
|
|
|
export interface NetworkedServerStepResult<AuthorityEvent> {
|
|
tick: number;
|
|
acknowledgements: Acknowledgement[];
|
|
validations: ValidationResult[];
|
|
events: AuthorityEvent[];
|
|
snapshotDue: boolean;
|
|
}
|
|
|
|
export interface NetworkedSimulationCheckpoint<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent,
|
|
> {
|
|
tick: number;
|
|
state: AuthorityState;
|
|
playerIds: PlayerId[];
|
|
lastReceivedSequences: Array<readonly [PlayerId, number]>;
|
|
acknowledgedSequences: Array<readonly [PlayerId, number]>;
|
|
queuedInputs: Array<{
|
|
playerId: PlayerId;
|
|
packet: InputPacket<Input>;
|
|
}>;
|
|
pendingEvents: AuthorityEvent[];
|
|
}
|
|
|
|
interface QueuedInput<Input> {
|
|
playerId: PlayerId;
|
|
packet: InputPacket<Input>;
|
|
}
|
|
|
|
interface QueuedReport<ClientState> {
|
|
playerId: PlayerId;
|
|
report: ClientStateReport<ClientState>;
|
|
}
|
|
|
|
export class NetworkedAuthoritativeEngine<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent,
|
|
> {
|
|
readonly game: NetworkedGameDefinition<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>;
|
|
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, AuthorityState>();
|
|
private queuedInputs: QueuedInput<Input>[] = [];
|
|
private queuedReports: QueuedReport<ClientState>[] = [];
|
|
private pendingEvents: AuthorityEvent[] = [];
|
|
private state: AuthorityState;
|
|
private currentTick = 0;
|
|
|
|
constructor(
|
|
game: NetworkedGameDefinition<
|
|
AuthorityState,
|
|
ClientState,
|
|
Input,
|
|
AuthorityEvent,
|
|
PerceptionEvent
|
|
>,
|
|
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.server.createInitialState();
|
|
game.server.initializeState?.(this.state, {
|
|
tick: 0,
|
|
deltaSeconds: 1 / game.tickRateHz,
|
|
});
|
|
this.assertValidState();
|
|
this.storeHistory();
|
|
}
|
|
|
|
get tick(): number {
|
|
return this.currentTick;
|
|
}
|
|
|
|
get currentState(): Readonly<AuthorityState> {
|
|
return this.state;
|
|
}
|
|
|
|
get playerIds(): PlayerId[] {
|
|
return [...this.players];
|
|
}
|
|
|
|
addPlayer(playerId: PlayerId): void {
|
|
if (this.players.has(playerId)) {
|
|
return;
|
|
}
|
|
|
|
this.players.add(playerId);
|
|
this.game.server.addPlayer?.(this.state, {
|
|
playerId,
|
|
tick: this.currentTick,
|
|
emit: (event) => this.pendingEvents.push(event),
|
|
});
|
|
this.assertValidState();
|
|
this.storeHistory();
|
|
}
|
|
|
|
removePlayer(playerId: PlayerId): void {
|
|
if (!this.players.delete(playerId)) {
|
|
return;
|
|
}
|
|
|
|
this.game.server.removePlayer?.(this.state, {
|
|
playerId,
|
|
tick: this.currentTick,
|
|
emit: (event) => this.pendingEvents.push(event),
|
|
});
|
|
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<ClientState>,
|
|
): 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(): NetworkedServerStepResult<AuthorityEvent> {
|
|
this.currentTick += 1;
|
|
const deltaSeconds = 1 / this.game.tickRateHz;
|
|
const events = this.pendingEvents;
|
|
this.pendingEvents = [];
|
|
const emit = (event: AuthorityEvent) => events.push(event);
|
|
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.server.applyInput(this.state, packet.input, {
|
|
...this.inputContext(playerId, packet),
|
|
emit,
|
|
});
|
|
|
|
const previousAck = this.acknowledgedSequence.get(playerId) ?? 0;
|
|
if (packet.sequence > previousAck) {
|
|
this.acknowledgedSequence.set(playerId, packet.sequence);
|
|
acknowledgementByPlayer.set(playerId, packet.sequence);
|
|
}
|
|
}
|
|
|
|
this.game.server.step(this.state, {
|
|
tick: this.currentTick,
|
|
deltaSeconds,
|
|
emit,
|
|
});
|
|
this.assertValidState();
|
|
this.storeHistory();
|
|
|
|
const validations: ValidationResult[] = [];
|
|
const futureReports: QueuedReport<ClientState>[] = [];
|
|
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,
|
|
events,
|
|
snapshotDue: this.currentTick % this.snapshotEveryTicks === 0,
|
|
};
|
|
}
|
|
|
|
createSnapshot(
|
|
playerId: PlayerId,
|
|
serverTime: number,
|
|
): StateSnapshot<ClientState> {
|
|
return {
|
|
tick: this.currentTick,
|
|
serverTime,
|
|
state: this.game.replication.createSnapshot(this.state, {
|
|
playerId,
|
|
tick: this.currentTick,
|
|
}),
|
|
};
|
|
}
|
|
|
|
createSnapshotBatches(serverTime: number): SnapshotBatch<ClientState>[] {
|
|
const groups = new Map<string | number, PlayerId[]>();
|
|
for (const playerId of this.players) {
|
|
const context = { playerId, tick: this.currentTick };
|
|
const key =
|
|
this.game.replication.groupKey?.(this.state, context) ?? playerId;
|
|
const group = groups.get(key);
|
|
if (group) {
|
|
group.push(playerId);
|
|
} else {
|
|
groups.set(key, [playerId]);
|
|
}
|
|
}
|
|
|
|
return [...groups.values()].map((playerIds) => ({
|
|
playerIds,
|
|
state: this.createSnapshot(playerIds[0]!, serverTime).state,
|
|
}));
|
|
}
|
|
|
|
createPerceptions(
|
|
playerId: PlayerId,
|
|
events: readonly AuthorityEvent[],
|
|
): PerceptionEvent[] {
|
|
if (!this.game.replication.perceive) {
|
|
return [];
|
|
}
|
|
|
|
const perceptions: PerceptionEvent[] = [];
|
|
for (const event of events) {
|
|
const perception = this.game.replication.perceive(
|
|
this.state,
|
|
event,
|
|
{ playerId, tick: this.currentTick },
|
|
);
|
|
if (perception !== null) {
|
|
perceptions.push(perception);
|
|
}
|
|
}
|
|
return perceptions;
|
|
}
|
|
|
|
createSimulationCheckpoint(): NetworkedSimulationCheckpoint<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent
|
|
> {
|
|
return {
|
|
tick: this.currentTick,
|
|
state: this.game.server.cloneState(this.state),
|
|
playerIds: [...this.players],
|
|
lastReceivedSequences: [...this.lastReceivedSequence],
|
|
acknowledgedSequences: [...this.acknowledgedSequence],
|
|
queuedInputs: this.queuedInputs.map(({ playerId, packet }) => ({
|
|
playerId,
|
|
packet: this.cloneInputPacket(packet),
|
|
})),
|
|
pendingEvents: [...this.pendingEvents],
|
|
};
|
|
}
|
|
|
|
restoreSimulationCheckpoint(
|
|
checkpoint: NetworkedSimulationCheckpoint<
|
|
AuthorityState,
|
|
Input,
|
|
AuthorityEvent
|
|
>,
|
|
): void {
|
|
if (!Number.isInteger(checkpoint.tick) || checkpoint.tick < 0) {
|
|
throw new RangeError("checkpoint tick must be a non-negative integer");
|
|
}
|
|
|
|
this.currentTick = checkpoint.tick;
|
|
this.state = this.game.server.cloneState(checkpoint.state);
|
|
|
|
this.players.clear();
|
|
for (const playerId of checkpoint.playerIds) this.players.add(playerId);
|
|
|
|
this.lastReceivedSequence.clear();
|
|
for (const [playerId, sequence] of checkpoint.lastReceivedSequences) {
|
|
this.lastReceivedSequence.set(playerId, sequence);
|
|
}
|
|
|
|
this.acknowledgedSequence.clear();
|
|
for (const [playerId, sequence] of checkpoint.acknowledgedSequences) {
|
|
this.acknowledgedSequence.set(playerId, sequence);
|
|
}
|
|
|
|
this.queuedInputs = checkpoint.queuedInputs.map(({ playerId, packet }) => ({
|
|
playerId,
|
|
packet: this.cloneInputPacket(packet),
|
|
}));
|
|
this.queuedReports = [];
|
|
this.pendingEvents = [...checkpoint.pendingEvents];
|
|
this.history.clear();
|
|
this.assertValidState();
|
|
this.storeHistory();
|
|
}
|
|
|
|
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 cloneInputPacket(packet: InputPacket<Input>): InputPacket<Input> {
|
|
const encoded = this.game.codecs.input.encode(packet.input);
|
|
return {
|
|
sequence: packet.sequence,
|
|
targetTick: packet.targetTick,
|
|
observedTick: packet.observedTick ?? packet.targetTick,
|
|
input: this.game.codecs.input.decode(encoded),
|
|
};
|
|
}
|
|
|
|
private validateReport(
|
|
playerId: PlayerId,
|
|
report: ClientStateReport<ClientState>,
|
|
): ValidationResult {
|
|
const authoritative = this.history.get(report.tick);
|
|
if (!authoritative) {
|
|
return {
|
|
playerId,
|
|
tick: report.tick,
|
|
valid: false,
|
|
reason: "outside-history",
|
|
};
|
|
}
|
|
|
|
const valid = this.game.replication.validateClientState
|
|
? this.game.replication.validateClientState(
|
|
authoritative,
|
|
report.state,
|
|
{ playerId, tick: report.tick },
|
|
)
|
|
: (this.game.client.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.server.validateState &&
|
|
!this.game.server.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.server.cloneStateForHistory?.(this.state) ??
|
|
this.game.server.cloneState(this.state),
|
|
);
|
|
|
|
const oldestTick = this.currentTick - this.historyTicks;
|
|
for (const tick of this.history.keys()) {
|
|
if (tick < oldestTick) {
|
|
this.history.delete(tick);
|
|
}
|
|
}
|
|
}
|
|
}
|