add generic stateful input streaming
All checks were successful
build / image (push) Successful in 50s
All checks were successful
build / image (push) Successful in 50s
This commit is contained in:
@@ -27,6 +27,16 @@ export {
|
||||
type LagCompensationPolicies,
|
||||
type LagCompensationResolutionContext,
|
||||
} from "./lag-compensation.js";
|
||||
export {
|
||||
createInputStateStream,
|
||||
InputStateStream,
|
||||
withInputStream,
|
||||
type InputStreamDefinition,
|
||||
type InputStreamEmission,
|
||||
type InputStreamNetworkedGame,
|
||||
type InputStreamNeutralizeContext,
|
||||
type InputStreamPolicy,
|
||||
} from "./input-stream.js";
|
||||
export { NetworkedPredictedEngine } from "./networked-client.js";
|
||||
export {
|
||||
NetworkedAuthoritativeEngine,
|
||||
|
||||
194
packages/engine/src/input-stream.ts
Normal file
194
packages/engine/src/input-stream.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
defineNetworkedGame,
|
||||
type DefinedNetworkedGame,
|
||||
} from "./define-networked-game.js";
|
||||
import type { PlayerId } from "./types.js";
|
||||
|
||||
export interface InputStreamNeutralizeContext {
|
||||
playerId: PlayerId;
|
||||
tick: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export interface InputStreamDefinition<Input> {
|
||||
/** Stateful input refresh rate. Changes are still sent immediately. */
|
||||
heartbeatRateHz?: number;
|
||||
/** Authority fails safe to a neutral command after this silence window. */
|
||||
timeoutMs?: number;
|
||||
inputsEqual(left: Readonly<Input>, right: Readonly<Input>): boolean;
|
||||
neutralize(
|
||||
lastInput: Readonly<Input>,
|
||||
context: InputStreamNeutralizeContext,
|
||||
): Input;
|
||||
/**
|
||||
* Rebuilds persistent state after a timed-out connection resumes. Clear
|
||||
* edge-triggered actions here so a heartbeat cannot execute them twice.
|
||||
*/
|
||||
resume?(
|
||||
lastClientInput: Readonly<Input>,
|
||||
context: InputStreamNeutralizeContext,
|
||||
): Input;
|
||||
}
|
||||
|
||||
export interface InputStreamPolicy<Input> {
|
||||
readonly heartbeatRateHz: number;
|
||||
readonly heartbeatIntervalMs: number;
|
||||
readonly timeoutTicks: number;
|
||||
readonly timeoutMs: number;
|
||||
inputsEqual(left: Readonly<Input>, right: Readonly<Input>): boolean;
|
||||
cloneInput(input: Readonly<Input>): Input;
|
||||
neutralize(
|
||||
lastInput: Readonly<Input>,
|
||||
context: InputStreamNeutralizeContext,
|
||||
): Input;
|
||||
resume(
|
||||
lastClientInput: Readonly<Input>,
|
||||
context: InputStreamNeutralizeContext,
|
||||
): Input;
|
||||
}
|
||||
|
||||
export interface InputStreamEmission<Input> {
|
||||
kind: "state" | "heartbeat";
|
||||
input: Input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-independent latest-state sender. A changed command is available
|
||||
* immediately; an unchanged command is made available only when its heartbeat
|
||||
* is due.
|
||||
*/
|
||||
export class InputStateStream<Input> {
|
||||
private current: Input | null = null;
|
||||
private lastSent: Input | null = null;
|
||||
private lastSentAt = Number.NEGATIVE_INFINITY;
|
||||
private dirty = false;
|
||||
|
||||
constructor(readonly policy: InputStreamPolicy<Input>) {}
|
||||
|
||||
update(input: Readonly<Input>): void {
|
||||
this.current = this.policy.cloneInput(input);
|
||||
this.dirty =
|
||||
this.lastSent === null ||
|
||||
!this.policy.inputsEqual(this.current, this.lastSent);
|
||||
}
|
||||
|
||||
reset(input?: Readonly<Input>): void {
|
||||
this.current = input === undefined ? null : this.policy.cloneInput(input);
|
||||
this.lastSent = null;
|
||||
this.lastSentAt = Number.NEGATIVE_INFINITY;
|
||||
this.dirty = input !== undefined;
|
||||
}
|
||||
|
||||
/** Forces the current value to receive a new sequence on its next send. */
|
||||
invalidate(): void {
|
||||
this.lastSent = null;
|
||||
this.lastSentAt = Number.NEGATIVE_INFINITY;
|
||||
this.dirty = this.current !== null;
|
||||
}
|
||||
|
||||
/** Returns the next state to transmit, or null when no send is due. */
|
||||
consume(now: number, force = false): InputStreamEmission<Input> | null {
|
||||
if (!Number.isFinite(now)) {
|
||||
throw new RangeError("input stream time must be finite");
|
||||
}
|
||||
if (this.current === null) return null;
|
||||
|
||||
const heartbeatDue =
|
||||
now - this.lastSentAt >= this.policy.heartbeatIntervalMs;
|
||||
if (!force && !this.dirty && !heartbeatDue) return null;
|
||||
|
||||
const kind = this.dirty || this.lastSent === null ? "state" : "heartbeat";
|
||||
const outgoing = this.policy.cloneInput(this.current);
|
||||
this.lastSent = this.policy.cloneInput(this.current);
|
||||
this.lastSentAt = now;
|
||||
this.dirty = false;
|
||||
return { kind, input: outgoing };
|
||||
}
|
||||
}
|
||||
|
||||
export function createInputStateStream<Input>(game: {
|
||||
inputStream?: InputStreamPolicy<Input>;
|
||||
}): InputStateStream<Input> {
|
||||
if (!game.inputStream) {
|
||||
throw new Error("Game is not configured with withInputStream()");
|
||||
}
|
||||
return new InputStateStream(game.inputStream);
|
||||
}
|
||||
|
||||
export type InputStreamNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
> = DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> & {
|
||||
readonly inputStream: InputStreamPolicy<Input>;
|
||||
createInputStream(): InputStateStream<Input>;
|
||||
};
|
||||
|
||||
/** Adds reliable latest-state delivery and a server-side dead-man timeout. */
|
||||
export function withInputStream<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
>(
|
||||
game: DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
definition: InputStreamDefinition<Input>,
|
||||
): InputStreamNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> {
|
||||
const heartbeatRateHz = definition.heartbeatRateHz ?? Math.min(20, game.tickRateHz);
|
||||
const timeoutMs = definition.timeoutMs ?? 400;
|
||||
if (!Number.isFinite(heartbeatRateHz) || heartbeatRateHz <= 0) {
|
||||
throw new RangeError("heartbeatRateHz must be positive");
|
||||
}
|
||||
if (heartbeatRateHz > game.tickRateHz) {
|
||||
throw new RangeError("heartbeatRateHz cannot exceed the simulation tick rate");
|
||||
}
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 1_000 / heartbeatRateHz) {
|
||||
throw new RangeError("timeoutMs must exceed one heartbeat interval");
|
||||
}
|
||||
|
||||
const cloneInput = (input: Readonly<Input>): Input => {
|
||||
const encoded = game.codecs.input.encode(input as Input);
|
||||
return game.codecs.input.decode(encoded);
|
||||
};
|
||||
const policy: InputStreamPolicy<Input> = Object.freeze({
|
||||
heartbeatRateHz,
|
||||
heartbeatIntervalMs: 1_000 / heartbeatRateHz,
|
||||
timeoutTicks: Math.max(1, Math.ceil((timeoutMs / 1_000) * game.tickRateHz)),
|
||||
timeoutMs,
|
||||
inputsEqual: definition.inputsEqual,
|
||||
cloneInput,
|
||||
neutralize: definition.neutralize,
|
||||
resume: definition.resume ?? ((input) => cloneInput(input)),
|
||||
});
|
||||
const wrapped = defineNetworkedGame({
|
||||
...game,
|
||||
inputStream: policy,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
...wrapped,
|
||||
inputStream: policy,
|
||||
createInputStream: () => new InputStateStream(policy),
|
||||
});
|
||||
}
|
||||
@@ -71,7 +71,10 @@ export class NetworkClock {
|
||||
return clientNow + this.offset;
|
||||
}
|
||||
|
||||
recommendedInputLeadTicks(tickRateHz: number, maximum = 8): number {
|
||||
recommendedInputLeadTicks(
|
||||
tickRateHz: number,
|
||||
maximum = Math.max(8, Math.ceil(tickRateHz / 2)),
|
||||
): number {
|
||||
const tickMilliseconds = 1_000 / tickRateHz;
|
||||
const latencyBudget = this.smoothedRtt / 2 + this.smoothedJitter * 2;
|
||||
|
||||
|
||||
@@ -38,6 +38,20 @@ export interface NetworkedSimulationCheckpoint<
|
||||
playerId: PlayerId;
|
||||
packet: InputPacket<Input>;
|
||||
}>;
|
||||
inputStreamStates: Array<{
|
||||
playerId: PlayerId;
|
||||
lastReceivedTick: number;
|
||||
clientSequence: number;
|
||||
clientTargetTick: number;
|
||||
clientObservedTick: number;
|
||||
clientInput: Input;
|
||||
lastAppliedTick: number;
|
||||
appliedSequence: number;
|
||||
appliedInput: Input;
|
||||
applied: boolean;
|
||||
neutralized: boolean;
|
||||
resumePending: boolean;
|
||||
}>;
|
||||
pendingEvents: AuthorityEvent[];
|
||||
}
|
||||
|
||||
@@ -51,6 +65,20 @@ interface QueuedReport<ClientState> {
|
||||
report: ClientStateReport<ClientState>;
|
||||
}
|
||||
|
||||
interface InputStreamState<Input> {
|
||||
lastReceivedTick: number;
|
||||
clientSequence: number;
|
||||
clientTargetTick: number;
|
||||
clientObservedTick: number;
|
||||
clientInput: Input;
|
||||
lastAppliedTick: number;
|
||||
appliedSequence: number;
|
||||
appliedInput: Input;
|
||||
applied: boolean;
|
||||
neutralized: boolean;
|
||||
resumePending: boolean;
|
||||
}
|
||||
|
||||
export class NetworkedAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
@@ -73,6 +101,10 @@ export class NetworkedAuthoritativeEngine<
|
||||
private readonly lastReceivedSequence = new Map<PlayerId, number>();
|
||||
private readonly acknowledgedSequence = new Map<PlayerId, number>();
|
||||
private readonly history = new Map<number, AuthorityState>();
|
||||
private readonly inputStreamStates = new Map<
|
||||
PlayerId,
|
||||
InputStreamState<Input>
|
||||
>();
|
||||
private queuedInputs: QueuedInput<Input>[] = [];
|
||||
private queuedReports: QueuedReport<ClientState>[] = [];
|
||||
private pendingEvents: AuthorityEvent[] = [];
|
||||
@@ -142,6 +174,7 @@ export class NetworkedAuthoritativeEngine<
|
||||
});
|
||||
this.lastReceivedSequence.delete(playerId);
|
||||
this.acknowledgedSequence.delete(playerId);
|
||||
this.inputStreamStates.delete(playerId);
|
||||
this.queuedInputs = this.queuedInputs.filter(
|
||||
(queued) => queued.playerId !== playerId,
|
||||
);
|
||||
@@ -158,6 +191,27 @@ export class NetworkedAuthoritativeEngine<
|
||||
}
|
||||
|
||||
const previousSequence = this.lastReceivedSequence.get(playerId) ?? 0;
|
||||
if (packet.sequence === previousSequence && this.game.inputStream) {
|
||||
const stream = this.inputStreamStates.get(playerId);
|
||||
if (
|
||||
!stream ||
|
||||
stream.clientSequence !== packet.sequence ||
|
||||
stream.clientTargetTick !== packet.targetTick ||
|
||||
stream.clientObservedTick !== (packet.observedTick ?? packet.targetTick) ||
|
||||
!this.game.inputStream.inputsEqual(stream.clientInput, packet.input)
|
||||
) {
|
||||
return { accepted: false, reason: "duplicate" };
|
||||
}
|
||||
stream.lastReceivedTick = this.currentTick;
|
||||
if (
|
||||
stream.applied &&
|
||||
stream.neutralized &&
|
||||
stream.appliedSequence === stream.clientSequence
|
||||
) {
|
||||
stream.resumePending = true;
|
||||
}
|
||||
return { accepted: true };
|
||||
}
|
||||
if (packet.sequence <= previousSequence) {
|
||||
return { accepted: false, reason: "duplicate" };
|
||||
}
|
||||
@@ -174,6 +228,23 @@ export class NetworkedAuthoritativeEngine<
|
||||
}
|
||||
|
||||
this.lastReceivedSequence.set(playerId, packet.sequence);
|
||||
if (this.game.inputStream) {
|
||||
const previous = this.inputStreamStates.get(playerId);
|
||||
this.inputStreamStates.set(playerId, {
|
||||
lastReceivedTick: this.currentTick,
|
||||
clientSequence: packet.sequence,
|
||||
clientTargetTick: packet.targetTick,
|
||||
clientObservedTick: packet.observedTick ?? packet.targetTick,
|
||||
clientInput: this.game.inputStream.cloneInput(packet.input),
|
||||
lastAppliedTick: previous?.lastAppliedTick ?? this.currentTick,
|
||||
appliedSequence: previous?.appliedSequence ?? packet.sequence,
|
||||
appliedInput: previous?.appliedInput ??
|
||||
this.game.inputStream.cloneInput(packet.input),
|
||||
applied: previous?.applied ?? false,
|
||||
neutralized: previous?.neutralized ?? false,
|
||||
resumePending: false,
|
||||
});
|
||||
}
|
||||
this.queuedInputs.push({ playerId, packet });
|
||||
return { accepted: true };
|
||||
}
|
||||
@@ -234,6 +305,18 @@ export class NetworkedAuthoritativeEngine<
|
||||
emit,
|
||||
});
|
||||
|
||||
if (this.game.inputStream) {
|
||||
const stream = this.inputStreamStates.get(playerId);
|
||||
if (stream) {
|
||||
stream.lastAppliedTick = this.currentTick;
|
||||
stream.appliedSequence = packet.sequence;
|
||||
stream.appliedInput = this.game.inputStream.cloneInput(packet.input);
|
||||
stream.applied = true;
|
||||
stream.neutralized = false;
|
||||
stream.resumePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
const previousAck = this.acknowledgedSequence.get(playerId) ?? 0;
|
||||
if (packet.sequence > previousAck) {
|
||||
this.acknowledgedSequence.set(playerId, packet.sequence);
|
||||
@@ -241,6 +324,9 @@ export class NetworkedAuthoritativeEngine<
|
||||
}
|
||||
}
|
||||
|
||||
this.applyInputResumes(emit);
|
||||
this.applyInputTimeouts(emit);
|
||||
|
||||
this.game.server.step(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds,
|
||||
@@ -344,6 +430,22 @@ export class NetworkedAuthoritativeEngine<
|
||||
playerId,
|
||||
packet: this.cloneInputPacket(packet),
|
||||
})),
|
||||
inputStreamStates: [...this.inputStreamStates].map(
|
||||
([playerId, stream]) => ({
|
||||
playerId,
|
||||
lastReceivedTick: stream.lastReceivedTick,
|
||||
clientSequence: stream.clientSequence,
|
||||
clientTargetTick: stream.clientTargetTick,
|
||||
clientObservedTick: stream.clientObservedTick,
|
||||
clientInput: this.cloneInput(stream.clientInput),
|
||||
lastAppliedTick: stream.lastAppliedTick,
|
||||
appliedSequence: stream.appliedSequence,
|
||||
appliedInput: this.cloneInput(stream.appliedInput),
|
||||
applied: stream.applied,
|
||||
neutralized: stream.neutralized,
|
||||
resumePending: stream.resumePending,
|
||||
}),
|
||||
),
|
||||
pendingEvents: [...this.pendingEvents],
|
||||
};
|
||||
}
|
||||
@@ -379,6 +481,22 @@ export class NetworkedAuthoritativeEngine<
|
||||
playerId,
|
||||
packet: this.cloneInputPacket(packet),
|
||||
}));
|
||||
this.inputStreamStates.clear();
|
||||
for (const stream of checkpoint.inputStreamStates) {
|
||||
this.inputStreamStates.set(stream.playerId, {
|
||||
lastReceivedTick: stream.lastReceivedTick,
|
||||
clientSequence: stream.clientSequence,
|
||||
clientTargetTick: stream.clientTargetTick,
|
||||
clientObservedTick: stream.clientObservedTick,
|
||||
clientInput: this.cloneInput(stream.clientInput),
|
||||
lastAppliedTick: stream.lastAppliedTick,
|
||||
appliedSequence: stream.appliedSequence,
|
||||
appliedInput: this.cloneInput(stream.appliedInput),
|
||||
applied: stream.applied,
|
||||
neutralized: stream.neutralized,
|
||||
resumePending: stream.resumePending,
|
||||
});
|
||||
}
|
||||
this.queuedReports = [];
|
||||
this.pendingEvents = [...checkpoint.pendingEvents];
|
||||
this.history.clear();
|
||||
@@ -401,15 +519,95 @@ export class NetworkedAuthoritativeEngine<
|
||||
}
|
||||
|
||||
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),
|
||||
input: this.cloneInput(packet.input),
|
||||
};
|
||||
}
|
||||
|
||||
private cloneInput(input: Input): Input {
|
||||
const encoded = this.game.codecs.input.encode(input);
|
||||
return this.game.codecs.input.decode(encoded);
|
||||
}
|
||||
|
||||
private applyInputTimeouts(emit: (event: AuthorityEvent) => void): void {
|
||||
const policy = this.game.inputStream;
|
||||
if (!policy) return;
|
||||
|
||||
for (const [playerId, stream] of this.inputStreamStates) {
|
||||
if (
|
||||
!stream.applied ||
|
||||
stream.neutralized ||
|
||||
this.currentTick - stream.lastReceivedTick < policy.timeoutTicks
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const input = policy.neutralize(policy.cloneInput(stream.appliedInput), {
|
||||
playerId,
|
||||
tick: this.currentTick,
|
||||
sequence: stream.appliedSequence,
|
||||
});
|
||||
const packet: InputPacket<Input> = {
|
||||
sequence: stream.appliedSequence,
|
||||
targetTick: this.currentTick,
|
||||
observedTick: this.currentTick,
|
||||
input,
|
||||
};
|
||||
if (!this.game.validateInput(input, this.inputContext(playerId, packet))) {
|
||||
throw new Error("Input stream neutralizer produced an invalid input");
|
||||
}
|
||||
this.game.server.applyInput(this.state, input, {
|
||||
...this.inputContext(playerId, packet),
|
||||
emit,
|
||||
});
|
||||
stream.appliedInput = policy.cloneInput(input);
|
||||
stream.neutralized = true;
|
||||
stream.resumePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
private applyInputResumes(emit: (event: AuthorityEvent) => void): void {
|
||||
const policy = this.game.inputStream;
|
||||
if (!policy) return;
|
||||
|
||||
for (const [playerId, stream] of this.inputStreamStates) {
|
||||
if (
|
||||
!stream.applied ||
|
||||
!stream.neutralized ||
|
||||
!stream.resumePending ||
|
||||
stream.appliedSequence !== stream.clientSequence
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const input = policy.resume(policy.cloneInput(stream.clientInput), {
|
||||
playerId,
|
||||
tick: this.currentTick,
|
||||
sequence: stream.clientSequence,
|
||||
});
|
||||
const packet: InputPacket<Input> = {
|
||||
sequence: stream.clientSequence,
|
||||
targetTick: this.currentTick,
|
||||
observedTick: this.currentTick,
|
||||
input,
|
||||
};
|
||||
if (!this.game.validateInput(input, this.inputContext(playerId, packet))) {
|
||||
throw new Error("Input stream resume produced an invalid input");
|
||||
}
|
||||
this.game.server.applyInput(this.state, input, {
|
||||
...this.inputContext(playerId, packet),
|
||||
emit,
|
||||
});
|
||||
stream.lastAppliedTick = this.currentTick;
|
||||
stream.appliedInput = policy.cloneInput(input);
|
||||
stream.neutralized = false;
|
||||
stream.resumePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
private validateReport(
|
||||
playerId: PlayerId,
|
||||
report: ClientStateReport<ClientState>,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
PlayerId,
|
||||
TickContext,
|
||||
} from "./types.js";
|
||||
import type { InputStreamPolicy } from "./input-stream.js";
|
||||
|
||||
export interface EmittingTickContext<Event> extends TickContext {
|
||||
emit(event: Event): void;
|
||||
@@ -112,6 +113,8 @@ export interface NetworkedGameDefinition<
|
||||
PerceptionEvent
|
||||
>;
|
||||
validateInput(input: Input, context: InputContext): boolean;
|
||||
/** Present when the game is wrapped with withInputStream(). */
|
||||
inputStream?: InputStreamPolicy<Input>;
|
||||
codecs: {
|
||||
input: BinaryCodec<Input>;
|
||||
state: BinaryCodec<ClientState>;
|
||||
|
||||
@@ -877,6 +877,7 @@ function createSeededEngine<
|
||||
client: game.client,
|
||||
replication: game.replication,
|
||||
validateInput: game.validateInput,
|
||||
...(game.inputStream ? { inputStream: game.inputStream } : {}),
|
||||
codecs: game.codecs,
|
||||
};
|
||||
return new NetworkedAuthoritativeEngine(definition, serverOptions);
|
||||
@@ -969,6 +970,15 @@ function cloneSimulationCheckpoint<
|
||||
playerId,
|
||||
packet: cloneInputPacket(game, packet),
|
||||
})),
|
||||
inputStreamStates: checkpoint.inputStreamStates.map((stream) => ({
|
||||
...stream,
|
||||
clientInput: game.codecs.input.decode(
|
||||
game.codecs.input.encode(stream.clientInput),
|
||||
),
|
||||
appliedInput: game.codecs.input.decode(
|
||||
game.codecs.input.encode(stream.appliedInput),
|
||||
),
|
||||
})),
|
||||
pendingEvents: [...checkpoint.pendingEvents],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
ReplayDivergenceError,
|
||||
SpatialGridIndex,
|
||||
createBinaryProtocol,
|
||||
createInputStateStream,
|
||||
createJsonCodec,
|
||||
deterministicHash,
|
||||
defineGame,
|
||||
defineMultiplayerGame,
|
||||
defineNetworkedGame,
|
||||
withLagCompensation,
|
||||
withInputStream,
|
||||
withReplayTransport,
|
||||
withSpatialReplication,
|
||||
withTimeTravel,
|
||||
@@ -270,6 +272,158 @@ test("network clock estimates RTT, offset, and input lead", () => {
|
||||
assert.equal(stats.clockOffset, 10);
|
||||
assert.equal(clock.toServerTime(300), 310);
|
||||
assert.equal(clock.recommendedInputLeadTicks(60), 4);
|
||||
|
||||
const distantClock = new NetworkClock();
|
||||
const distantPing = distantClock.createPing(0);
|
||||
distantClock.receivePong(
|
||||
{
|
||||
...distantPing,
|
||||
serverReceivedAt: 1_000,
|
||||
serverSentAt: 1_000,
|
||||
},
|
||||
2_000,
|
||||
);
|
||||
assert.equal(distantClock.recommendedInputLeadTicks(60), 30);
|
||||
});
|
||||
|
||||
test("input-stream HOF heartbeats state, executes actions once, and fails safe", () => {
|
||||
const codec = createJsonCodec();
|
||||
const base = defineMultiplayerGame({
|
||||
clock: { ticksPerSecond: 10, snapshotsPerSecond: 5 },
|
||||
authority: {
|
||||
createInitialState: () => ({ moving: false, actionHeld: false, actions: 0 }),
|
||||
cloneState: (state) => ({ ...state }),
|
||||
applyInput(state, input) {
|
||||
state.moving = input.move;
|
||||
if (input.action && !state.actionHeld) state.actions += 1;
|
||||
state.actionHeld = input.action;
|
||||
},
|
||||
step() {},
|
||||
},
|
||||
prediction: {
|
||||
createInitialState: () => ({ moving: false, actionHeld: false, actions: 0 }),
|
||||
cloneState: (state) => ({ ...state }),
|
||||
applyInput(state, input) {
|
||||
state.moving = input.move;
|
||||
state.actionHeld = input.action;
|
||||
},
|
||||
step() {},
|
||||
},
|
||||
visibility: {
|
||||
createSnapshot: (authority) => ({ ...authority }),
|
||||
},
|
||||
input: {
|
||||
validate: (input) =>
|
||||
typeof input?.move === "boolean" && typeof input.action === "boolean",
|
||||
},
|
||||
encoding: { input: codec, clientState: codec },
|
||||
});
|
||||
const game = withInputStream(base, {
|
||||
heartbeatRateHz: 5,
|
||||
timeoutMs: 300,
|
||||
inputsEqual: (left, right) =>
|
||||
left.move === right.move && left.action === right.action,
|
||||
neutralize: () => ({ move: false, action: false }),
|
||||
resume: (lastClientInput) => ({
|
||||
move: lastClientInput.move,
|
||||
action: false,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(game.inputStream.timeoutTicks, 3);
|
||||
const stream = createInputStateStream(game);
|
||||
stream.update({ move: true, action: true });
|
||||
assert.deepEqual(stream.consume(0), {
|
||||
kind: "state",
|
||||
input: { move: true, action: true },
|
||||
});
|
||||
assert.equal(stream.consume(199), null);
|
||||
assert.deepEqual(stream.consume(200), {
|
||||
kind: "heartbeat",
|
||||
input: { move: true, action: true },
|
||||
});
|
||||
stream.update({ move: false, action: false });
|
||||
assert.deepEqual(stream.consume(201), {
|
||||
kind: "state",
|
||||
input: { move: false, action: false },
|
||||
});
|
||||
|
||||
const server = game.createServer();
|
||||
server.addPlayer(1);
|
||||
const packet = {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
observedTick: 0,
|
||||
input: { move: true, action: true },
|
||||
};
|
||||
assert.deepEqual(server.submitInput(1, packet), { accepted: true });
|
||||
assert.deepEqual(server.step().acknowledgements, [
|
||||
{ playerId: 1, sequence: 1 },
|
||||
]);
|
||||
assert.deepEqual(server.currentState, {
|
||||
moving: true,
|
||||
actionHeld: true,
|
||||
actions: 1,
|
||||
});
|
||||
|
||||
// The exact same sequence is a keepalive, not another action.
|
||||
assert.deepEqual(server.submitInput(1, packet), { accepted: true });
|
||||
assert.deepEqual(
|
||||
server.submitInput(1, {
|
||||
...packet,
|
||||
input: { move: false, action: true },
|
||||
}),
|
||||
{ accepted: false, reason: "duplicate" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
server.submitInput(1, { ...packet, observedTick: 1 }),
|
||||
{ accepted: false, reason: "duplicate" },
|
||||
);
|
||||
server.step();
|
||||
assert.equal(server.currentState.actions, 1);
|
||||
|
||||
// Silence trips the dead-man switch exactly once.
|
||||
server.step();
|
||||
server.step();
|
||||
assert.deepEqual(server.currentState, {
|
||||
moving: false,
|
||||
actionHeld: false,
|
||||
actions: 1,
|
||||
});
|
||||
|
||||
// A delayed heartbeat restores state through resume(), with the action
|
||||
// stripped so it still has exactly-once semantics.
|
||||
assert.deepEqual(server.submitInput(1, packet), { accepted: true });
|
||||
server.step();
|
||||
assert.deepEqual(server.currentState, {
|
||||
moving: true,
|
||||
actionHeld: false,
|
||||
actions: 1,
|
||||
});
|
||||
|
||||
const checkpoint = server.createSimulationCheckpoint();
|
||||
const restored = game.createServer();
|
||||
restored.restoreSimulationCheckpoint(checkpoint);
|
||||
assert.deepEqual(restored.createSimulationCheckpoint(), checkpoint);
|
||||
|
||||
const recordedGame = withTimeTravel(game, {
|
||||
createSeed: () => 0,
|
||||
hashState: deterministicHash,
|
||||
checkpointIntervalTicks: 2,
|
||||
verificationIntervalTicks: 1,
|
||||
});
|
||||
const recordedServer = recordedGame.createServer({ replaySeed: 0 });
|
||||
recordedServer.addPlayer(1);
|
||||
recordedServer.submitInput(1, packet);
|
||||
recordedServer.step();
|
||||
recordedServer.submitInput(1, packet);
|
||||
recordedServer.step();
|
||||
recordedServer.step();
|
||||
recordedServer.step();
|
||||
recordedServer.submitInput(1, packet);
|
||||
recordedServer.step();
|
||||
const replay = recordedGame.createReplay(recordedServer.exportRecording());
|
||||
assert.deepEqual(replay.seek(recordedServer.tick).state, recordedServer.currentState);
|
||||
});
|
||||
|
||||
test("binary protocol round-trips generic inputs, state, and control frames", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createJsonCodec,
|
||||
defineMultiplayerGame,
|
||||
withInputStream,
|
||||
} from "@syncer/engine";
|
||||
import type {
|
||||
FluxAuthorityEvent,
|
||||
@@ -36,7 +37,7 @@ const perceptionCodec = createJsonCodec<FluxPerception>();
|
||||
* a team tug-of-war: hold thrust, manage private energy, and pull the shared
|
||||
* core through your team's gate.
|
||||
*/
|
||||
export const fluxGame = defineMultiplayerGame<FluxGameContract>({
|
||||
const baseFluxGame = defineMultiplayerGame<FluxGameContract>({
|
||||
clock: {
|
||||
ticksPerSecond: FLUX_TICK_RATE,
|
||||
snapshotsPerSecond: FLUX_SNAPSHOT_RATE,
|
||||
@@ -220,6 +221,13 @@ export const fluxGame = defineMultiplayerGame<FluxGameContract>({
|
||||
},
|
||||
});
|
||||
|
||||
export const fluxGame = withInputStream(baseFluxGame, {
|
||||
heartbeatRateHz: 20,
|
||||
timeoutMs: 400,
|
||||
inputsEqual: (left, right) => left.thrust === right.thrust,
|
||||
neutralize: () => ({ thrust: false }),
|
||||
});
|
||||
|
||||
function createAuthorityState(): FluxAuthorityState {
|
||||
return {
|
||||
core: 0,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createJsonCodec,
|
||||
defineMultiplayerGame,
|
||||
withInputStream,
|
||||
withSpatialReplication,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
@@ -290,7 +291,36 @@ const baseRoyaleGame = defineMultiplayerGame<RoyaleGameContract>({
|
||||
},
|
||||
});
|
||||
|
||||
export const royaleGame = withSpatialReplication(baseRoyaleGame, {
|
||||
const streamedRoyaleGame = withInputStream(baseRoyaleGame, {
|
||||
heartbeatRateHz: 20,
|
||||
timeoutMs: 400,
|
||||
inputsEqual(left, right) {
|
||||
return (
|
||||
left.forward === right.forward &&
|
||||
left.strafe === right.strafe &&
|
||||
left.yaw === right.yaw &&
|
||||
left.pitch === right.pitch &&
|
||||
left.fire === right.fire &&
|
||||
left.sprint === right.sprint &&
|
||||
left.reload === right.reload
|
||||
);
|
||||
},
|
||||
neutralize(lastInput) {
|
||||
return {
|
||||
...lastInput,
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
};
|
||||
},
|
||||
resume(lastInput) {
|
||||
return { ...lastInput, reload: false };
|
||||
},
|
||||
});
|
||||
|
||||
export const royaleGame = withSpatialReplication(streamedRoyaleGame, {
|
||||
cellSize: ROYALE_CHUNK_SIZE,
|
||||
bandwidthBudgetBytesPerSecond: 30_000,
|
||||
reservedBytesPerSnapshot: 300,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
defineNetworkedGame,
|
||||
deterministicHash,
|
||||
withInputStream,
|
||||
withLagCompensation,
|
||||
withReplayTransport,
|
||||
withTimeTravel,
|
||||
@@ -242,7 +243,41 @@ const shooterDefinition = defineNetworkedGame<
|
||||
},
|
||||
});
|
||||
|
||||
const lagCompensatedShooterDefinition = withLagCompensation(shooterDefinition, {
|
||||
const streamedShooterDefinition = withInputStream(shooterDefinition, {
|
||||
heartbeatRateHz: 20,
|
||||
timeoutMs: 400,
|
||||
inputsEqual(left, right) {
|
||||
return (
|
||||
left.strafe === right.strafe &&
|
||||
left.forward === right.forward &&
|
||||
left.yaw === right.yaw &&
|
||||
left.pitch === right.pitch &&
|
||||
left.fire === right.fire &&
|
||||
left.sprint === right.sprint &&
|
||||
left.reload === right.reload &&
|
||||
left.weapon === right.weapon
|
||||
);
|
||||
},
|
||||
neutralize(lastInput) {
|
||||
return {
|
||||
...lastInput,
|
||||
strafe: 0,
|
||||
forward: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
};
|
||||
},
|
||||
resume(lastInput) {
|
||||
return {
|
||||
...lastInput,
|
||||
fire: lastInput.fire && WEAPONS[lastInput.weapon].automatic,
|
||||
reload: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const lagCompensatedShooterDefinition = withLagCompensation(streamedShooterDefinition, {
|
||||
historySeconds: 0.5,
|
||||
captureState(state) {
|
||||
return {
|
||||
|
||||
@@ -22,12 +22,16 @@ test("Flux Relay is a complete game defined through the public authoring API", (
|
||||
|
||||
const server = fluxGame.createServer();
|
||||
server.addPlayer(1);
|
||||
assert.deepEqual(server.submitInput(1, createThrustPacket(1, 1)), {
|
||||
const thrustPacket = createThrustPacket(1, 1);
|
||||
assert.deepEqual(server.submitInput(1, thrustPacket), {
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for (let tick = 0; tick < 180; tick += 1) {
|
||||
if (tick > 0 && tick % 6 === 0) {
|
||||
assert.deepEqual(server.submitInput(1, thrustPacket), { accepted: true });
|
||||
}
|
||||
events.push(...server.step().events);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user