diff --git a/src/lib/stores/game.svelte.ts b/src/lib/stores/game.svelte.ts new file mode 100644 index 0000000..f9d6f7c --- /dev/null +++ b/src/lib/stores/game.svelte.ts @@ -0,0 +1,164 @@ +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();