Files
syncer/packages/engine/test/engine.test.mjs
Syncer Deploy 96b73922cc
All checks were successful
build / image (push) Successful in 50s
add generic stateful input streaming
2026-08-28 11:27:53 -03:00

1057 lines
30 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
FixedStepClock,
NetworkClock,
ReplayDivergenceError,
SpatialGridIndex,
createBinaryProtocol,
createInputStateStream,
createJsonCodec,
deterministicHash,
defineGame,
defineMultiplayerGame,
defineNetworkedGame,
withLagCompensation,
withInputStream,
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);
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", () => {
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);
});