import { DuplicateGame } from '../../engine/game'; import { legalSyncedMoves, selectionHighlight, type SelectionHighlight } from '../../engine/legality'; import { ghosts } from '../../engine/ghosts'; import { evaluateStatus } from '../../engine/endgame'; import { serialize, deserialize } from '../../engine/notation'; import { PLAYER_BOARDS } from '../../engine/boards'; import type { BoardId, Player, Square, SyncMove, HistoryEntry, GhostMarker, GameStatus, } from '../../engine/types'; /** A plain, reactivity-friendly snapshot of everything the UI renders. */ export interface GameView { /** Piece-placement FEN field per board. */ fen: Record; currentPlayer: Player; ply: number; ghosts: GhostMarker[]; status: GameStatus; history: HistoryEntry[]; } function buildView(game: DuplicateGame): GameView { return { fen: { NW: game.boards.NW.fen(), NE: game.boards.NE.fen(), SW: game.boards.SW.fen(), SE: game.boards.SE.fen(), }, currentPlayer: game.currentPlayer, ply: game.ply, ghosts: ghosts(game), status: evaluateStatus(game), history: [...game.history], }; } class GameStore { /** The authoritative live game — deliberately NOT a $state proxy. */ #game = new DuplicateGame(); /** Snapshot the UI renders. While scrubbing it reflects a past ply. */ view = $state(buildView(this.#game)); /** The grabbed square, or null. */ selected = $state(null); /** Triple-highlight for the grabbed piece, or null. */ highlight = $state(null); /** A pawn move awaiting a promotion choice, or null. */ pendingPromotion = $state<{ from: Square; to: Square } | null>(null); /** Ply currently being viewed; null means the live position. */ scrubPly = $state(null); get isScrubbing(): boolean { return this.scrubPly !== null; } /** Which boards belong to the player to move (for the turn glow). */ get activeBoards(): [BoardId, BoardId] { return PLAYER_BOARDS[this.#game.currentPlayer]; } /** Grab a piece: must be the current player's turn and a live (non-scrub) view. */ select(square: Square): void { if (this.isScrubbing) return; if (this.selected === square) { this.clearSelection(); return; } this.selected = square; this.highlight = selectionHighlight(this.#game, square); } clearSelection(): void { this.selected = null; this.highlight = null; } /** Attempt to play the grabbed piece to `to`. Opens the promotion dialog if needed. */ commitTo(to: Square): void { const from = this.selected; if (from === null || this.highlight === null) return; if (!this.highlight.playable.includes(to)) return; // not a synchronized-legal square const moves = legalSyncedMoves(this.#game).filter((m) => m.from === from && m.to === to); if (moves.length === 0) return; if (moves.some((m) => m.promotion)) { this.pendingPromotion = { from, to }; return; } this.#apply(moves[0]); } /** Finish a promotion started by commitTo. */ choosePromotion(piece: SyncMove['promotion']): void { if (this.pendingPromotion === null) return; this.#apply({ ...this.pendingPromotion, promotion: piece }); this.pendingPromotion = null; } cancelPromotion(): void { this.pendingPromotion = null; } #apply(move: SyncMove): void { this.#game.applyMove(move); this.clearSelection(); this.scrubPly = null; this.view = buildView(this.#game); } undo(): void { this.#game.undo(); this.clearSelection(); this.scrubPly = null; this.view = buildView(this.#game); } newGame(): void { this.#game = new DuplicateGame(); this.clearSelection(); this.pendingPromotion = null; this.scrubPly = null; this.view = buildView(this.#game); } /** Scrub the move history; null returns to the live position. */ scrubTo(ply: number | null): void { this.clearSelection(); if (ply === null || ply >= this.#game.ply) { this.scrubPly = null; this.view = buildView(this.#game); return; } this.scrubPly = ply; this.view = buildView(new DuplicateGame(this.#game.history.slice(0, ply))); } /** Manually declare a draw (provisional: insufficient material is not auto-detected). */ declareDraw(): void { this.view = { ...this.view, status: { state: 'draw', reason: 'manual', checks: [], result: { N: 'draw', S: 'draw', E: 'draw', W: 'draw' } }, }; } save(): void { const blob = new Blob([serialize(this.#game.history)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `duplicate-chess-${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); } async load(file: File): Promise { const history = deserialize(await file.text()); this.#game = new DuplicateGame(history); this.clearSelection(); this.pendingPromotion = null; this.scrubPly = null; this.view = buildView(this.#game); } } export const gameStore = new GameStore();