This commit is contained in:
472
packages/engine/src/networked-server.ts
Normal file
472
packages/engine/src/networked-server.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user