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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user