add generic stateful input streaming
All checks were successful
build / image (push) Successful in 50s

This commit is contained in:
Syncer Deploy
2026-08-28 11:27:53 -03:00
parent 0bf2b95755
commit 96b73922cc
15 changed files with 767 additions and 81 deletions

View File

@@ -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", () => {