deploy Syncer to a2acloud Kubernetes
Some checks failed
build / image (push) Failing after 28s

This commit is contained in:
Syncer Deploy
2026-08-28 10:46:42 -03:00
commit e93c1b026f
72 changed files with 17680 additions and 0 deletions

View 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);
}
}
}
}