/** * 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 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 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