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