feat(chess): add client prediction + server reconciliation (P4.10)
This commit is contained in:
parent
39d91b6356
commit
721cc5484d
2 changed files with 703 additions and 0 deletions
482
packages/chess/src/net/prediction.test.ts
Normal file
482
packages/chess/src/net/prediction.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
221
packages/chess/src/net/prediction.ts
Normal file
221
packages/chess/src/net/prediction.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue