From 721cc5484de631c66f8d45a807b1afcf71a14715 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 17:48:33 -0600 Subject: [PATCH] feat(chess): add client prediction + server reconciliation (P4.10) --- packages/chess/src/net/prediction.test.ts | 482 ++++++++++++++++++++++ packages/chess/src/net/prediction.ts | 221 ++++++++++ 2 files changed, 703 insertions(+) create mode 100644 packages/chess/src/net/prediction.test.ts create mode 100644 packages/chess/src/net/prediction.ts diff --git a/packages/chess/src/net/prediction.test.ts b/packages/chess/src/net/prediction.test.ts new file mode 100644 index 0000000..c9373a9 --- /dev/null +++ b/packages/chess/src/net/prediction.test.ts @@ -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(); + readonly sentMoves: SentMove[] = []; + + on( + type: T, + listener: (event: Extract) => 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; + 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"); + }); +}); diff --git a/packages/chess/src/net/prediction.ts b/packages/chess/src/net/prediction.ts new file mode 100644 index 0000000..d1dbe9e --- /dev/null +++ b/packages/chess/src/net/prediction.ts @@ -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, + ); + } +}