903 lines
25 KiB
JavaScript
903 lines
25 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
FixedStepClock,
|
|
NetworkClock,
|
|
ReplayDivergenceError,
|
|
SpatialGridIndex,
|
|
createBinaryProtocol,
|
|
createJsonCodec,
|
|
deterministicHash,
|
|
defineGame,
|
|
defineMultiplayerGame,
|
|
defineNetworkedGame,
|
|
withLagCompensation,
|
|
withReplayTransport,
|
|
withSpatialReplication,
|
|
withTimeTravel,
|
|
} from "../dist/index.js";
|
|
|
|
test("spatial replication indexes, prioritizes, budgets, and projects per viewer", () => {
|
|
const grid = new SpatialGridIndex(10);
|
|
grid.insert({ id: "negative", x: -4, z: -7 });
|
|
grid.insert({ id: "near", x: 6, z: 2 });
|
|
grid.insert({ id: "far", x: 40, z: 40 });
|
|
assert.deepEqual(
|
|
grid.query({ x: 0, z: 0 }, 10).map(({ id }) => id).sort(),
|
|
["near", "negative"],
|
|
);
|
|
|
|
const codec = createJsonCodec();
|
|
const base = defineMultiplayerGame({
|
|
clock: { ticksPerSecond: 10, snapshotsPerSecond: 10 },
|
|
authority: {
|
|
createInitialState: () => ({
|
|
entities: [
|
|
{ id: 1, x: 0, z: 0 },
|
|
{ id: 2, x: 10, z: 0 },
|
|
{ id: 3, x: 20, z: 0 },
|
|
{ id: 4, x: 200, z: 0 },
|
|
],
|
|
}),
|
|
cloneState: (state) => ({ entities: state.entities.map((entity) => ({ ...entity })) }),
|
|
applyInput() {},
|
|
step() {},
|
|
},
|
|
prediction: {
|
|
createInitialState: () => ({ entities: [], events: [] }),
|
|
cloneState: (state) => ({ entities: state.entities.map((entity) => ({ ...entity })), events: [...state.events] }),
|
|
applyInput() {},
|
|
step() {},
|
|
applyEvent(state, event) {
|
|
state.events.push(event);
|
|
},
|
|
},
|
|
visibility: {
|
|
createSnapshot: (authority) => ({
|
|
entities: authority.entities.map((entity) => ({ ...entity })),
|
|
events: [],
|
|
}),
|
|
perceive: (_authority, event) => event,
|
|
},
|
|
input: { validate: () => true },
|
|
encoding: { input: codec, clientState: codec, perception: codec },
|
|
});
|
|
const game = withSpatialReplication(base, {
|
|
cellSize: 16,
|
|
bandwidthBudgetBytesPerSecond: 1_000,
|
|
viewerPosition: (authority, playerId) => authority.entities.find(({ id }) => id === playerId) ?? null,
|
|
sources: [
|
|
{
|
|
category: "entities",
|
|
maximumDistance: 100,
|
|
entities: (authority) => authority.entities,
|
|
estimatedBytes: 40,
|
|
required: (entity, playerId) => entity.id === playerId,
|
|
},
|
|
],
|
|
projectSnapshot(snapshot, selection) {
|
|
return {
|
|
...snapshot,
|
|
entities: snapshot.entities.filter((entity) => selection.has("entities", entity.id)),
|
|
};
|
|
},
|
|
perceive(_authority, event, perception, selection) {
|
|
return selection.has("entities", event.sourceId) ? perception : null;
|
|
},
|
|
});
|
|
|
|
const server = game.createServer();
|
|
server.addPlayer(1);
|
|
assert.deepEqual(
|
|
server.createSnapshot(1, 0).state.entities.map(({ id }) => id),
|
|
[1, 2],
|
|
);
|
|
const selection = game.spatialReplication.select(server.currentState, {
|
|
playerId: 1,
|
|
tick: server.tick,
|
|
});
|
|
assert.equal(selection.budgetBytes, 100);
|
|
assert.equal(selection.usedBytes, 80);
|
|
assert.equal(selection.candidateCount, 3);
|
|
assert.equal(selection.droppedCount, 1);
|
|
assert.deepEqual(server.createPerceptions(1, [{ sourceId: 2 }]), [{ sourceId: 2 }]);
|
|
assert.deepEqual(server.createPerceptions(1, [{ sourceId: 3 }]), []);
|
|
});
|
|
|
|
test("the developer-facing API compiles named sections into a networked game", () => {
|
|
const codec = createJsonCodec();
|
|
const game = defineMultiplayerGame({
|
|
clock: { ticksPerSecond: 12, snapshotsPerSecond: 4 },
|
|
authority: {
|
|
createInitialState: () => ({ value: 0 }),
|
|
cloneState: (state) => ({ ...state }),
|
|
applyInput(state, input) {
|
|
state.value += input.amount;
|
|
},
|
|
step() {},
|
|
},
|
|
prediction: {
|
|
createInitialState: () => ({ value: 0 }),
|
|
cloneState: (state) => ({ ...state }),
|
|
applyInput(state, input) {
|
|
state.value += input.amount;
|
|
},
|
|
step() {},
|
|
},
|
|
visibility: {
|
|
createSnapshot: (authority) => ({ value: authority.value }),
|
|
},
|
|
input: {
|
|
validate: (input) => Number.isFinite(input.amount),
|
|
},
|
|
encoding: {
|
|
input: codec,
|
|
clientState: codec,
|
|
},
|
|
});
|
|
|
|
assert.equal(game.tickRateHz, 12);
|
|
assert.equal(game.snapshotRateHz, 4);
|
|
const server = game.createServer();
|
|
server.addPlayer(1);
|
|
assert.equal(
|
|
server.submitInput(1, {
|
|
sequence: 1,
|
|
targetTick: 1,
|
|
observedTick: 0,
|
|
input: { amount: 3 },
|
|
}).accepted,
|
|
true,
|
|
);
|
|
server.step();
|
|
assert.equal(server.currentState.value, 3);
|
|
|
|
// Internet clients can be several ticks behind the server when their first
|
|
// input arrives. Accept it within the default one-second transport window
|
|
// and apply it on the next authoritative step.
|
|
for (let index = 0; index < 6; index++) server.step();
|
|
assert.deepEqual(
|
|
server.submitInput(1, {
|
|
sequence: 2,
|
|
targetTick: 2,
|
|
observedTick: 2,
|
|
input: { amount: 2 },
|
|
}),
|
|
{ accepted: true },
|
|
);
|
|
server.step();
|
|
assert.equal(server.currentState.value, 5);
|
|
|
|
for (let index = 0; index < 7; index++) server.step();
|
|
assert.deepEqual(
|
|
server.submitInput(1, {
|
|
sequence: 3,
|
|
targetTick: 2,
|
|
observedTick: 2,
|
|
input: { amount: 2 },
|
|
}),
|
|
{ accepted: false, reason: "past" },
|
|
);
|
|
|
|
assert.deepEqual(codec.decode(codec.encode({ unicode: "flux ⚡" })), {
|
|
unicode: "flux ⚡",
|
|
});
|
|
});
|
|
|
|
const testGame = defineGame({
|
|
tickRateHz: 10,
|
|
snapshotRateHz: 5,
|
|
createInitialState: () => ({ position: 0, velocity: 0 }),
|
|
cloneState: (state) => ({ ...state }),
|
|
validateInput: (input) =>
|
|
Number.isFinite(input.axis) && Math.abs(input.axis) <= 1,
|
|
applyInput: (state, input) => {
|
|
state.velocity = input.axis;
|
|
},
|
|
step: (state, { deltaSeconds }) => {
|
|
state.position += state.velocity * deltaSeconds;
|
|
},
|
|
validateState: (state) =>
|
|
Number.isFinite(state.position) && Number.isFinite(state.velocity),
|
|
validateClientState: (authoritative, candidate) =>
|
|
Math.abs(authoritative.position - candidate.position) < 0.000_001,
|
|
});
|
|
|
|
test("defineGame creates matching authoritative and predicted runtimes", () => {
|
|
const server = testGame.createServer();
|
|
const client = testGame.createClient();
|
|
|
|
server.addPlayer(7);
|
|
client.initialize(7, server.createSnapshot(0));
|
|
|
|
const packet = client.createInput({ axis: 1 }, 1);
|
|
assert.deepEqual(server.submitInput(7, packet), { accepted: true });
|
|
|
|
client.step();
|
|
const result = server.step();
|
|
|
|
assert.equal(result.snapshotDue, false);
|
|
assert.deepEqual(result.acknowledgements, [
|
|
{ playerId: 7, sequence: packet.sequence },
|
|
]);
|
|
|
|
client.acknowledge(packet.sequence);
|
|
client.reconcile(server.createSnapshot(100));
|
|
assert.equal(client.currentState.position, server.currentState.position);
|
|
|
|
const valid = server.submitStateReport(7, client.createStateReport());
|
|
assert.equal(valid?.valid, true);
|
|
|
|
const invalid = server.submitStateReport(7, {
|
|
tick: server.tick,
|
|
state: { position: 99, velocity: 0 },
|
|
});
|
|
assert.equal(invalid?.valid, false);
|
|
|
|
assert.deepEqual(
|
|
server.submitInput(7, {
|
|
sequence: packet.sequence + 1,
|
|
targetTick: server.tick + 1,
|
|
input: { axis: 2 },
|
|
}),
|
|
{ accepted: false, reason: "invalid" },
|
|
);
|
|
});
|
|
|
|
test("fixed-step clocks cap catch-up work", () => {
|
|
const clock = new FixedStepClock({ rateHz: 10, maxCatchUpSteps: 3 });
|
|
let steps = 0;
|
|
|
|
assert.equal(clock.advance(0, () => steps++), 0);
|
|
assert.equal(clock.advance(100, () => steps++), 1);
|
|
assert.equal(clock.advance(1_000, () => steps++), 3);
|
|
assert.equal(steps, 4);
|
|
});
|
|
|
|
test("network clock estimates RTT, offset, and input lead", () => {
|
|
const clock = new NetworkClock();
|
|
const ping = clock.createPing(100);
|
|
const stats = clock.receivePong(
|
|
{
|
|
...ping,
|
|
serverReceivedAt: 160,
|
|
serverSentAt: 162,
|
|
},
|
|
202,
|
|
);
|
|
|
|
assert.equal(stats.roundTripTime, 100);
|
|
assert.equal(stats.clockOffset, 10);
|
|
assert.equal(clock.toServerTime(300), 310);
|
|
assert.equal(clock.recommendedInputLeadTicks(60), 4);
|
|
});
|
|
|
|
test("binary protocol round-trips generic inputs, state, and control frames", () => {
|
|
const numberCodec = {
|
|
encode(value) {
|
|
const payload = new Uint8Array(8);
|
|
new DataView(payload.buffer).setFloat64(0, value, true);
|
|
return payload;
|
|
},
|
|
decode(payload) {
|
|
return new DataView(
|
|
payload.buffer,
|
|
payload.byteOffset,
|
|
payload.byteLength,
|
|
).getFloat64(0, true);
|
|
},
|
|
};
|
|
const protocol = createBinaryProtocol({
|
|
input: numberCodec,
|
|
state: numberCodec,
|
|
event: numberCodec,
|
|
});
|
|
|
|
const input = {
|
|
kind: "input",
|
|
packet: {
|
|
sequence: 4,
|
|
targetTick: 20,
|
|
observedTick: 16,
|
|
input: 0.75,
|
|
},
|
|
};
|
|
assert.deepEqual(protocol.decodeClient(protocol.encodeClient(input)), input);
|
|
|
|
const welcome = {
|
|
kind: "welcome",
|
|
playerId: 9,
|
|
snapshot: { tick: 18, serverTime: 42.5, state: 3.25 },
|
|
};
|
|
assert.deepEqual(protocol.decodeServer(protocol.encodeServer(welcome)), welcome);
|
|
|
|
const rejection = { kind: "reject-input", sequence: 44 };
|
|
assert.deepEqual(
|
|
protocol.decodeServer(protocol.encodeServer(rejection)),
|
|
rejection,
|
|
);
|
|
|
|
const replayStart = {
|
|
kind: "replay-start",
|
|
ticketId: 12,
|
|
perspectiveId: 9,
|
|
fromTick: 100,
|
|
toTick: 160,
|
|
frameCount: 2,
|
|
playbackRate: 0.75,
|
|
};
|
|
assert.deepEqual(
|
|
protocol.decodeServer(protocol.encodeServer(replayStart)),
|
|
replayStart,
|
|
);
|
|
|
|
const replayFrame = {
|
|
kind: "replay-frame",
|
|
ticketId: 12,
|
|
tick: 120,
|
|
state: 3.5,
|
|
events: [4.5, 5.5],
|
|
};
|
|
assert.deepEqual(
|
|
protocol.decodeServer(protocol.encodeServer(replayFrame)),
|
|
replayFrame,
|
|
);
|
|
const truncatedReplayFrame = protocol.encodeServer(replayFrame).slice(0, -1);
|
|
assert.throws(() => protocol.decodeServer(truncatedReplayFrame), RangeError);
|
|
|
|
const replayEnd = { kind: "replay-end", ticketId: 12 };
|
|
assert.deepEqual(
|
|
protocol.decodeServer(protocol.encodeServer(replayEnd)),
|
|
replayEnd,
|
|
);
|
|
});
|
|
|
|
test("networked games keep authority private and derive viewer perceptions", () => {
|
|
const jsonCodec = {
|
|
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
|
|
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
|
|
};
|
|
const privateGame = defineNetworkedGame({
|
|
tickRateHz: 10,
|
|
snapshotRateHz: 5,
|
|
server: {
|
|
createInitialState: () => ({
|
|
publicValues: new Map(),
|
|
secrets: new Map(),
|
|
}),
|
|
cloneState: (state) => ({
|
|
publicValues: new Map(state.publicValues),
|
|
secrets: new Map(state.secrets),
|
|
}),
|
|
addPlayer(state, { playerId }) {
|
|
state.publicValues.set(playerId, 0);
|
|
state.secrets.set(playerId, `secret-${playerId}`);
|
|
},
|
|
applyInput(state, input, { playerId, emit }) {
|
|
state.publicValues.set(playerId, input);
|
|
emit({ source: playerId, privateValue: state.secrets.get(playerId) });
|
|
},
|
|
step() {},
|
|
},
|
|
client: {
|
|
createInitialState: () => ({ ownValue: 0, cues: [] }),
|
|
cloneState: (state) => ({ ...state, cues: [...state.cues] }),
|
|
applyInput(state, input) {
|
|
state.ownValue = input;
|
|
},
|
|
step() {},
|
|
applyEvent(state, event) {
|
|
state.cues.push(event);
|
|
},
|
|
},
|
|
replication: {
|
|
createSnapshot(authority, { playerId }) {
|
|
return {
|
|
ownValue: authority.publicValues.get(playerId) ?? 0,
|
|
cues: [],
|
|
};
|
|
},
|
|
validateClientState(authority, candidate, { playerId }) {
|
|
return candidate.ownValue === authority.publicValues.get(playerId);
|
|
},
|
|
perceive(_authority, event, { playerId }) {
|
|
return event.source === playerId ? null : "heard-an-event";
|
|
},
|
|
},
|
|
validateInput: (input) => Number.isFinite(input),
|
|
codecs: { input: jsonCodec, state: jsonCodec, event: jsonCodec },
|
|
});
|
|
|
|
const server = privateGame.createServer();
|
|
server.addPlayer(1);
|
|
server.addPlayer(2);
|
|
|
|
const snapshot = server.createSnapshot(1, 0);
|
|
assert.deepEqual(snapshot.state, { ownValue: 0, cues: [] });
|
|
assert.equal("secrets" in snapshot.state, false);
|
|
|
|
assert.equal(
|
|
server.submitInput(2, { sequence: 1, targetTick: 1, input: 5 }).accepted,
|
|
true,
|
|
);
|
|
const result = server.step();
|
|
assert.deepEqual(server.createPerceptions(1, result.events), [
|
|
"heard-an-event",
|
|
]);
|
|
assert.deepEqual(server.createPerceptions(2, result.events), []);
|
|
|
|
const client = privateGame.createClient();
|
|
client.initialize(1, snapshot);
|
|
client.receiveEvent("heard-an-event", result.tick);
|
|
assert.deepEqual(client.currentState.cues, ["heard-an-event"]);
|
|
|
|
const eventFrame = privateGame.protocol.encodeServer({
|
|
kind: "event",
|
|
tick: result.tick,
|
|
event: "heard-an-event",
|
|
});
|
|
assert.deepEqual(privateGame.protocol.decodeServer(eventFrame), {
|
|
kind: "event",
|
|
tick: result.tick,
|
|
event: "heard-an-event",
|
|
});
|
|
});
|
|
|
|
const lagJsonCodec = {
|
|
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
|
|
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
|
|
};
|
|
|
|
const lagBaseGame = defineNetworkedGame({
|
|
tickRateHz: 10,
|
|
snapshotRateHz: 5,
|
|
server: {
|
|
createInitialState: () => ({ positions: {}, resolutions: [] }),
|
|
cloneState: (state) => ({
|
|
positions: { ...state.positions },
|
|
resolutions: state.resolutions.map((entry) => ({ ...entry })),
|
|
}),
|
|
addPlayer(state, { playerId }) {
|
|
state.positions[playerId] = 0;
|
|
},
|
|
applyInput(state, input, { playerId }) {
|
|
if (input.moveTo !== undefined) state.positions[playerId] = input.moveTo;
|
|
},
|
|
step() {},
|
|
},
|
|
client: {
|
|
createInitialState: () => ({ positions: {}, resolutions: [] }),
|
|
cloneState: (state) => structuredClone(state),
|
|
applyInput() {},
|
|
step() {},
|
|
},
|
|
replication: {
|
|
createSnapshot: (state) => structuredClone(state),
|
|
},
|
|
validateInput: (input) =>
|
|
(input.moveTo === undefined || Number.isFinite(input.moveTo)) &&
|
|
(input.action === undefined ||
|
|
["rewind", "current", "projectile", "strict"].includes(input.action)),
|
|
codecs: { input: lagJsonCodec, state: lagJsonCodec, event: lagJsonCodec },
|
|
});
|
|
|
|
const lagPolicyGame = withLagCompensation(lagBaseGame, {
|
|
historySeconds: 2,
|
|
captureState: (state) => ({ positions: { ...state.positions } }),
|
|
cloneHistoricalState: (state) => ({ positions: { ...state.positions } }),
|
|
classifyAction: (input) =>
|
|
input.action ? { type: input.action, targetId: input.targetId ?? 2 } : null,
|
|
cloneAction: (action) => ({ ...action }),
|
|
actions: {
|
|
rewind: {
|
|
mode: "rewind",
|
|
maximumRewindMs: 1_000,
|
|
resolve: recordLagResolution,
|
|
},
|
|
current: {
|
|
mode: "current",
|
|
resolve: recordLagResolution,
|
|
},
|
|
projectile: {
|
|
mode: "fast-forward",
|
|
maximumRewindMs: 1_000,
|
|
resolve: recordLagResolution,
|
|
},
|
|
strict: {
|
|
mode: "rewind",
|
|
maximumRewindMs: 100,
|
|
outOfWindow: "reject",
|
|
resolve: recordLagResolution,
|
|
},
|
|
},
|
|
});
|
|
|
|
const lagReplayGame = withTimeTravel(lagPolicyGame, {
|
|
createSeed: () => 0,
|
|
hashState: deterministicHash,
|
|
checkpointIntervalTicks: 2,
|
|
verificationIntervalTicks: 1,
|
|
});
|
|
|
|
function recordLagResolution(context) {
|
|
context.currentState.resolutions.push({
|
|
type: context.action.type,
|
|
requestedTick: context.requestedTick,
|
|
resolvedTick: context.resolvedTick,
|
|
currentTick: context.currentTick,
|
|
rewindTicks: context.rewindTicks,
|
|
catchUpTicks: context.catchUpTicks,
|
|
historicalPosition:
|
|
context.historicalState.positions[context.action.targetId],
|
|
currentPosition: context.currentState.positions[context.action.targetId],
|
|
});
|
|
}
|
|
|
|
test("lag-compensation HOF applies an independent policy per action", () => {
|
|
const server = lagPolicyGame.createServer();
|
|
server.addPlayer(1);
|
|
server.addPlayer(2);
|
|
|
|
server.submitInput(2, {
|
|
sequence: 1,
|
|
targetTick: 1,
|
|
observedTick: 0,
|
|
input: { moveTo: 5 },
|
|
});
|
|
server.step();
|
|
server.step();
|
|
|
|
server.submitInput(1, {
|
|
sequence: 1,
|
|
targetTick: 3,
|
|
observedTick: 0,
|
|
input: { action: "rewind", targetId: 2 },
|
|
});
|
|
server.step();
|
|
server.submitInput(1, {
|
|
sequence: 2,
|
|
targetTick: 4,
|
|
observedTick: 0,
|
|
input: { action: "current", targetId: 2 },
|
|
});
|
|
server.step();
|
|
server.submitInput(1, {
|
|
sequence: 3,
|
|
targetTick: 5,
|
|
observedTick: 2,
|
|
input: { action: "projectile", targetId: 2 },
|
|
});
|
|
server.step();
|
|
server.submitInput(1, {
|
|
sequence: 4,
|
|
targetTick: 6,
|
|
observedTick: 0,
|
|
input: { action: "strict", targetId: 2 },
|
|
});
|
|
server.step();
|
|
|
|
assert.equal(server.currentState.positions[2], 5);
|
|
assert.deepEqual(server.currentState.resolutions, [
|
|
{
|
|
type: "rewind",
|
|
requestedTick: 0,
|
|
resolvedTick: 0,
|
|
currentTick: 3,
|
|
rewindTicks: 3,
|
|
catchUpTicks: 0,
|
|
historicalPosition: 0,
|
|
currentPosition: 5,
|
|
},
|
|
{
|
|
type: "current",
|
|
requestedTick: 0,
|
|
resolvedTick: 4,
|
|
currentTick: 4,
|
|
rewindTicks: 0,
|
|
catchUpTicks: 0,
|
|
historicalPosition: 5,
|
|
currentPosition: 5,
|
|
},
|
|
{
|
|
type: "projectile",
|
|
requestedTick: 2,
|
|
resolvedTick: 2,
|
|
currentTick: 5,
|
|
rewindTicks: 3,
|
|
catchUpTicks: 3,
|
|
historicalPosition: 5,
|
|
currentPosition: 5,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("lag-compensated actions replay deterministically through checkpoints", () => {
|
|
const server = lagReplayGame.createServer();
|
|
server.addPlayer(1);
|
|
server.addPlayer(2);
|
|
server.submitInput(2, {
|
|
sequence: 1,
|
|
targetTick: 1,
|
|
observedTick: 0,
|
|
input: { moveTo: 5 },
|
|
});
|
|
server.step();
|
|
server.step();
|
|
server.submitInput(1, {
|
|
sequence: 1,
|
|
targetTick: 3,
|
|
observedTick: 0,
|
|
input: { action: "rewind", targetId: 2 },
|
|
});
|
|
for (let tick = 2; tick < 6; tick += 1) server.step();
|
|
|
|
const replay = lagReplayGame.createReplay(server.exportRecording());
|
|
assert.deepEqual(replay.seek(6).state, server.currentState);
|
|
assert.equal(replay.currentState.resolutions[0].historicalPosition, 0);
|
|
});
|
|
|
|
const replayCodec = {
|
|
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
|
|
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
|
|
};
|
|
|
|
const replayDefinition = defineNetworkedGame({
|
|
tickRateHz: 10,
|
|
snapshotRateHz: 5,
|
|
server: {
|
|
createInitialState: () => ({
|
|
seed: 0,
|
|
secret: "uninitialized",
|
|
positions: new Map(),
|
|
velocities: new Map(),
|
|
}),
|
|
cloneState: (state) => ({
|
|
...state,
|
|
positions: new Map(state.positions),
|
|
velocities: new Map(state.velocities),
|
|
}),
|
|
addPlayer(state, { playerId, emit }) {
|
|
state.positions.set(playerId, state.seed);
|
|
state.velocities.set(playerId, 0);
|
|
emit({ type: "joined", playerId });
|
|
},
|
|
removePlayer(state, { playerId }) {
|
|
state.positions.delete(playerId);
|
|
state.velocities.delete(playerId);
|
|
},
|
|
applyInput(state, input, { playerId, emit }) {
|
|
state.velocities.set(playerId, input.axis);
|
|
emit({ type: "moved", playerId });
|
|
},
|
|
step(state, { deltaSeconds }) {
|
|
for (const [playerId, velocity] of state.velocities) {
|
|
state.positions.set(
|
|
playerId,
|
|
(state.positions.get(playerId) ?? 0) + velocity * deltaSeconds,
|
|
);
|
|
}
|
|
},
|
|
},
|
|
client: {
|
|
createInitialState: () => ({ ownPosition: 0, cues: [] }),
|
|
cloneState: (state) => ({ ...state, cues: [...state.cues] }),
|
|
applyInput(state, input) {
|
|
state.ownPosition += input.axis / 10;
|
|
},
|
|
step() {},
|
|
applyEvent(state, event) {
|
|
state.cues.push(event);
|
|
},
|
|
},
|
|
replication: {
|
|
createSnapshot(authority, { playerId }) {
|
|
return {
|
|
ownPosition: authority.positions.get(playerId) ?? 0,
|
|
cues: [],
|
|
};
|
|
},
|
|
perceive(_authority, event, { playerId }) {
|
|
return event.playerId === playerId ? null : `heard-${event.type}`;
|
|
},
|
|
},
|
|
validateInput: (input) =>
|
|
Number.isFinite(input.axis) && Math.abs(input.axis) <= 1,
|
|
codecs: {
|
|
input: replayCodec,
|
|
state: replayCodec,
|
|
event: replayCodec,
|
|
},
|
|
});
|
|
|
|
const replayGame = withTimeTravel(replayDefinition, {
|
|
createSeed: () => 1,
|
|
initializeState(state, seed) {
|
|
state.seed = seed;
|
|
state.secret = `secret-${seed}`;
|
|
},
|
|
hashState: deterministicHash,
|
|
checkpointIntervalTicks: 2,
|
|
verificationIntervalTicks: 1,
|
|
});
|
|
|
|
const replayTransportGame = withReplayTransport(replayGame, {
|
|
historySeconds: 0.6,
|
|
captureRateHz: 5,
|
|
createTickets(event, { tick }) {
|
|
return event.type === "joined" && event.playerId === 2
|
|
? {
|
|
requesterId: 1,
|
|
perspectiveId: 2,
|
|
fromTick: 0,
|
|
toTick: tick,
|
|
}
|
|
: null;
|
|
},
|
|
authorizeReplay: ({ requesterId, perspectiveId }) =>
|
|
requesterId === 1 && perspectiveId === 2,
|
|
});
|
|
|
|
test("time-travel HOF records, seeks, verifies, filters, and branches", () => {
|
|
const server = replayGame.createServer({ replaySeed: 7 });
|
|
server.addPlayer(1);
|
|
server.addPlayer(2);
|
|
assert.equal(
|
|
server.submitInput(1, {
|
|
sequence: 1,
|
|
targetTick: 1,
|
|
input: { axis: 1 },
|
|
}).accepted,
|
|
true,
|
|
);
|
|
assert.equal(
|
|
server.submitInput(2, {
|
|
sequence: 1,
|
|
targetTick: 1,
|
|
input: { axis: -0.5 },
|
|
}).accepted,
|
|
true,
|
|
);
|
|
|
|
for (let tick = 0; tick < 3; tick += 1) server.step();
|
|
assert.equal(
|
|
server.submitInput(1, {
|
|
sequence: 2,
|
|
targetTick: 5,
|
|
input: { axis: 0.25 },
|
|
}).accepted,
|
|
true,
|
|
);
|
|
for (let tick = 3; tick < 6; tick += 1) server.step();
|
|
|
|
const recording = server.exportRecording();
|
|
assert.equal(recording.seed, 7);
|
|
assert.equal(recording.durationTicks, 6);
|
|
assert.deepEqual(
|
|
recording.checkpoints.map(({ tick }) => tick),
|
|
[0, 2, 4, 6],
|
|
);
|
|
|
|
const replay = replayGame.createReplay(recording);
|
|
const finalFrame = replay.seek(6);
|
|
assert.deepEqual(finalFrame.state, server.currentState);
|
|
assert.equal(finalFrame.hash, deterministicHash(server.currentState));
|
|
|
|
const privateView = replay.viewAs(1, 123);
|
|
assert.equal(privateView.tick, 6);
|
|
assert.equal("secret" in privateView.state, false);
|
|
assert.deepEqual(Object.keys(privateView.state).sort(), ["cues", "ownPosition"]);
|
|
|
|
replay.seek(1);
|
|
assert.deepEqual(replay.perceptionsAs(1), ["heard-joined", "heard-moved"]);
|
|
const rewindPosition = replay.currentState.positions.get(1);
|
|
replay.seek(6);
|
|
assert.notEqual(replay.currentState.positions.get(1), rewindPosition);
|
|
|
|
const branch = replay.branch(2);
|
|
assert.equal(
|
|
branch.submitInput(1, {
|
|
sequence: 2,
|
|
targetTick: 3,
|
|
input: { axis: -1 },
|
|
}).accepted,
|
|
true,
|
|
);
|
|
while (branch.tick < 6) branch.step();
|
|
assert.notEqual(
|
|
deterministicHash(branch.currentState),
|
|
deterministicHash(server.currentState),
|
|
);
|
|
|
|
const branchReplay = replayGame.createReplay(branch.exportRecording());
|
|
assert.deepEqual(branchReplay.seek(6).state, branch.currentState);
|
|
assert.deepEqual(replay.seek(6).state, server.currentState);
|
|
});
|
|
|
|
test("time-travel replay pinpoints deterministic divergence", () => {
|
|
const server = replayGame.createServer({ replaySeed: 4 });
|
|
server.addPlayer(1);
|
|
server.submitInput(1, {
|
|
sequence: 1,
|
|
targetTick: 1,
|
|
input: { axis: 1 },
|
|
});
|
|
for (let tick = 0; tick < 3; tick += 1) server.step();
|
|
|
|
const recording = server.exportRecording();
|
|
const corrupted = {
|
|
...recording,
|
|
verifications: recording.verifications.map((entry) =>
|
|
entry.tick === 3 ? { ...entry, hash: "corrupted" } : entry,
|
|
),
|
|
};
|
|
const replay = replayGame.createReplay(corrupted);
|
|
assert.throws(() => replay.seek(3), ReplayDivergenceError);
|
|
});
|
|
|
|
test("rolling authority recordings stay bounded and remain seekable", () => {
|
|
const server = replayGame.createServer({
|
|
replaySeed: 8,
|
|
recordingHistoryTicks: 4,
|
|
});
|
|
server.addPlayer(1);
|
|
for (let tick = 0; tick < 10; tick += 1) server.step();
|
|
|
|
const recording = server.exportRecording();
|
|
assert.equal(recording.startTick, 6);
|
|
assert.equal(recording.durationTicks, 10);
|
|
assert.ok(recording.verifications.every(({ tick }) => tick >= 6));
|
|
assert.ok(recording.checkpoints.every(({ tick }) => tick >= 6));
|
|
|
|
const replay = replayGame.createReplay(recording);
|
|
assert.equal(replay.startTick, 6);
|
|
assert.deepEqual(replay.seek(10).state, server.currentState);
|
|
assert.throws(() => replay.seek(5), RangeError);
|
|
});
|
|
|
|
test("replay transport issues only authorized projected frames from a bounded ring", () => {
|
|
const server = replayTransportGame.createServer({ replaySeed: 11 });
|
|
server.addPlayer(1);
|
|
server.addPlayer(2);
|
|
for (let tick = 0; tick < 12; tick += 1) server.step();
|
|
|
|
const automaticTickets = server.drainReplayTickets(1);
|
|
assert.equal(automaticTickets.length, 1);
|
|
assert.equal(automaticTickets[0].perspectiveId, 2);
|
|
|
|
assert.equal(
|
|
server.issueReplay({
|
|
requesterId: 1,
|
|
perspectiveId: 1,
|
|
fromTick: 0,
|
|
toTick: 12,
|
|
}),
|
|
null,
|
|
);
|
|
|
|
const ticket = server.issueReplay({
|
|
requesterId: 1,
|
|
perspectiveId: 2,
|
|
fromTick: 0,
|
|
toTick: 12,
|
|
playbackRate: 0.5,
|
|
});
|
|
assert.ok(ticket);
|
|
assert.equal(ticket.requesterId, 1);
|
|
assert.equal(ticket.perspectiveId, 2);
|
|
assert.equal(ticket.fromTick, 6);
|
|
assert.equal(ticket.toTick, 12);
|
|
assert.equal(ticket.playbackRate, 0.5);
|
|
assert.ok(ticket.frames.length > 1);
|
|
assert.ok(
|
|
ticket.frames.every(
|
|
({ state }) =>
|
|
!Object.hasOwn(state, "secret") &&
|
|
Object.keys(state).sort().join(",") === "cues,ownPosition",
|
|
),
|
|
);
|
|
|
|
assert.deepEqual(server.drainReplayTickets(1), [ticket]);
|
|
assert.deepEqual(server.drainReplayTickets(1), []);
|
|
assert.equal(server.exportRecording().startTick, 6);
|
|
});
|