// Pure reducers that advance the per-game cache from live events, so the UI renders a move // from the event without a follow-up game.state + game.history fetch. They never touch the network // or the cache store — the stream handler applies the returned cache and the game screen acts on // `refetch` — which keeps the gap / own-move / idempotency logic unit-testable in isolation. import type { CachedGame } from './gamecache'; import type { GameView, MoveRecord, PushEvent, StateView } from './model'; /** The fields an opponent_moved delta carries that advance a cached game. */ export interface MoveDelta { move?: MoveRecord; game?: GameView; bagLen: number; } /** * DeltaResult is the outcome of applying an event: the advanced cache (set only when it changed) * and whether the caller must fall back to a full game.state + game.history fetch — a gap, a * missing payload, or the actor's own move on a device that has not drawn its new rack yet. */ export interface DeltaResult { cache?: CachedGame; refetch: boolean; } /** * seedInitialState builds a fresh cache entry from a started game's initial view (match_found / * game_started). A freshly started game has no moves, so the board replays from an empty journal. */ export function seedInitialState(state: StateView): CachedGame { return { view: state, moves: [] }; } /** * applyMoveDelta advances cached by one move from an opponent_moved delta, keyed on the post-move * count so it is idempotent (a re-delivered move, or the echo of one's own move, is a no-op) and * gap-safe (a missed move asks for a refetch). An opponent's move leaves the recipient's rack * unchanged, so it is preserved; the actor's own move drew a new rack the delta does not carry, so * a device still behind on its own move refetches to pick it up. */ export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): DeltaResult { // Nothing cached to advance (the game was never opened on this device): ignore it; the next open // cold-loads the game. if (!cached) return { refetch: false }; // An older peer, or a dropped payload, carried no delta: the open game must refetch. if (!d.move || !d.game) return { refetch: true }; const have = cached.view.game.moveCount; const next = d.game.moveCount; if (next <= have) return { refetch: false }; // already applied (idempotent / own echo) if (next > have + 1) return { refetch: true }; // a gap — an intermediate move was missed // The actor's own move changed their rack (a draw), which opponent_moved does not carry. if (d.move.player === cached.view.seat) return { refetch: true }; const view: StateView = { ...cached.view, game: d.game, bagLen: d.bagLen }; return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false }; } /** * applyGameOver settles a finished game from a game_over event's final summary (the adjusted * scores after rack penalties and the winner). It refetches when the cached board is behind the * final move count — the closing move was missed — so history is repaired, and is a harmless * re-settle when the closing opponent_moved already finished the game. */ export function applyGameOver(cached: CachedGame | undefined, game: GameView | undefined): DeltaResult { if (!cached) return { refetch: false }; if (!game) return { refetch: cached.view.game.status !== 'finished' }; if (cached.view.game.moveCount < game.moveCount) return { refetch: true }; const view: StateView = { ...cached.view, game }; return { cache: { view, moves: cached.moves }, refetch: false }; } /** * advanceCached applies a live game event to a cached game off-screen, for the global stream handler * to keep a game the player is not currently viewing warm: opening it from the lobby then renders the * opponent's move (or the final result) with no stale-board flash. It returns the advanced cache, or * undefined when nothing changes — no cache, an idempotent re-delivery, an unrelated event kind, or a * gap / missing payload that the next open cold-loads (off-screen there is no game screen to honour a * refetch, so a fall-back simply leaves the cache for load() to repair). The mounted game screen owns * its own cache, so the caller skips the game currently in view to avoid applying a delta twice. */ export function advanceCached(cached: CachedGame | undefined, e: PushEvent): CachedGame | undefined { if (e.kind === 'opponent_moved') { return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen }).cache; } if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache; if (e.kind === 'opponent_joined' && e.state) return applyOpponentJoined(cached, e.state); return undefined; } /** * applyOpponentJoined adopts an opponent_joined event into a cached open game: it overlays the * refreshed seats, status and player count from the event's StateView onto the cached game, leaving * the board, the starter's rack and everything else intact (the join changes who is seated, not the * position). It returns undefined when the game is not cached (the next open cold-loads it). It backs * both the mounted game board's in-place update and the global handler's off-screen cache warming. */ export function applyOpponentJoined(cached: CachedGame | undefined, state: StateView): CachedGame | undefined { if (!cached) return undefined; const game: GameView = { ...cached.view.game, seats: state.game.seats, status: state.game.status, players: state.game.players }; return { view: { ...cached.view, game }, moves: cached.moves }; }