feat(chess): add client prediction + server reconciliation (P4.10)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 17:48:33 -06:00
commit 721cc5484d
No known key found for this signature in database
2 changed files with 703 additions and 0 deletions

View file

@ -0,0 +1,482 @@
// Tests for PredictionManager. We stub GameClient with a minimal event-capable
// double so every assertion is deterministic — no sockets, no timers.
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { AttrKey, EntityId, FactValue } from "@paratype/rete";
import { ChessEngine } from "../engine.js";
import { squareToAlgebraic, algebraicToSquare } from "../coord.js";
import { GAME_ENTITY } from "../schema.js";
import { PredictionManager } from "./prediction.js";
import type { GameClient, GameClientEvent, GameClientEventType } from "./client.js";
import type {
Fact,
GameDeltaPayload,
GameStatePayload,
PromotionPiece,
} from "./types.js";
// ---------------------------------------------------------------------------
// Mock GameClient
// ---------------------------------------------------------------------------
//
// Only the surface PredictionManager touches: `on(type, listener)` and
// `sendMove(from, to, promoteTo?)`. Each `emit*` helper shoves an event into
// the stored listeners synchronously so we can test reconciliation inline,
// or via `setTimeout` to simulate server round-trip latency.
interface SentMove {
from: string;
to: string;
promoteTo?: PromotionPiece;
}
type Listener = (event: GameClientEvent) => void;
class MockGameClient {
private readonly listeners = new Map<GameClientEventType, Listener[]>();
readonly sentMoves: SentMove[] = [];
on<T extends GameClientEventType>(
type: T,
listener: (event: Extract<GameClientEvent, { type: T }>) => void,
): void {
const arr = this.listeners.get(type);
const cast = listener as unknown as Listener;
if (arr) arr.push(cast);
else this.listeners.set(type, [cast]);
}
sendMove(from: string, to: string, promoteTo?: PromotionPiece): void {
this.sentMoves.push(
promoteTo !== undefined ? { from, to, promoteTo } : { from, to },
);
}
emitState(payload: GameStatePayload): void {
this.dispatch({ type: "game.state", payload });
}
emitDelta(payload: GameDeltaPayload): void {
this.dispatch({ type: "game.delta", payload });
}
emitError(payload: { code: string; message: string; fatal: boolean }): void {
this.dispatch({ type: "error", payload });
}
private dispatch(event: GameClientEvent): void {
const arr = this.listeners.get(event.type);
if (!arr) return;
for (const l of arr.slice()) l(event);
}
/** Coerce to the `GameClient` type the PredictionManager constructor expects. */
asClient(): GameClient {
return this as unknown as GameClient;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Snapshot the starting position as a `GameStatePayload`. */
function startingStatePayload(): GameStatePayload {
const engine = new ChessEngine();
const facts = engine.session.allFacts().map((f) => ({
id: f.id as number,
attr: f.attr as string,
value: f.value,
})) satisfies Fact[];
return {
facts,
turn: "white",
lastSeq: 1,
moveHistory: [],
activeRules: [],
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
};
}
/**
* Compute the authoritative delta for playing a move on `from` (a snapshot of
* state before the move). Returns the inserted/retracted fact lists the server
* would broadcast. We compute it by diffing session.allFacts() before/after.
*/
function computeDelta(
fromFacts: Fact[],
to: ChessEngine,
moveNotation: string,
): GameDeltaPayload {
const toFacts = to.session.allFacts().map((f) => ({
id: f.id as number,
attr: f.attr as string,
value: f.value,
})) satisfies Fact[];
const key = (f: Fact) => `${f.id}:${f.attr}`;
const beforeMap = new Map(fromFacts.map((f) => [key(f), f]));
const afterMap = new Map(toFacts.map((f) => [key(f), f]));
const retracted: Fact[] = [];
const inserted: Fact[] = [];
for (const [k, f] of beforeMap) {
const a = afterMap.get(k);
if (a === undefined) {
retracted.push(f);
} else if (!Object.is(a.value, f.value)) {
// Value changed → retract + insert.
retracted.push(f);
inserted.push(a);
}
}
for (const [k, f] of afterMap) {
if (!beforeMap.has(k)) inserted.push(f);
}
return {
inserted,
retracted,
moveNotation,
turn: to.getCurrentTurn(),
gameOver: null,
};
}
/** Build a fresh engine loaded from the given facts (mirrors server state). */
function engineFromFacts(facts: Fact[]): ChessEngine {
const engine = new ChessEngine();
const existing = engine.session.allFacts().slice();
for (const f of existing) {
engine.session.retract(f.id, f.attr as AttrKey);
}
for (const f of facts) {
engine.session.insert(
f.id as EntityId,
f.attr as AttrKey,
f.value as FactValue,
);
}
return engine;
}
/** Find the square of the white piece at `algebraic` in a fact list. */
function findPositionFact(
facts: Fact[],
square: number,
): Fact | undefined {
return facts.find((f) => f.attr === "Position" && f.value === square);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("PredictionManager", () => {
let client: MockGameClient;
let listener: ReturnType<typeof vi.fn>;
let pm: PredictionManager;
beforeEach(() => {
client = new MockGameClient();
listener = vi.fn();
pm = new PredictionManager(client.asClient(), listener);
// Seed authoritative state with the FIDE starting position.
client.emitState(startingStatePayload());
listener.mockClear(); // ignore the initial state notification
});
it("applies a legal optimistic move, updates engine, and sends it", () => {
// e2 -> e4
const from = algebraicToSquare("e2");
const to = algebraicToSquare("e4");
const ok = pm.applyPrediction(from, to);
expect(ok).toBe(true);
expect(listener).toHaveBeenCalledTimes(1);
expect(client.sentMoves).toEqual([{ from: "e2", to: "e4" }]);
// Predicted engine should show the pawn on e4 and black to move.
const predicted = pm.getCurrentEngine();
expect(predicted.getCurrentTurn()).toBe("black");
const e4Fact = findPositionFact(predicted.session.allFacts(), to);
expect(e4Fact).toBeDefined();
// Base engine must NOT have moved (still shows pawn on e2, white to move).
const base = pm.getBaseEngine();
expect(base.getCurrentTurn()).toBe("white");
expect(findPositionFact(base.session.allFacts(), from)).toBeDefined();
expect(findPositionFact(base.session.allFacts(), to)).toBeUndefined();
// A pending move is recorded.
expect(pm.getPendingMoves()).toEqual([{ from, to }]);
});
it("drops an illegal optimistic move without mutating state or sending", () => {
// e2 -> e5 is not a legal opening move (two-square advance lands on e4).
const from = algebraicToSquare("e2");
const to = algebraicToSquare("e5");
const ok = pm.applyPrediction(from, to);
expect(ok).toBe(false);
expect(listener).not.toHaveBeenCalled();
expect(client.sentMoves).toEqual([]);
expect(pm.getPendingMoves()).toEqual([]);
// Predicted and base engines both still show the original position.
expect(pm.getCurrentEngine().getCurrentTurn()).toBe("white");
});
it("reconciles via game.delta: applies authoritative state, clears prediction", () => {
const from = algebraicToSquare("e2");
const to = algebraicToSquare("e4");
pm.applyPrediction(from, to);
listener.mockClear();
// Compute the server-authoritative delta the server would broadcast.
const beforeFacts = pm.getBaseEngine().session.allFacts().map((f) => ({
id: f.id as number,
attr: f.attr as string,
value: f.value,
}));
const authoritative = engineFromFacts(beforeFacts);
const move = authoritative.findMove(from, to);
expect(move).not.toBeNull();
authoritative.applyMove(move!);
const delta = computeDelta(beforeFacts, authoritative, "e4");
client.emitDelta(delta);
// Prediction cleared; base engine now reflects the server's e4.
expect(pm.getPendingMoves()).toEqual([]);
const current = pm.getCurrentEngine();
expect(current).toBe(pm.getBaseEngine()); // no predicted engine anymore
expect(current.getCurrentTurn()).toBe("black");
expect(
findPositionFact(current.session.allFacts(), to),
).toBeDefined();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith(pm.getBaseEngine());
});
it("rolls back on non-fatal server error (move rejected)", () => {
const from = algebraicToSquare("e2");
const to = algebraicToSquare("e4");
pm.applyPrediction(from, to);
listener.mockClear();
client.emitError({ code: "NOT_YOUR_TURN", message: "nope", fatal: false });
// Predicted engine cleared; UI snaps back to the base (pre-move) state.
expect(pm.getPendingMoves()).toEqual([]);
const current = pm.getCurrentEngine();
expect(current).toBe(pm.getBaseEngine());
expect(current.getCurrentTurn()).toBe("white");
expect(findPositionFact(current.session.allFacts(), from)).toBeDefined();
expect(findPositionFact(current.session.allFacts(), to)).toBeUndefined();
expect(listener).toHaveBeenCalledTimes(1);
});
it("ignores fatal errors (session teardown handled elsewhere)", () => {
const from = algebraicToSquare("e2");
const to = algebraicToSquare("e4");
pm.applyPrediction(from, to);
listener.mockClear();
// Fatal errors don't trigger a rollback — the app layer will tear the
// session down and re-bootstrap via a fresh `game.state`.
client.emitError({ code: "BAD_TOKEN", message: "expired", fatal: true });
expect(pm.getPendingMoves()).toEqual([{ from, to }]);
expect(listener).not.toHaveBeenCalled();
});
it("applyFullState replaces base engine with server facts, clears pending", () => {
// Pre-populate a pending prediction we expect to be wiped.
pm.applyPrediction(algebraicToSquare("e2"), algebraicToSquare("e4"));
listener.mockClear();
// Emit a different authoritative position: starting position but black to move.
const alt = new ChessEngine();
// Emulate server-decided turn override: replace Turn fact.
alt.session.retract(GAME_ENTITY, "Turn");
alt.session.insert(GAME_ENTITY, "Turn", "black");
const facts = alt.session.allFacts().map((f) => ({
id: f.id as number,
attr: f.attr as string,
value: f.value,
})) satisfies Fact[];
client.emitState({
facts,
turn: "black",
lastSeq: 42,
moveHistory: [],
activeRules: [],
fen: "",
});
expect(pm.getPendingMoves()).toEqual([]);
expect(pm.getCurrentEngine().getCurrentTurn()).toBe("black");
// Base and current engines match (no prediction outstanding).
expect(pm.getCurrentEngine()).toBe(pm.getBaseEngine());
expect(listener).toHaveBeenCalledTimes(1);
});
it("sends promotion moves with algebraic notation and promoteTo intact", () => {
// Build a position where white can promote: pawn on a7, kings only.
const engine = new ChessEngine();
// Wipe starting position first.
const existing = engine.session.allFacts().slice();
for (const f of existing) {
engine.session.retract(f.id, f.attr as AttrKey);
}
// Insert minimal promotion position: white pawn a7, white king e1, black king e8.
let id = 1;
const insertPiece = (
type: string,
color: string,
square: number,
): void => {
const eid = id++ as EntityId;
engine.session.insert(eid, "PieceType", type);
engine.session.insert(eid, "Color", color);
engine.session.insert(eid, "Position", square);
engine.session.insert(eid, "HasMoved", true);
};
insertPiece("pawn", "white", algebraicToSquare("a7"));
insertPiece("king", "white", algebraicToSquare("e1"));
insertPiece("king", "black", algebraicToSquare("e8"));
engine.session.insert(GAME_ENTITY, "Turn", "white");
engine.session.insert(GAME_ENTITY, "HalfmoveClock", 0);
engine.session.insert(GAME_ENTITY, "FullmoveNumber", 1);
engine.session.insert(GAME_ENTITY, "EnPassantTarget", null);
engine.session.insert(GAME_ENTITY, "GameStatus", "active");
engine.session.insert(GAME_ENTITY, "Winner", null);
const facts = engine.session.allFacts().map((f) => ({
id: f.id as number,
attr: f.attr as string,
value: f.value,
})) satisfies Fact[];
client.emitState({
facts,
turn: "white",
lastSeq: 10,
moveHistory: [],
activeRules: [],
fen: "",
});
listener.mockClear();
const from = algebraicToSquare("a7");
const to = algebraicToSquare("a8");
const ok = pm.applyPrediction(from, to, "knight");
expect(ok).toBe(true);
expect(client.sentMoves).toEqual([
{ from: "a7", to: "a8", promoteTo: "knight" },
]);
// Predicted engine should show a white knight on a8.
const predicted = pm.getCurrentEngine();
const a8 = predicted.session
.allFacts()
.find((f) => f.attr === "Position" && f.value === to);
expect(a8).toBeDefined();
const typeOfPromoted = predicted.session
.allFacts()
.find((f) => f.id === a8!.id && f.attr === "PieceType")?.value;
expect(typeOfPromoted).toBe("knight");
});
it("simulated 100ms latency: prediction then delta converges to server state", async () => {
vi.useFakeTimers();
try {
const from = algebraicToSquare("e2");
const to = algebraicToSquare("e4");
// t=0: capture the facts the server will see BEFORE processing.
const serverViewBefore = pm.getBaseEngine().session.allFacts().map((f) => ({
id: f.id as number,
attr: f.attr as string,
value: f.value,
}));
const predictOk = pm.applyPrediction(from, to);
expect(predictOk).toBe(true);
// Immediately the UI sees the optimistic state: black to move.
expect(pm.getCurrentEngine().getCurrentTurn()).toBe("black");
// Schedule the server's authoritative delta 100ms from now.
const authoritative = engineFromFacts(serverViewBefore);
const serverMove = authoritative.findMove(from, to);
expect(serverMove).not.toBeNull();
authoritative.applyMove(serverMove!);
const delta = computeDelta(serverViewBefore, authoritative, "e4");
setTimeout(() => client.emitDelta(delta), 100);
// Advance virtual time. After 100ms the listener delivers the delta.
await vi.advanceTimersByTimeAsync(100);
// Final state must match the authoritative server engine.
const finalFacts = pm.getCurrentEngine().session.allFacts();
const authFacts = authoritative.session.allFacts();
// Compare as sorted string triples for structural equality.
const toKey = (fs: typeof finalFacts) =>
fs
.map((f) => `${f.id}|${f.attr}|${JSON.stringify(f.value)}`)
.sort();
expect(toKey(finalFacts)).toEqual(toKey(authFacts));
expect(pm.getCurrentEngine().getCurrentTurn()).toBe("black");
expect(pm.getPendingMoves()).toEqual([]);
expect(pm.getCurrentEngine()).toBe(pm.getBaseEngine());
} finally {
vi.useRealTimers();
}
});
it("multiple predictions are all cleared on reconcile", () => {
// White plays e2->e4, then (hypothetically) stacks a second prediction.
// In chess this can't happen (turn flip), but the manager must handle
// the case cleanly: both predictions wiped on the first server delta.
const from1 = algebraicToSquare("e2");
const to1 = algebraicToSquare("e4");
pm.applyPrediction(from1, to1);
const from2 = algebraicToSquare("d7");
const to2 = algebraicToSquare("d5");
pm.applyPrediction(from2, to2);
expect(pm.getPendingMoves()).toHaveLength(2);
// Server acknowledges only e2->e4; pending queue must reset.
const beforeFacts = pm.getBaseEngine().session.allFacts().map((f) => ({
id: f.id as number,
attr: f.attr as string,
value: f.value,
}));
const authoritative = engineFromFacts(beforeFacts);
authoritative.applyMove(authoritative.findMove(from1, to1)!);
const delta = computeDelta(beforeFacts, authoritative, "e4");
client.emitDelta(delta);
expect(pm.getPendingMoves()).toEqual([]);
expect(pm.getCurrentEngine()).toBe(pm.getBaseEngine());
expect(pm.getCurrentEngine().getCurrentTurn()).toBe("black");
});
it("squareToAlgebraic is the exact wire format sent to the server", () => {
// Contract test: sendMove uses two-char algebraic coords, not numbers.
const from = algebraicToSquare("g1");
const to = algebraicToSquare("f3");
pm.applyPrediction(from, to);
expect(client.sentMoves).toEqual([
{ from: squareToAlgebraic(from), to: squareToAlgebraic(to) },
]);
expect(client.sentMoves[0]?.from).toBe("g1");
expect(client.sentMoves[0]?.to).toBe("f3");
});
});

View file

@ -0,0 +1,221 @@
// Client prediction + server reconciliation for @paratype/chess.
//
// Responsibilities:
// * Apply moves optimistically to a LOCAL clone of the last confirmed
// server state (so UI feels instant on drag-drop).
// * Keep authoritative `baseEngine` state in sync with server events:
// - `game.state` — full snapshot; replaces base state entirely.
// - `game.delta` — incremental EAV patch; applied to base state.
// - `error` — (non-fatal) means the pending optimistic move was
// rejected by the server → roll back to base state.
// * Expose the engine the UI should render: predicted when we have an
// in-flight prediction, otherwise base.
//
// This module is intentionally decoupled from React. `useChessEngine` and
// friends consume it via the `onStateChange` callback.
import type { AttrKey, EntityId, FactValue } from "@paratype/rete";
import { ChessEngine } from "../engine.js";
import { squareToAlgebraic } from "../coord.js";
import type { PieceType, Square } from "../schema.js";
import type { GameClient } from "./client.js";
import type {
Fact,
GameDeltaPayload,
GameStatePayload,
PromotionPiece,
} from "./types.js";
interface PendingMove {
readonly from: number;
readonly to: number;
readonly promoteTo?: PromotionPiece;
}
export type StateChangeListener = (engine: ChessEngine) => void;
export class PredictionManager {
/** Authoritative state mirrored from the server (last confirmed). */
private baseEngine: ChessEngine;
/** Locally-predicted state applied on top of `baseEngine`. Null when no prediction is in flight. */
private predictedEngine: ChessEngine | null = null;
/** Moves issued optimistically and awaiting server confirmation. */
private pendingMoves: PendingMove[] = [];
private readonly client: GameClient;
private readonly onStateChange: StateChangeListener;
constructor(client: GameClient, onStateChange: StateChangeListener) {
this.client = client;
this.onStateChange = onStateChange;
this.baseEngine = freshEngine();
this.attachListeners();
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/**
* Apply a user move optimistically and notify the server. Returns true iff
* the move is legal against the current (predicted or base) engine state.
* Illegal moves are dropped silently (no send, no state change).
*/
applyPrediction(from: number, to: number, promoteTo?: PromotionPiece): boolean {
// Predict on top of the most up-to-date local view. If a prediction is
// already in flight we stack on it (rare in chess — one player moves at
// a time — but correct in principle for any rule set where multiple
// moves can be in flight from the same client).
const engine =
this.predictedEngine ?? cloneEngine(this.baseEngine);
const move = engine.findMove(from, to, promoteTo as PieceType | undefined);
if (!move) return false;
engine.applyMove(move, (promoteTo as PieceType | undefined) ?? "queen");
this.predictedEngine = engine;
this.pendingMoves = [...this.pendingMoves, { from, to, ...(promoteTo !== undefined ? { promoteTo } : {}) }];
this.onStateChange(engine);
// Fire the intent to the server. `sendMove` is a no-op if we aren't
// connected; in that case the caller's reconnect logic will resend the
// last state on `game.state`, which clears `pendingMoves` anyway.
this.client.sendMove(
squareToAlgebraic(from as Square),
squareToAlgebraic(to as Square),
promoteTo,
);
return true;
}
/** Engine the UI should render (predicted if available, else authoritative). */
getCurrentEngine(): ChessEngine {
return this.predictedEngine ?? this.baseEngine;
}
/** Authoritative engine (last confirmed server state). Primarily for tests. */
getBaseEngine(): ChessEngine {
return this.baseEngine;
}
/** Pending-move queue snapshot. Primarily for tests. */
getPendingMoves(): readonly PendingMove[] {
return this.pendingMoves;
}
// -------------------------------------------------------------------------
// Server-event handlers
// -------------------------------------------------------------------------
private attachListeners(): void {
this.client.on("game.state", (e) => this.applyFullState(e.payload));
this.client.on("game.delta", (e) => this.reconcile(e.payload));
this.client.on("error", (e) => {
// Fatal errors tear down the session; the app restarts from a fresh
// `game.state`. Non-fatal errors (ILLEGAL_MOVE / NOT_YOUR_TURN / …)
// mean the optimistic move was rejected → revert to the last confirmed
// server state so the UI stops showing a phantom piece.
if (!e.payload.fatal) this.rollback();
});
}
private applyFullState(state: GameStatePayload): void {
// Full snapshot from the server (on join or after a large gap). Replace
// `baseEngine` entirely and drop any outstanding predictions — the
// server view supersedes everything.
const next = freshEngine();
loadFacts(next, state.facts);
this.baseEngine = next;
this.predictedEngine = null;
this.pendingMoves = [];
this.onStateChange(this.baseEngine);
}
private reconcile(delta: GameDeltaPayload): void {
// Apply the authoritative delta to the base engine, then clear the
// prediction: the server's outcome is now reflected in `baseEngine`,
// and any locally-predicted future moves must be re-issued against it.
applyDeltaToEngine(this.baseEngine, delta);
this.predictedEngine = null;
this.pendingMoves = [];
this.onStateChange(this.baseEngine);
}
private rollback(): void {
// The server rejected a pending move. Throw the prediction away; the UI
// snaps back to the last confirmed state.
this.predictedEngine = null;
this.pendingMoves = [];
this.onStateChange(this.baseEngine);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* A brand-new engine that is *empty* of the default starting position.
* `ChessEngine` seeds the starting position in its constructor; we retract
* those facts so the caller can load arbitrary server state cleanly.
*/
function freshEngine(): ChessEngine {
const engine = new ChessEngine();
clearSession(engine);
return engine;
}
/**
* Copy `src`'s facts into a fresh engine so predictions can mutate without
* corrupting the authoritative state. `ChessEngine`'s only state is the
* EAV session, so a fact-level copy is a complete clone.
*/
function cloneEngine(src: ChessEngine): ChessEngine {
const next = freshEngine();
loadFacts(next, src.session.allFacts().map(f => ({
id: f.id as number,
attr: f.attr as string,
value: f.value,
})));
return next;
}
function clearSession(engine: ChessEngine): void {
// Copy first: retracting while iterating the live array would skip entries.
const existing = engine.session.allFacts().slice();
for (const f of existing) {
engine.session.retract(f.id, f.attr as AttrKey);
}
}
function loadFacts(engine: ChessEngine, facts: Fact[]): void {
clearSession(engine);
for (const f of facts) {
engine.session.insert(
f.id as EntityId,
f.attr as AttrKey,
f.value as FactValue,
);
}
}
function applyDeltaToEngine(
engine: ChessEngine,
delta: GameDeltaPayload,
): void {
// Order matters: retract first so that an (id, attr) pair reappearing in
// `inserted` isn't immediately removed by a subsequent retract of the
// same key. Matches the server's replay semantics.
for (const f of delta.retracted) {
engine.session.retract(f.id as EntityId, f.attr as AttrKey);
}
for (const f of delta.inserted) {
engine.session.insert(
f.id as EntityId,
f.attr as AttrKey,
f.value as FactValue,
);
}
}