Spaces:
Running
Running
Download frontend/src/engine/board.js from chesstb/explorer: direct link, hf CLI and curl.
- Browser
- Download file 66.1 kB
-
https://huggingface.co/spaces/chesstb/explorer/resolve/main/frontend/src/engine/board.js
- Command line
-
hf download hf://spaces/chesstb/explorer/frontend/src/engine/board.js
-
curl -L -o board.js https://huggingface.co/spaces/chesstb/explorer/resolve/main/frontend/src/engine/board.js
66.1 kB
| /** | |
| * board.js: Chessground wrapper | |
| * | |
| * Wraps the vendor Chessground() instance and a chessops position, and owns: | |
| * - A branching move tree (_nodes/_rootChildren) used by the PGN panel: | |
| * playing a move after navigating back adds a variation instead of | |
| * overwriting the continuation from that point. A plain | |
| * undo/redo FEN stack (_history/_future) sits alongside it as a | |
| * fallback for Back/Forward once navigation walks off the tree (e.g. | |
| * past a board edit that started a fresh one). | |
| * - Best-move arrows for DTZ/DTC/DTM/DTM50, drawn via chessground's | |
| * native drawable.autoShapes. | |
| * - Click-to-place editing: click a spare piece, then click a square to | |
| * place it. Drag-from-tray is also supported (chessground's | |
| * dragNewPiece()). | |
| * - goToNode() - jump to any node in the current move tree (used by | |
| * the PGN panel, including variation moves) without resetting the tree | |
| * or re-triggering a probe for a position that's already been evaluated. | |
| * - Board/piece theming: the piece image path is owned by theme.js | |
| * (Theme.pieceThemeFn()). reconstruct() updates the piece | |
| * CSS <link> and repopulates the spare-tray/promotion-dialog images; | |
| * chessground is never destroyed and rebuilt for theming, since piece | |
| * images are plain CSS. | |
| * - The active rules variant (Standard / Chess960) and the FEN protocol | |
| * boundary between them. chessops's own `Chess` class implements both | |
| * rulesets' move generation/legality/SAN identically and has no | |
| * variant concept of its own - castling legality, paths, and rook | |
| * squares are computed the same way regardless of variant (see | |
| * chessgroundDests() below for the one place chessops is told which | |
| * variant is active, purely to choose which destinations chessground | |
| * is offered). The application's Standard-vs-Chess960 FEN contract is | |
| * stricter than chessops's generic one: chessops's FEN parser resolves | |
| * a 'K'/'Q'/'k'/'q' castling letter to whichever rook sits on the | |
| * correct side of the king wherever the king happens to be (needed for | |
| * Chess960, where the king isn't always on the e-file) and also accepts | |
| * Shredder-FEN/X-FEN rook-file letters unconditionally - neither of | |
| * which is a legal Standard-chess castling encoding. _buildPosition() | |
| * below enforces the application's Standard-mode castling contract | |
| * (only 'K'/'Q'/'k'/'q', each requiring the king on its e1/e8 home | |
| * square and the resolved rook on its exact a1/h1/a8/h8 home square) | |
| * before trusting chessops's more permissive parse, so the frontend | |
| * and the backend (see app.py's _validate_fen_parts and python-chess's | |
| * own chess960=False castling validation) agree on whether a given FEN | |
| * is valid for the active variant. | |
| * | |
| * Every position-changing action - setPosition(), clear(), playMove(), | |
| * goBack()/goForward(), board edits (off-board drop, board-setup drag, | |
| * click-to-place), setVariant() - records itself (in the tree or the | |
| * undo/redo stack, whichever applies) and invokes the onPositionChange | |
| * callback itself, so callers never need to remember to do either one | |
| * manually. | |
| * | |
| * The chessops position (_position) is the single source of truth for | |
| * position/legality - chessground is a dumb, controlled rendering surface. | |
| * Every position-changing function ends with a call to _syncChessground(), | |
| * which pushes _position's current FEN (plus turn colour, check state, and | |
| * last-move squares) into chessground in one shot. The core mutation | |
| * handler (_handleBoardMutation) is idempotent rather than tracking which | |
| * of chessground's several event hooks handled a given user action, | |
| * because chessground fires multiple, differently-scoped events for what | |
| * is logically one move. For an ordinary relocation, events.move fires | |
| * first, then events.change, then movable.events.after (see board.ts's | |
| * baseMove()/userMove()). events.change carries no orig/dest, so if it won | |
| * the idempotency race it would take the generic "board edit" branch and | |
| * leave movable.events.after's orig/dest with nothing left to act on. | |
| * events.move is therefore the primary handler: it fires before | |
| * events.change and, unlike movable.events.after, carries orig/dest (plus | |
| * capture info), so it wins the idempotency race with enough information | |
| * to classify the move correctly. events.change and movable.events.after | |
| * are wired as idempotent fallbacks, and events.change is the only hook | |
| * that fires for an off-board deletion (drag.ts's deleteOnDropOff branch | |
| * calls it directly, with no accompanying events.move/ | |
| * movable.events.after). | |
| */ | |
| import { Chessground } from '../vendor/chessground.min.js'; | |
| import { Theme } from './theme.js'; | |
| import * as Storage from './storage.js'; | |
| import { Chess, Castles, castlingSide } from '../vendor/chessops/chess.js'; | |
| import { parseFen, makeFen } from '../vendor/chessops/fen.js'; | |
| import { chessgroundDests } from '../vendor/chessops/compat.js'; | |
| import { parseSan, makeSan } from '../vendor/chessops/san.js'; | |
| import { parseSquare, makeSquare } from '../vendor/chessops/util.js'; | |
| // ── Variant ────────────────────────────────────────────────────────────── | |
| // The application's rules-mode state. Distinct from chessops's own `rules` | |
| // property (always 'chess' for both Standard and Chess960 - see the | |
| // module doc comment above): this is application metadata that travels | |
| // alongside the position, never derived from it. | |
| const VARIANTS = Object.freeze({ STANDARD: 'standard', CHESS960: 'chess960' }); | |
| const Board = (() => { | |
| let cg = null; // Chessground instance | |
| let _position = null; // chessops Chess position - see module doc comment | |
| let _variant = VARIANTS.STANDARD; | |
| let _onPositionChange = null; | |
| let _onMoveHistoryChange = null; | |
| let _pendingPromotion = null; | |
| // ── Lock mode ──────────────────────────────────────────────────────────── | |
| // When true, the board only accepts legal moves for the side to move; | |
| // spare-piece placement and off-board deletion are disabled. Toggled via | |
| // setLocked(). Moves played through the ranked-moves table, auto-play, or | |
| // the PGN panel are unaffected, since those are already always legal. | |
| let _locked = false; | |
| // [orig, dest] of the most recently played/navigated-to move, or | |
| // undefined. Fed into chessground's `lastMove` config on every sync. | |
| // Tracked separately from the move tree because Back/Forward/goToNode | |
| // land on a node that already has its own from/to squares recorded; | |
| // this variable is what lets _syncChessground() highlight the right | |
| // squares for those navigations too, not just for a freshly played move. | |
| let _lastMoveKeys = undefined; | |
| // ── Undo / redo stacks ──────────────────────────────────────────────────── | |
| // Plain chronological FEN log, used only as a Back/Forward fallback once | |
| // navigation walks off the current move tree entirely (e.g. past a board | |
| // edit - edits aren't part of the tree, see _resetMoveLine below). While | |
| // the current position is inside the tree, goBack()/goForward() use the | |
| // tree's parent/activeChild pointers instead (see below). | |
| let _history = []; | |
| let _future = []; | |
| const _MAX_HISTORY = 100; | |
| function _pushHistory(fen) { | |
| _history.push(fen); | |
| if (_history.length > _MAX_HISTORY) _history.shift(); | |
| _future = []; // any new position discards the redo stack | |
| } | |
| // ── Move tree ────────────────────────────────────────────────────────────── | |
| // Only legal moves via playMove() (and the promotion/drag-move | |
| // equivalents) are recorded here. setPosition() / clear() / board edits / | |
| // setVariant() reset the tree. | |
| // | |
| // Each node is {id, san, fen, from, to, parentId, children, activeChild}. | |
| // `id` is also the node's index in _nodes. parentId === ROOT (-1) means | |
| // the node replies to the tree's start position (_startFen). `children` | |
| // lists every move tried from that node - children[0] renders as the | |
| // "main" continuation, children[1+] as parenthesised variations. | |
| // `activeChild` is whichever child Forward should follow from that node | |
| // (whichever branch was most recently played/clicked into) - not | |
| // necessarily children[0]. `from`/`to` are the move's origin/destination | |
| // squares exactly as played (for a Chess960 castle, the king's origin | |
| // and whichever destination square chessground/chessops reported, | |
| // never assumed to be a fixed c/g-file square), recorded alongside | |
| // san/fen so lastMove highlighting works when navigating the tree, not | |
| // just on a freshly-played move. | |
| // | |
| // The PGN panel and PGN export walk this tree recursively so alternate | |
| // moves are preserved as variations instead of overwriting history. | |
| const ROOT = -1; | |
| let _nodes = []; // [{id, san, fen, from, to, parentId, children:[], activeChild}] | |
| let _rootChildren = []; // ids of moves played directly from _startFen | |
| let _rootActiveChild = null; | |
| let _currentId = ROOT; // id of the node currently shown, or ROOT for _startFen itself | |
| let _startFen = null; // FEN the current tree began from (for PGN formatting) | |
| function _childrenOf(id) { return id === ROOT ? _rootChildren : _nodes[id].children; } | |
| function _activeChildOf(id) { return id === ROOT ? _rootActiveChild : _nodes[id].activeChild; } | |
| function _setActiveChild(id, childId) { | |
| if (id === ROOT) _rootActiveChild = childId; | |
| else _nodes[id].activeChild = childId; | |
| } | |
| function _resetMoveLine() { | |
| _nodes = []; | |
| _rootChildren = []; | |
| _rootActiveChild = null; | |
| _currentId = ROOT; | |
| _future = []; | |
| _startFen = _position ? currentFen() : null; | |
| if (_onMoveHistoryChange) _emitHistory(); | |
| } | |
| function _emitHistory() { | |
| if (_onMoveHistoryChange) { | |
| _onMoveHistoryChange({ | |
| nodes: _nodes, rootChildren: _rootChildren, | |
| rootActiveChild: _rootActiveChild, currentId: _currentId, | |
| }, _startFen); | |
| } | |
| } | |
| // Records a played move as a child of the current node. If the current | |
| // node already has a child with this exact SAN (the user replayed a move | |
| // already tried from this point), that existing node is reused instead | |
| // of creating a duplicate. Otherwise a new node is created and appended | |
| // to the end of the children list, so an already-explored continuation | |
| // stays the main line and the newly-played move is recorded as a | |
| // parenthesised variation. | |
| function _addMove(san, fen, from, to) { | |
| _future = []; // starting a fresh line invalidates any pre-tree redo | |
| const kids = _childrenOf(_currentId); | |
| let childId = kids.find(id => _nodes[id].san === san); | |
| if (childId === undefined) { | |
| childId = _nodes.length; | |
| _nodes.push({ id: childId, san, fen, from, to, parentId: _currentId, children: [], activeChild: null }); | |
| kids.push(childId); | |
| } | |
| // (else: reused an existing child - same SAN replayed from this node) | |
| _setActiveChild(_currentId, childId); | |
| _currentId = childId; | |
| return childId; | |
| } | |
| // ── Piece theme ─────────────────────────────────────────────────────────── | |
| // Delegates to theme.js, which owns the active piece set. Kept as a local | |
| // wrapper (rather than calling Theme.pieceThemeFn() inline everywhere) | |
| // so callers below don't need to know theme.js exists. Resolved once on | |
| // first use rather than re-fetched from Theme on every call. | |
| let _pieceThemeFn = null; | |
| function _pieceTheme(piece) { | |
| if (!_pieceThemeFn) _pieceThemeFn = Theme.pieceThemeFn(); | |
| return _pieceThemeFn(piece); | |
| } | |
| // ── chessops adapter ───────────────────────────────────────────────────── | |
| // Small translation layer between this file's FEN-string/algebraic-key | |
| // vocabulary and chessops's Setup/Position/numeric-square vocabulary. | |
| // Contains no chess rules of its own - every legality, castling, and | |
| // check/mate determination below is delegated to chessops. | |
| function currentFen() { return makeFen(_position.toSetup()); } | |
| function _turnChar(pos) { return pos.turn === 'white' ? 'w' : 'b'; } | |
| function _fenErrorMessage(err) { | |
| switch (err && err.message) { | |
| case 'ERR_CASTLING': | |
| return "Invalid castling availability: expected some combination of 'K', 'Q', 'k', 'q', rook-file letters 'a'-'h'/'A'-'H', or '-'."; | |
| case 'ERR_TURN': return 'Invalid FEN: side to move must be "w" or "b".'; | |
| case 'ERR_EP_SQUARE': return 'Invalid FEN: en passant target square is not valid.'; | |
| case 'ERR_HALFMOVES': return 'Invalid FEN: halfmove clock is not valid.'; | |
| case 'ERR_FULLMOVES': return 'Invalid FEN: fullmove number is not valid.'; | |
| case 'ERR_BOARD': return 'Invalid FEN: board part is not valid.'; | |
| default: return 'Invalid FEN: position violates chess rules.'; | |
| } | |
| } | |
| function _positionErrorMessage(err) { | |
| switch (err && err.message) { | |
| case 'ERR_KINGS': return 'Position must have exactly one king per side.'; | |
| case 'ERR_OPPOSITE_CHECK': return 'Invalid position: the side not to move is already in check.'; | |
| case 'ERR_PAWNS_ON_BACKRANK': return 'Invalid position: a pawn is on the back rank.'; | |
| case 'ERR_EMPTY': return 'Position has no pieces.'; | |
| default: return 'Invalid FEN: position violates chess rules.'; | |
| } | |
| } | |
| // A castling-availability letter with no matching king/rook pair on the | |
| // board (e.g. "K" with no white king and rook on their home squares) is | |
| // syntactically valid but not a legal right - chessops's own parser | |
| // silently drops it rather than rejecting the FEN (see parseCastlingFen | |
| // in fen.js), so that has to be checked here by comparing what was | |
| // requested against what the constructed position actually granted. | |
| // This works the same way for Standard 'KQkq' and Chess960 file-letter | |
| // rights, since both are resolved by chessops before this comparison; | |
| // no Standard-only assumption is made about which squares are valid. | |
| function _missingCastlingRights(setup, pos) { | |
| const missing = []; | |
| for (const square of setup.castlingRights) { | |
| if (!pos.castles.castlingRights.has(square)) missing.push(makeSquare(square)); | |
| } | |
| return missing; | |
| } | |
| // Standard chess's castling letters mean something fixed: 'K'/'Q' is | |
| // the White rook on h1/a1, with the White king on e1 - never "whichever | |
| // rook is right/left of the king" (chessops's own, Chess960-capable, | |
| // resolution rule; see the module doc comment). A right that chessops | |
| // resolves to any other square is a Chess960-only reading of the same | |
| // letter and must be rejected here, in Standard mode, even though | |
| // chessops itself parsed it without complaint. Chess960 mode has no | |
| // "home square" of its own - any resolved king/rook pair is | |
| // acceptable there, which _missingCastlingRights above already covers. | |
| const _HOME_SQUARES = { | |
| white: { king: parseSquare('e1'), rook: { a: parseSquare('a1'), h: parseSquare('h1') } }, | |
| black: { king: parseSquare('e8'), rook: { a: parseSquare('a8'), h: parseSquare('h8') } }, | |
| }; | |
| function _nonOrthodoxCastlingRights(pos) { | |
| const offending = []; | |
| for (const color of ['white', 'black']) { | |
| const kingSquare = pos.board.kingOf(color); | |
| for (const side of ['a', 'h']) { | |
| const rookSquare = pos.castles.rook[color][side]; | |
| if (rookSquare === undefined) continue; | |
| if (kingSquare !== _HOME_SQUARES[color].king || rookSquare !== _HOME_SQUARES[color].rook[side]) { | |
| offending.push(makeSquare(rookSquare)); | |
| } | |
| } | |
| } | |
| return offending; | |
| } | |
| // Standard mode accepts only 'K'/'Q'/'k'/'q' (each optional, in that | |
| // order) or '-'; Chess960 additionally accepts Shredder-FEN/X-FEN | |
| // rook-file letters. Mirrors app.py's _validate_fen_parts exactly: | |
| // same accepted grammar, same rejected input, so a FEN the frontend | |
| // accepts is never one the backend then rejects, or vice versa. | |
| const _CASTLING_SYNTAX_STANDARD = /^K?Q?k?q?$/; | |
| const _CASTLING_SYNTAX_CHESS960 = /^[A-Ha-hKQkq]{1,4}$/; | |
| function _castlingSyntaxError(variant, field) { | |
| if (field === '-') return null; | |
| const pattern = variant === VARIANTS.CHESS960 ? _CASTLING_SYNTAX_CHESS960 : _CASTLING_SYNTAX_STANDARD; | |
| if (pattern.test(field)) return null; | |
| const expected = variant === VARIANTS.CHESS960 | |
| ? "some combination of 'K', 'Q', 'k', 'q', rook-file letters 'a'-'h'/'A'-'H', or '-'" | |
| : "some combination of 'K', 'Q', 'k', 'q', or '-'"; | |
| return `Invalid castling availability: '${field}' (expected ${expected}).`; | |
| } | |
| // Parses and fully validates a FEN into a chessops position under the | |
| // given variant (defaulting to the currently-active one), or returns a | |
| // user-facing reason it was rejected. Rejects a syntactically-valid | |
| // chessops parse that isn't actually a legal encoding for the | |
| // requested variant (see the two checks above and the module doc | |
| // comment) - the same FEN text can be accepted under one variant and | |
| // rejected under the other. | |
| function _buildPosition(fen, variant = _variant) { | |
| const fields = fen.split(' '); | |
| if (fields.length >= 3) { | |
| const syntaxError = _castlingSyntaxError(variant, fields[2]); | |
| if (syntaxError) return { ok: false, reason: syntaxError }; | |
| } | |
| const parsed = parseFen(fen); | |
| if (parsed.isErr) return { ok: false, reason: _fenErrorMessage(parsed.error) }; | |
| const setup = parsed.value; | |
| const built = Chess.fromSetup(setup); | |
| if (built.isErr) return { ok: false, reason: _positionErrorMessage(built.error) }; | |
| const pos = built.value; | |
| const missing = _missingCastlingRights(setup, pos); | |
| if (missing.length) { | |
| return { ok: false, reason: `Invalid castling availability: no matching king and rook for ${missing.join(', ')}.` }; | |
| } | |
| if (variant !== VARIANTS.CHESS960) { | |
| const nonOrthodox = _nonOrthodoxCastlingRights(pos); | |
| if (nonOrthodox.length) { | |
| return { | |
| ok: false, | |
| reason: `Invalid castling availability: Standard chess requires the king and rook on their home squares (${nonOrthodox.join(', ')} is not one).`, | |
| }; | |
| } | |
| } | |
| return { ok: true, pos }; | |
| } | |
| function _pieceCodeOf(piece) { | |
| if (!piece) return null; | |
| const letter = piece.role === 'knight' ? 'N' : piece.role[0].toUpperCase(); | |
| return (piece.color === 'white' ? 'w' : 'b') + letter; | |
| } | |
| // Total legal move count, counting each underpromotion choice as a | |
| // separate move (matching the count a full legal-move generator would | |
| // report) rather than one destination square per promoting pawn move. | |
| // Includes Chess960 castling moves - they're just another destination | |
| // chessops's own move generation supplies (see chessgroundDests below). | |
| function _countLegalMoves(pos) { | |
| const ctx = pos.ctx(); | |
| let count = 0; | |
| for (const [from, squares] of pos.allDests(ctx)) { | |
| const promotes = pos.board.pawn.has(from); | |
| for (const to of squares) { | |
| const isPromotion = promotes && (to < 8 || to >= 56); | |
| count += isPromotion ? 4 : 1; | |
| } | |
| } | |
| return count; | |
| } | |
| // Builds chessground's `movable.dests` shape from the active position, | |
| // using the variant-aware compatibility bridge. Standard mode exposes | |
| // orthodox king-destination castling only; Chess960 exposes the | |
| // king-to-rook destination. | |
| function _legalDestsMap() { | |
| if (!_position) return new Map(); | |
| const dests = chessgroundDests(_position, { chess960: _variant === VARIANTS.CHESS960 }); | |
| if (_variant === VARIANTS.CHESS960) return dests; | |
| const filtered = new Map(); | |
| for (const [from, squares] of dests) { | |
| const fromSquare = parseSquare(from); | |
| const allowed = squares.filter(to => { | |
| const toSquare = parseSquare(to); | |
| const side = castlingSide(_position, { from: fromSquare, to: toSquare }); | |
| if (!side) return true; | |
| return toSquare !== _position.castles.rook[_position.turn][side]; | |
| }); | |
| if (allowed.length) filtered.set(from, allowed); | |
| } | |
| return filtered; | |
| } | |
| // ── Chessground sync ────────────────────────────────────────────────────── | |
| // The single point where _position's state is pushed into chessground. | |
| // Called unconditionally after every position-changing action - even a | |
| // "legal move" one, since chessground's own baseMove() has no concept of | |
| // en passant, promotion, or Chess960 castling and can leave its internal | |
| // state briefly chess-wise-incorrect (e.g. not removing the captured | |
| // pawn on an en passant capture, leaving a pawn on the back rank instead | |
| // of the promoted piece, or leaving the rook wherever it was dragged | |
| // from instead of its final castled square) until this call corrects | |
| // it. Chessground's internal state is not authoritative; | |
| // this call always overwrites it from _position. | |
| function _syncChessground() { | |
| if (!cg || !_position) return; | |
| cg.set({ | |
| fen: currentFen(), | |
| turnColor: _position.turn, | |
| check: _position.isCheck() ? _position.turn : false, | |
| lastMove: _lastMoveKeys, | |
| // Locked: restrict dragging/click-move to the side to move's | |
| // legal destinations. Unlocked: free editing. | |
| movable: { | |
| free: !_locked, | |
| color: _locked ? _position.turn : 'both', | |
| dests: _locked ? _legalDestsMap() : undefined, | |
| }, | |
| // Off-board drop-to-delete is disabled while locked, same as the | |
| // spare trays below. | |
| draggable: { deleteOnDropOff: !_locked }, | |
| }); | |
| } | |
| // ── Promotion ───────────────────────────────────────────────────────────── | |
| // pieceCode is a two-letter code like 'wP' (colour + uppercase type), | |
| // matching the shape the rest of this file (and theme.js) already uses. | |
| function _isPromotionMove(target, pieceCode) { | |
| if (!pieceCode || pieceCode[1] !== 'P') return false; | |
| const color = pieceCode[0], rank = target[1]; | |
| return (color === 'w' && rank === '8') || (color === 'b' && rank === '1'); | |
| } | |
| // Maps the promotion dialog's single-letter piece codes to chessops's | |
| // full role names. | |
| const ROLE_OF = { P: 'pawn', N: 'knight', B: 'bishop', R: 'rook', Q: 'queen', K: 'king' }; | |
| function _showPromotionDialog(source, target, color) { | |
| _pendingPromotion = { source, target }; | |
| const dialog = document.getElementById('promotion-dialog'); | |
| ['q','r','b','n'].forEach(p => { | |
| const btn = dialog.querySelector(`.promotion-piece-btn[data-piece="${p}"]`); | |
| const img = btn && btn.querySelector('img'); | |
| if (img) img.src = _pieceTheme(color + p.toUpperCase()); | |
| }); | |
| dialog.classList.add('is-visible'); | |
| dialog.querySelector('.promotion-piece-btn')?.focus(); | |
| } | |
| function _hidePromotionDialog() { | |
| document.getElementById('promotion-dialog').classList.remove('is-visible'); | |
| } | |
| // Cancelling (Escape / backdrop click) needs to resync, since | |
| // chessground has already visually relocated the pawn to the target | |
| // square by the time the dialog is showing (its own drag/drop already | |
| // moved the piece before this handler ever runs). This call reverts | |
| // that visual relocation by pushing _position's still-unchanged state | |
| // back into chessground. | |
| function _cancelPromotion() { | |
| _hidePromotionDialog(); | |
| _pendingPromotion = null; | |
| _syncChessground(); | |
| } | |
| function _onPromotionSelect(pieceLetter) { | |
| _hidePromotionDialog(); | |
| if (!_pendingPromotion) return; | |
| const { source, target } = _pendingPromotion; | |
| _pendingPromotion = null; | |
| const move = { from: parseSquare(source), to: parseSquare(target), promotion: ROLE_OF[pieceLetter.toUpperCase()] }; | |
| if (!_position.isLegal(move)) { _syncChessground(); return; } // revert the visual pawn-on-back-rank relocation | |
| const san = makeSan(_position, move); | |
| _position.play(move); | |
| _lastMoveKeys = [source, target]; | |
| _addMove(san, currentFen(), source, target); | |
| _emitHistory(); | |
| _syncChessground(); | |
| // immediate=true: a completed promotion is a single, definite move | |
| // (see playMove()'s own comment on this parameter), not part of a | |
| // rapid-fire stream of changes the debounce below it is meant to | |
| // guard against. | |
| if (_onPositionChange) _onPositionChange(currentFen(), true); | |
| } | |
| // ── Core sync algorithm ────────────────────────────────────────────────── | |
| // Chessground fires multiple, differently-scoped callbacks for what a | |
| // user experiences as a single action (events.move, events.change, | |
| // movable.events.after, events.dropNewPiece), so rather than track | |
| // which-callback-handled-what with suppression flags, this handler is | |
| // idempotent: it compares chessground's current placement against | |
| // _position's, and no-ops if they already match. | |
| // | |
| // events.move is wired as the informative hook (the one that passes | |
| // origHint/destHint below) rather than movable.events.after, because of | |
| // chessground's event-firing order for an ordinary relocation: | |
| // events.move fires first (scheduled from within board.ts's baseMove()), | |
| // then events.change (also scheduled by baseMove(), immediately after), | |
| // then movable.events.after (scheduled later by userMove()) - all via | |
| // setTimeout(fn,1), so same-delay-timer FIFO ordering makes this | |
| // deterministic. events.change carries no orig/dest, so if it were the | |
| // informative hook it would always take the generic "board edit" branch | |
| // below first, leaving movable.events.after's real orig/dest with | |
| // nothing left to act on once it fires - the idempotency guard would | |
| // already see the position as synced. events.move fires before | |
| // events.change and, unlike movable.events.after, carries orig/dest | |
| // (plus capture info), so it reliably wins the idempotency race with | |
| // enough information to classify the move correctly. | |
| // events.change and movable.events.after are wired below as idempotent | |
| // fallbacks/no-ops for a relocation; events.change is load-bearing for | |
| // the one case with no orig/dest-carrying hook at all - an off-board | |
| // deletion (drag.ts's deleteOnDropOff branch). | |
| // | |
| // Standard mode accepts only the orthodox king-destination castle; | |
| // Chess960 accepts the king-to-rook representation. | |
| function _handleBoardMutation(origHint, destHint) { | |
| if (!cg || !_position) return; | |
| const cgPlacement = cg.getFen(); // chessground's own current placement-only FEN | |
| const ourPlacement = currentFen().split(' ')[0]; | |
| if (cgPlacement === ourPlacement) return; // already synced by another callback this tick | |
| if (origHint && destHint) { | |
| // _position hasn't moved yet at this point - only chessground | |
| // has - so the piece that was at origHint is still readable | |
| // from _position. | |
| const pieceCode = _pieceCodeOf(_position.board.get(parseSquare(origHint))); | |
| if (pieceCode && _isPromotionMove(destHint, pieceCode)) { | |
| _showPromotionDialog(origHint, destHint, pieceCode[0]); | |
| return; // resolved later by _onPromotionSelect()/_cancelPromotion() | |
| } | |
| const move = { from: parseSquare(origHint), to: parseSquare(destHint) }; | |
| if (_variant === VARIANTS.STANDARD) { | |
| const side = castlingSide(_position, move); | |
| if (side && move.to === _position.castles.rook[_position.turn][side]) { | |
| _syncChessground(); | |
| return; | |
| } | |
| } | |
| if (_position.isLegal(move)) { | |
| const san = makeSan(_position, move); | |
| _position.play(move); | |
| _lastMoveKeys = [origHint, destHint]; | |
| _addMove(san, currentFen(), origHint, destHint); | |
| _emitHistory(); | |
| _syncChessground(); | |
| // immediate=true: see the comment on the equivalent call in | |
| // _onPromotionSelect() above - a completed drag-and-drop | |
| // move is a single, definite move too. | |
| if (_onPositionChange) _onPositionChange(currentFen(), true); | |
| return; | |
| } | |
| // Not legal - fall through to the generic edit path below, which | |
| // reads the placement chessground already committed rather than | |
| // origHint/destHint. | |
| } | |
| // Board-edit path: spare-piece drop, off-board delete, or an | |
| // illegal relocation. Castling rights and the en passant square are | |
| // always dropped here because an edited placement's rights can no | |
| // longer be assumed valid. | |
| const prevFen = currentFen(); | |
| const nextFen = `${cgPlacement} ${_turnChar(_position)} - - 0 1`; | |
| const built = _buildPosition(nextFen); | |
| if (!built.ok) { _syncChessground(); return; } // invalid resulting position - snap back | |
| _position = built.pos; | |
| _pushHistory(prevFen); | |
| _lastMoveKeys = undefined; | |
| _resetMoveLine(); | |
| _syncChessground(); | |
| if (_onPositionChange) _onPositionChange(currentFen()); | |
| } | |
| function _onCgSelect(key) { | |
| if (!_clickPlacePiece) return; // not in placement mode - let chessground handle the click normally | |
| cg.selectSquare(null); // clear any selection chessground itself may have started, | |
| // so a piece "armed" for placement never gets relocated | |
| // by chessground's own click-to-move instead | |
| const role = ROLE_OF[_clickPlacePiece[1]]; | |
| const color = _clickPlacePiece[0] === 'w' ? 'white' : 'black'; | |
| const square = parseSquare(key); | |
| if (role === 'king') { | |
| const existing = _position.board.kingOf(color); | |
| if (existing !== undefined && existing !== square) return; // rejected - a king of this colour already exists elsewhere | |
| } | |
| const prevFen = currentFen(); | |
| // Trial-mutate a clone rather than _position directly, so an | |
| // illegal result (e.g. two kings adjacent, or leaving the side not | |
| // to move in check) can be refused before anything commits - the | |
| // same validate-then-commit gate _handleBoardMutation's edit path | |
| // below already runs every other board edit through. | |
| const trial = _position.clone(); | |
| trial.board.set(square, { role, color, promoted: false }); | |
| // Re-derive castling rights from the edited board rather than | |
| // leaving stale rook/king square references behind (e.g. a right | |
| // pointing at a square this edit just overwrote). | |
| trial.castles = Castles.fromSetup({ board: trial.board, castlingRights: _position.castles.castlingRights }); | |
| const nextFen = makeFen(trial.toSetup()); | |
| if (nextFen === prevFen) return; // identity placement (e.g. re-placing the same piece already there) - nothing to record | |
| const built = _buildPosition(nextFen); | |
| if (!built.ok) { setClickPlacePiece(null); return; } // illegal result - refuse, board stays as it was | |
| _position = built.pos; | |
| _pushHistory(prevFen); | |
| _lastMoveKeys = undefined; | |
| _resetMoveLine(); | |
| _syncChessground(); | |
| if (_onPositionChange) _onPositionChange(currentFen()); | |
| } | |
| // ── Best-move arrows (DTZ / DTC / DTM / DTM50) ──────────────────────────── | |
| // Native chessground feature (drawable.autoShapes) - square keys, not | |
| // pixel coordinates, so chessground handles orientation/resizing itself. | |
| // bestMoves: { dtz: entry|null, dtc: entry|null, dtm: entry|null, | |
| // dtm50: entry|null } - the top-ranked move for each metric (any of | |
| // which may be missing, e.g. for a terminal or not-yet-probed position). | |
| function drawArrows(bestMoves) { | |
| if (!cg) return; | |
| if (!bestMoves) { cg.setAutoShapes([]); return; } | |
| const shapes = []; | |
| ['dtz', 'dtc', 'dtm', 'dtm50'].forEach(metric => { | |
| const entry = bestMoves[metric]; | |
| if (!entry) return; | |
| const move = parseSan(_position, entry.san); | |
| if (!move) return; | |
| const side = _variant === VARIANTS.STANDARD ? castlingSide(_position, move) : undefined; | |
| const dest = side | |
| ? `${side === 'a' ? 'c' : 'g'}${_position.turn === 'white' ? '1' : '8'}` | |
| : makeSquare(move.to); | |
| shapes.push({ orig: makeSquare(move.from), dest, brush: metric }); | |
| }); | |
| cg.setAutoShapes(shapes); | |
| } | |
| function clearArrows() { | |
| if (!cg) return; | |
| cg.setAutoShapes([]); | |
| } | |
| // ── Click-to-place + spare-piece trays ─────────────────────────────────── | |
| let _clickPlacePiece = null; // e.g. 'wQ', 'bK', or null | |
| function _toCgPiece(code /* e.g. 'wQ' */) { | |
| return { color: code[0] === 'w' ? 'white' : 'black', role: ROLE_OF[code[1]] }; | |
| } | |
| function _populateTrayImages() { | |
| document.querySelectorAll('.spare-piece').forEach(btn => { | |
| const code = btn.dataset.piece; | |
| const img = btn.querySelector('img'); | |
| if (img && code) img.src = _pieceTheme(code); | |
| }); | |
| } | |
| // Two interaction paths, both needed: click-to-place (click a tray | |
| // piece, then click a destination square) and drag-from-tray (native | |
| // HTML5 drag onto the board, handled via chessground's dragNewPiece()). | |
| // The spare-piece trays are expected to support both at once, so | |
| // neither wiring below can assume the other is absent. | |
| function _initSpareTrays() { | |
| document.querySelectorAll('.spare-piece').forEach(btn => { | |
| const code = btn.dataset.piece; | |
| // Path 1: click tray piece, then click a square - handled by | |
| // this click listener (arms _clickPlacePiece) plus _onCgSelect() | |
| // (does the actual placement), wired in init()'s Config.events.select. | |
| // Disabled while locked (tray is also dimmed - see setLocked()). | |
| btn.addEventListener('click', () => { | |
| if (_locked) return; | |
| setClickPlacePiece(_clickPlacePiece === code ? null : code); | |
| }); | |
| // Path 2: drag a tray piece directly onto the board. `force: | |
| // true` is required - cg.dragNewPiece() without it refuses to | |
| // overwrite an occupied square, and dropping a spare piece onto | |
| // an occupied square is expected to replace it. Disabled while locked. | |
| btn.addEventListener('pointerdown', e => { | |
| if (_locked) return; | |
| if (e.button !== undefined && e.button !== 0) return; // primary pointer only | |
| cg.dragNewPiece(_toCgPiece(code), e, /* force */ true); | |
| }); | |
| }); | |
| // Escape clears click-to-place selection | |
| document.addEventListener('keydown', e => { | |
| if (e.key === 'Escape' && _clickPlacePiece) setClickPlacePiece(null); | |
| }); | |
| } | |
| function setClickPlacePiece(piece) { | |
| _clickPlacePiece = piece; | |
| // Visual feedback | |
| document.querySelectorAll('.spare-piece.cp-selected').forEach(el => el.classList.remove('cp-selected')); | |
| if (piece) { | |
| document.querySelectorAll(`.spare-piece[data-piece="${piece}"]`).forEach(el => el.classList.add('cp-selected')); | |
| } | |
| // Update cursor on board | |
| const boardEl = document.getElementById('board'); | |
| if (boardEl) { | |
| boardEl.classList.toggle('cp-active', !!piece); | |
| } | |
| } | |
| // ── Public API ──────────────────────────────────────────────────────────── | |
| function _restoredOrientation() { | |
| return Storage.read('chesstb_orientation') === 'black' ? 'black' : 'white'; | |
| } | |
| function _restoredLocked() { | |
| return Storage.read('chesstb_locked') === 'true'; | |
| } | |
| // variant, if given, must be VARIANTS.STANDARD or VARIANTS.CHESS960 and | |
| // takes precedence over any caller-independent default - callers that | |
| // already know the intended variant (a URL share-link, a restored saved | |
| // game) pass it explicitly so the position below is constructed under | |
| // the correct ruleset context from the very first render, per this | |
| // file's variant-before-position invariant. Omitted or invalid values | |
| // fall back to Standard. | |
| function init(onPositionChange, initialFen, variant) { | |
| _onPositionChange = onPositionChange; | |
| _variant = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD; | |
| // Same validation setPosition() applies to every later position | |
| // change - an invalid initialFen (bad king count, unusable castling | |
| // rights, or anything else chessops rejects) falls back to the | |
| // default reset position, and the reason is returned below so the | |
| // caller can tell the user, instead of silently substituting a | |
| // different position with no feedback. | |
| const built = _buildPosition(initialFen); | |
| let loadError = null; | |
| if (built.ok) { | |
| _position = built.pos; | |
| } else { | |
| loadError = built.reason; | |
| _position = Chess.default(); | |
| } | |
| _startFen = currentFen(); | |
| // Built from _startFen (the validated/fallback position), not the | |
| // raw initialFen - otherwise an invalid initialFen leaves the | |
| // visible board showing the rejected placement while _position | |
| // already holds the fallback, a desync baked in right at startup. | |
| cg = Chessground(document.getElementById('board'), { | |
| fen: _startFen, | |
| orientation: _restoredOrientation(), | |
| turnColor: _position.turn, | |
| check: _position.isCheck() ? _position.turn : false, | |
| lastMove: undefined, | |
| coordinates: true, | |
| // coordinatesOnSquares stays false: that mode prints a combined | |
| // "a8"-style label on all 64 squares, whereas this app wants the | |
| // classic look - a rank number down the left column and a file | |
| // letter along the bottom row only, both sitting inset at the | |
| // board's edges. Chessground's native coords.ranks/coords.files | |
| // strips (one rank-only label per row, one file-only label per | |
| // column) already produce that layout once repositioned via CSS | |
| // (see chess/board.css) to sit inside the board edge instead of outside | |
| // it, so the combined per-square mode isn't needed here. | |
| coordinatesOnSquares: false, | |
| // Castling is driven by the chessops position (_position), not | |
| // chessground's own king-two-squares heuristic, so chessground | |
| // should never second-guess or auto-correct a king move into a | |
| // castle itself - this also holds for Chess960's king-to-rook | |
| // representation, which chessground has no native concept of. | |
| autoCastle: false, | |
| viewOnly: false, | |
| disableContextMenu: true, | |
| highlight: { lastMove: true, check: true }, | |
| animation: { enabled: true, duration: 200 }, | |
| movable: { | |
| // Any drag is provisionally accepted here and classified | |
| // afterward by _handleBoardMutation, rather than gating | |
| // drags up front with a separate edit-mode toggle. | |
| free: true, | |
| color: 'both', | |
| showDests: true, | |
| events: { after: (orig, dest) => _handleBoardMutation(orig, dest) }, | |
| }, | |
| premovable: { enabled: false }, | |
| draggable: { | |
| enabled: true, | |
| showGhost: true, | |
| deleteOnDropOff: true, | |
| }, | |
| selectable: { enabled: true }, | |
| events: { | |
| move: (orig, dest) => _handleBoardMutation(orig, dest), | |
| change: () => _handleBoardMutation(), | |
| dropNewPiece: () => _handleBoardMutation(), | |
| select: (key) => _onCgSelect(key), | |
| }, | |
| drawable: { | |
| enabled: true, | |
| visible: true, | |
| brushes: { | |
| dtz: { key: 'dtz', color: '#2451c4', opacity: 0.80, lineWidth: 10 }, | |
| dtc: { key: 'dtc', color: '#d113c6', opacity: 0.80, lineWidth: 10 }, | |
| dtm: { key: 'dtm', color: '#8b93a1', opacity: 0.80, lineWidth: 10 }, | |
| dtm50: { key: 'dtm50', color: '#e1453a', opacity: 0.80, lineWidth: 10 }, | |
| }, | |
| }, | |
| }); | |
| const dialog = document.getElementById('promotion-dialog'); | |
| dialog.querySelectorAll('.promotion-piece-btn').forEach(btn => { | |
| btn.addEventListener('click', () => _onPromotionSelect(btn.dataset.piece)); | |
| }); | |
| dialog.addEventListener('click', e => { | |
| if (e.target === dialog) _cancelPromotion(); | |
| }); | |
| document.addEventListener('keydown', e => { | |
| if (e.key !== 'Escape' || !dialog.classList.contains('is-visible')) return; | |
| _cancelPromotion(); | |
| // The promotion dialog sits on top of everything else - stop | |
| // this Escape from also reaching the PGN dialog/settings-panel | |
| // handlers (see PgnDialog.jsx, SettingsPanel.jsx), which would otherwise close those too | |
| // if either happened to be open underneath it. | |
| e.stopImmediatePropagation(); | |
| }); | |
| _populateTrayImages(); | |
| _initSpareTrays(); | |
| _syncTrayOrientation(); | |
| // Restored the same way orientation is above - persist:false since | |
| // this is a stored preference being applied on load, not a new one | |
| // being set, so it's not written straight back to the same key. | |
| setLocked(_restoredLocked(), false); | |
| return loadError ? { ok: false, reason: loadError } : { ok: true }; | |
| } | |
| /** | |
| * Set the piece-set CSS <link> and populate the spare-tray and | |
| * promotion-dialog images. Registered with theme.js via | |
| * Theme.init(reconstruct) and invoked once at bootstrap, after theme.js | |
| * injects the board's theme <style> tag. | |
| * | |
| * This never touches the Chessground instance itself - piece images are | |
| * plain CSS (`.cg-wrap piece.<role>.<color>`), not baked into a | |
| * constructor closure. | |
| */ | |
| function reconstruct() { | |
| if (!cg || !_position) return; | |
| const link = document.getElementById('chesstb-piece-css'); | |
| if (link) link.href = `/static/css/pieces-${Theme.currentPieceSet()}.css`; | |
| _populateTrayImages(); | |
| } | |
| function setPosition(fen) { | |
| const prevFen = _position ? currentFen() : null; | |
| // Re-submitting the FEN already on the board (e.g. pressing Enter in | |
| // the FEN box without editing it) is a no-op - skip the undo-stack | |
| // push and tree reset so the current move line survives it. | |
| if (fen === prevFen) return { ok: true }; | |
| const built = _buildPosition(fen); | |
| if (!built.ok) return { ok: false, reason: built.reason }; | |
| _position = built.pos; | |
| if (prevFen !== null) _pushHistory(prevFen); | |
| _hidePromotionDialog(); | |
| _pendingPromotion = null; | |
| _lastMoveKeys = undefined; | |
| _resetMoveLine(); | |
| _syncChessground(); | |
| // Position-change callback, as in playMove()/goBack()/goForward()/ | |
| // goToNode()/clear(). | |
| const nextFen = currentFen(); | |
| if (_onPositionChange) _onPositionChange(nextFen); | |
| return { ok: true }; | |
| } | |
| // Sets a new FEN and variant together in a single step - for a caller | |
| // that is replacing the position anyway (a shared URL, PGN import, | |
| // restoreTree()'s fallback path), so there is no "current position" | |
| // to preserve or validate against the new variant; the new FEN is | |
| // validated directly against the new variant instead (see | |
| // _buildPosition). Use setVariant() instead when the FEN is meant to | |
| // stay exactly as it is and only its interpretation should change | |
| // (e.g. the settings-panel toggle). | |
| function setPositionAndVariant(fen, variant) { | |
| const next = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD; | |
| const prevFen = _position ? currentFen() : null; | |
| if (fen === prevFen && next === _variant) return { ok: true }; // true no-op | |
| const built = _buildPosition(fen, next); | |
| if (!built.ok) return { ok: false, reason: built.reason }; | |
| _variant = next; | |
| _position = built.pos; | |
| if (prevFen !== null) _pushHistory(prevFen); | |
| _hidePromotionDialog(); | |
| _pendingPromotion = null; | |
| _lastMoveKeys = undefined; | |
| _resetMoveLine(); | |
| _syncChessground(); | |
| if (_onPositionChange) _onPositionChange(currentFen()); | |
| return { ok: true }; | |
| } | |
| function clear() { | |
| const prevFen = currentFen(); | |
| const resetFen = '4k3/8/8/8/8/8/8/4K3 w - - 0 1'; | |
| const built = _buildPosition(resetFen); | |
| console.assert(built.ok, 'clear(): resetFen must always be valid'); | |
| _pushHistory(prevFen); | |
| _position = built.pos; | |
| _hidePromotionDialog(); | |
| _pendingPromotion = null; | |
| _lastMoveKeys = undefined; | |
| _resetMoveLine(); | |
| _syncChessground(); | |
| if (_onPositionChange) _onPositionChange(resetFen); | |
| return resetFen; | |
| } | |
| // Mirrors chessground's own orientation as a class on #board-wrap-inner; | |
| // chess/board.css uses it to flip flex-direction so the spare-piece trays swap | |
| // sides visually without moving them in the DOM. | |
| function _syncTrayOrientation() { | |
| const wrapEl = document.getElementById('board-wrap-inner'); | |
| if (wrapEl) wrapEl.classList.toggle('orientation-black', cg.state.orientation === 'black'); | |
| } | |
| function flip() { | |
| cg.toggleOrientation(); | |
| _syncTrayOrientation(); | |
| Storage.write('chesstb_orientation', cg.state.orientation); | |
| clearArrows(); // arrows are orientation-dependent; caller (useExplorer.js) redraws right after flip | |
| } | |
| // ── Lock mode (public) ─────────────────────────────────────────────────── | |
| // Toggles legal-moves-only board interaction. Persisted to localStorage | |
| // the same way orientation is (see flip()) when persist defaults to | |
| // true; callers applying an already-known value (init() restoring on | |
| // load, or useExplorer.js's auto-play engaging/releasing Lock) pass persist:false. | |
| function setLocked(locked, persist = true) { | |
| _locked = !!locked; | |
| if (_locked) setClickPlacePiece(null); // drop any armed spare piece | |
| document.querySelectorAll('.spare-tray').forEach(el => { | |
| el.classList.toggle('is-locked', _locked); | |
| if (_locked) el.setAttribute('title', 'Board is locked'); | |
| else el.removeAttribute('title'); | |
| }); | |
| if (persist) Storage.write('chesstb_locked', _locked); | |
| _syncChessground(); | |
| } | |
| function isLocked() { return _locked; } | |
| // ── Variant (public) ───────────────────────────────────────────────────── | |
| // Persistence and toggle-panel wiring live in useExplorer.js and | |
| // SettingsPanel.jsx (Chess960 Mode is a Settings-panel item, alongside | |
| // Autoplay Delay and Show Root Row) - this module only holds the | |
| // current value and reacts to it changing. | |
| function setVariant(variant) { | |
| const next = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD; | |
| if (next === _variant) return { ok: true }; | |
| // This reinterprets the current position under a different | |
| // variant - the FEN itself is not changing (see | |
| // setPositionAndVariant() above for the "also loading a new | |
| // position" case) - so unlike every other _buildPosition() call in | |
| // this file, failure here must not fall back to a default | |
| // position or otherwise change what's on the board: the position | |
| // simply isn't representable under the requested variant's | |
| // castling rules (e.g. Chess960-only castling rights present, or | |
| // Chess960 castling rights on non-home squares - see | |
| // _nonOrthodoxCastlingRights), and the switch is refused outright, | |
| // leaving variant and position exactly as they were. | |
| if (_position) { | |
| const trial = _buildPosition(currentFen(), next); | |
| if (!trial.ok) { | |
| const modeName = next === VARIANTS.CHESS960 ? 'Chess960' : 'Standard'; | |
| return { ok: false, reason: `Can't switch to ${modeName} mode: ${trial.reason}` }; | |
| } | |
| } | |
| _variant = next; | |
| // The move tree belongs to the active variant; changing variants | |
| // starts a fresh tree from the current position. | |
| _lastMoveKeys = undefined; | |
| _resetMoveLine(); | |
| _syncChessground(); | |
| // Position change with no actual FEN change - still routed through | |
| // onPositionChange so callers re-probe under the new variant and | |
| // (via onPositionChange's own guard) auto-play is stopped rather | |
| // than left running against a variant it started under. | |
| if (_onPositionChange) _onPositionChange(currentFen()); | |
| return { ok: true }; | |
| } | |
| function getVariant() { return _variant; } | |
| function isChess960() { return _variant === VARIANTS.CHESS960; } | |
| // Legal move count for the current position, counting each | |
| // underpromotion choice separately (see _countLegalMoves above) and | |
| // including Chess960 castling. | |
| function legalMoveCount() { return _position ? _countLegalMoves(_position) : 0; } | |
| function isInsufficientMaterial() { return !!_position && _position.isInsufficientMaterial(); } | |
| // Result token for an arbitrary FEN, independent of the live board | |
| // state - used by pgnFormat.js's PGN-result detection. Returns '*' | |
| // (unknown/in progress) for a FEN that fails to parse. | |
| function outcomeFromFen(fen) { | |
| const built = _buildPosition(fen); | |
| if (!built.ok) return '*'; | |
| const pos = built.pos; | |
| if (pos.isCheckmate()) return pos.turn === 'white' ? '0-1' : '1-0'; | |
| if (pos.isStalemate() || pos.isInsufficientMaterial()) return '1/2-1/2'; | |
| return '*'; | |
| } | |
| function playMove(san) { | |
| if (!_position) return false; | |
| const move = parseSan(_position, san); | |
| if (!move) return false; | |
| const fromKey = makeSquare(move.from), toKey = makeSquare(move.to); | |
| const playedSan = makeSan(_position, move); | |
| _position.play(move); | |
| _lastMoveKeys = [fromKey, toKey]; | |
| // Track in the move tree (branches instead of overwriting if the | |
| // user had navigated back before playing this move). | |
| _addMove(playedSan, currentFen(), fromKey, toKey); | |
| _emitHistory(); | |
| _syncChessground(); | |
| // immediate=true: this is a single, definite move (played by | |
| // auto-play or by clicking a move in the ranked-moves table, the | |
| // two callers of playMove() - see useExplorer.js), not part of a | |
| // rapid-fire stream of changes the 300ms debounce guards against. | |
| if (_onPositionChange) _onPositionChange(currentFen(), true); | |
| return true; | |
| } | |
| // Back/Forward walk the move tree directly (parent / activeChild) so | |
| // stepping through a line that includes variations works the same as | |
| // stepping through the main line. The plain _history/_future FEN stack | |
| // is only consulted once navigation reaches the tree's root (id ROOT) | |
| // and goes further back than the tree itself - i.e. undoing past | |
| // whatever move/edit started the current tree (see _resetMoveLine). | |
| function goBack() { | |
| if (_currentId !== ROOT) { | |
| const parentId = _nodes[_currentId].parentId; | |
| const fen = parentId === ROOT ? _startFen : _nodes[parentId].fen; | |
| const built = _buildPosition(fen); | |
| if (!built.ok) { _syncChessground(); return false; } | |
| _position = built.pos; | |
| _lastMoveKeys = parentId === ROOT ? undefined : [_nodes[parentId].from, _nodes[parentId].to]; | |
| _currentId = parentId; | |
| } else { | |
| if (_history.length === 0) return false; | |
| const prevFen = _history.pop(); | |
| _future.push(currentFen()); | |
| const built = _buildPosition(prevFen); | |
| if (!built.ok) { | |
| _future.pop(); | |
| _history.push(prevFen); | |
| _syncChessground(); | |
| return false; | |
| } | |
| _position = built.pos; | |
| // The plain _history/_future stack holds FENs only, with no | |
| // accompanying from/to squares (unlike tree nodes), so there's | |
| // nothing to highlight as the last move here. | |
| _lastMoveKeys = undefined; | |
| // The loaded position does not match the tree's _startFen, so | |
| // the tree is reset. _resetMoveLine() also clears _future, so the | |
| // stack is saved and restored around the reset. | |
| const _pendingFuture = _future.slice(); | |
| _resetMoveLine(); | |
| _future = _pendingFuture; | |
| } | |
| _hidePromotionDialog(); | |
| _pendingPromotion = null; | |
| _syncChessground(); | |
| _emitHistory(); | |
| if (_onPositionChange) _onPositionChange(currentFen()); | |
| return true; | |
| } | |
| function goForward() { | |
| const activeChildId = _activeChildOf(_currentId); | |
| if (activeChildId !== null && activeChildId !== undefined) { | |
| const node = _nodes[activeChildId]; | |
| const built = _buildPosition(node.fen); | |
| if (!built.ok) { _syncChessground(); return false; } | |
| _position = built.pos; | |
| _lastMoveKeys = [node.from, node.to]; | |
| _currentId = activeChildId; | |
| } else if (_currentId === ROOT && _future.length > 0) { | |
| const nextFen = _future.pop(); | |
| const built = _buildPosition(nextFen); | |
| if (!built.ok) { | |
| _future.push(nextFen); | |
| _syncChessground(); | |
| return false; | |
| } | |
| _history.push(currentFen()); | |
| _position = built.pos; | |
| _lastMoveKeys = undefined; | |
| // Mirrors the goBack() fallback above: the position just loaded | |
| // isn't described by the current (already-reset) tree either, so | |
| // restart the tree here too and preserve any remaining redo | |
| // entries across the reset. | |
| const _pendingFuture = _future.slice(); | |
| _resetMoveLine(); | |
| _future = _pendingFuture; | |
| } else { | |
| return false; | |
| } | |
| _hidePromotionDialog(); | |
| _pendingPromotion = null; | |
| _syncChessground(); | |
| _emitHistory(); | |
| if (_onPositionChange) _onPositionChange(currentFen()); | |
| return true; | |
| } | |
| // goToNode() jumps to an arbitrary node in the current move tree (used | |
| // by the PGN panel - including variation moves, not just the main line). | |
| // Unlike setPosition(), this never calls _resetMoveLine(). targetId === | |
| // ROOT (-1) means "start of the tree" (the position before any move was | |
| // played). Clicking any node marks it - and every ancestor on the path | |
| // to it - as its parent's activeChild, so Forward continues along | |
| // whichever branch was just brought into view. | |
| function goToNode(targetId) { | |
| if (targetId !== ROOT && !_nodes[targetId]) return false; | |
| if (targetId === _currentId) return true; | |
| // Walked before touching _position/_syncChessground, and bounded to | |
| // at most one hop per node, so a corrupt parentId chain (see | |
| // _isValidTree) refuses the jump instead of looping forever. | |
| const chain = []; | |
| let walk = targetId; | |
| let guard = _nodes.length + 1; | |
| while (walk !== ROOT) { | |
| if (guard-- <= 0 || !_nodes[walk]) return false; | |
| chain.unshift(walk); | |
| walk = _nodes[walk].parentId; | |
| } | |
| const fen = targetId === ROOT ? _startFen : _nodes[targetId].fen; | |
| const built = _buildPosition(fen); | |
| if (!built.ok) return false; | |
| _position = built.pos; | |
| let parent = ROOT; | |
| chain.forEach(id => { _setActiveChild(parent, id); parent = id; }); | |
| _currentId = targetId; | |
| _lastMoveKeys = targetId === ROOT ? undefined : [_nodes[targetId].from, _nodes[targetId].to]; | |
| _hidePromotionDialog(); | |
| _pendingPromotion = null; | |
| _syncChessground(); | |
| _emitHistory(); | |
| if (_onPositionChange) _onPositionChange(currentFen()); | |
| return true; | |
| } | |
| // A legitimately-built tree (see _addMove above) only ever gives a node | |
| // a parentId that's ROOT or an already-existing, strictly smaller id, | |
| // and only ever lists children/activeChild with a strictly larger one, | |
| // so parentId chains and children references can never cycle or dangle. | |
| // Persisted state coming back out of localStorage bypasses that | |
| // construction path entirely; this re-checks the same invariant before | |
| // trusting it, since violating it would let goToNode()'s parentId walk | |
| // (or moveLine.js's tree walk) loop forever or index past the array. | |
| function _isValidNodeRef(id, nodesLength) { | |
| return typeof id === 'number' && Number.isInteger(id) && id >= 0 && id < nodesLength; | |
| } | |
| function _isValidTree(nodes, rootChildren) { | |
| if (!Array.isArray(nodes) || !Array.isArray(rootChildren)) return false; | |
| for (let id = 0; id < nodes.length; id++) { | |
| const node = nodes[id]; | |
| if (!node || typeof node.san !== 'string' || typeof node.fen !== 'string') return false; | |
| if (node.parentId !== ROOT && !(_isValidNodeRef(node.parentId, nodes.length) && node.parentId < id)) { | |
| return false; | |
| } | |
| if (!Array.isArray(node.children)) return false; | |
| for (const childId of node.children) { | |
| if (!_isValidNodeRef(childId, nodes.length) || childId <= id) return false; | |
| } | |
| if (node.activeChild !== null && node.activeChild !== undefined && | |
| !(_isValidNodeRef(node.activeChild, nodes.length) && node.activeChild > id)) { | |
| return false; | |
| } | |
| } | |
| return rootChildren.every(childId => _isValidNodeRef(childId, nodes.length)); | |
| } | |
| // restoreTree() rehydrates a full move tree (start position + every | |
| // node + current node) from persisted state in a single board update, | |
| // without replaying each move or triggering individual probes. | |
| // The active variant must already be established (via init()'s variant | |
| // argument) before this is called - this function only restores | |
| // position/tree data, never variant. | |
| function restoreTree(startFen, nodes, rootChildren, rootActiveChild, currentId) { | |
| if (!_position || !startFen) return false; | |
| const validTree = _isValidTree(nodes, rootChildren); | |
| const safeNodes = validTree ? nodes : []; | |
| const safeRootChildren = validTree ? rootChildren : []; | |
| const safeCurrentId = (currentId === ROOT || (validTree && _isValidNodeRef(currentId, safeNodes.length))) | |
| ? currentId : ROOT; | |
| const loadFen = safeCurrentId === ROOT ? startFen : safeNodes[safeCurrentId].fen; | |
| const built = _buildPosition(loadFen); | |
| if (!built.ok) { | |
| // Saved state doesn't parse (e.g. corrupted storage) - fall back | |
| // to just the start position with an empty tree. | |
| const fallback = _buildPosition(startFen); | |
| _position = fallback.ok ? fallback.pos : Chess.default(); | |
| _startFen = currentFen(); | |
| _nodes = []; | |
| _rootChildren = []; | |
| _rootActiveChild = null; | |
| _currentId = ROOT; | |
| _history = []; | |
| _future = []; | |
| _lastMoveKeys = undefined; | |
| if (cg) { | |
| _hidePromotionDialog(); | |
| _syncChessground(); | |
| } | |
| _emitHistory(); | |
| return false; | |
| } | |
| _position = built.pos; | |
| _startFen = startFen; | |
| _nodes = safeNodes; | |
| _rootChildren = safeRootChildren; | |
| _rootActiveChild = _isValidNodeRef(rootActiveChild, safeNodes.length) ? rootActiveChild : null; | |
| _currentId = safeCurrentId; | |
| _history = []; | |
| _future = []; | |
| _lastMoveKeys = (safeCurrentId === ROOT || !safeNodes[safeCurrentId]) | |
| ? undefined | |
| : [safeNodes[safeCurrentId].from, safeNodes[safeCurrentId].to]; | |
| _hidePromotionDialog(); | |
| _pendingPromotion = null; | |
| if (cg) _syncChessground(); | |
| _emitHistory(); | |
| return true; | |
| } | |
| // Install a parsed PGN in one update. Only the displayed start position | |
| // is probed; every imported move stays available for navigation/export. | |
| function importGame(tree, variant) { | |
| const next = variant === VARIANTS.CHESS960 ? VARIANTS.CHESS960 : VARIANTS.STANDARD; | |
| const built = _buildPosition(tree.startFen, next); | |
| if (!built.ok) return { ok: false, reason: built.reason }; | |
| if (!_isValidTree(tree.nodes, tree.rootChildren)) { | |
| return { ok: false, reason: 'Invalid move tree in PGN.' }; | |
| } | |
| _variant = next; | |
| _position = built.pos; | |
| restoreTree(tree.startFen, tree.nodes, tree.rootChildren, tree.rootActiveChild, ROOT); | |
| if (_onPositionChange) _onPositionChange(currentFen(), true); | |
| return { ok: true }; | |
| } | |
| function canGoBack() { return _currentId !== ROOT || _history.length > 0; } | |
| function canGoForward() { | |
| const activeChildId = _activeChildOf(_currentId); | |
| return (activeChildId !== null && activeChildId !== undefined) || (_currentId === ROOT && _future.length > 0); | |
| } | |
| function getMoveHistory() { | |
| return { nodes: _nodes, rootChildren: _rootChildren, rootActiveChild: _rootActiveChild, currentId: _currentId }; | |
| } | |
| function getStartFen() { return _startFen; } // PGN formatting | |
| function setOnMoveHistoryChange(fn) { | |
| _onMoveHistoryChange = fn; | |
| } | |
| return { | |
| init, reconstruct, setPosition, setPositionAndVariant, clear, flip, currentFen, | |
| playMove, goBack, goForward, goToNode, restoreTree, importGame, canGoBack, canGoForward, | |
| getMoveHistory, getStartFen, setOnMoveHistoryChange, | |
| drawArrows, clearArrows, | |
| setLocked, isLocked, | |
| setVariant, getVariant, isChess960, legalMoveCount, isInsufficientMaterial, outcomeFromFen, | |
| }; | |
| })(); | |
| export { Board, VARIANTS }; | |