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