draughts
Version:
A javascript draughts library for move generation/validation, piece placement/movement and draw detection
1,518 lines (1,318 loc) • 46.2 kB
JavaScript
'use strict'
/**
* @typedef {Object} MoveObject
* @property {number} from - Source square number (1-50)
* @property {number} to - Destination square number (1-50)
* @property {string} flags - Move flags: 'n' (normal), 'c' (capture), 'p' (promotion)
* @property {string} piece - The piece being moved ('b', 'B', 'w', 'W')
* @property {number[]} [takes] - Array of captured piece positions
* @property {number[]} [captures] - Alias for takes (captured piece positions)
* @property {string[]} [piecesCaptured] - Array of captured pieces
* @property {string[]} [piecesTaken] - Alias for piecesCaptured
* @property {number[]} [jumps] - Array of positions in the jump sequence
*/
/**
* @typedef {Object} ValidationResult
* @property {boolean} valid - Whether the FEN is valid
* @property {Object} [error] - Error object if validation fails
* @property {number} [error.code] - Error code
* @property {string} [error.message] - Error message
* @property {string} fen - The original FEN string
*/
/**
* @typedef {Object} HistoryEntry
* @property {MoveObject} move - The move that was made
* @property {string} turn - The player who made the move ('B' or 'W')
* @property {number} moveNumber - The move number
*/
/**
* @typedef {Object} GameState
* @property {string} position - Internal position representation (56 chars)
* @property {string} turn - Current player turn ('B' or 'W')
* @property {number} moveNumber - Current move number
* @property {HistoryEntry[]} history - Game move history
* @property {Object<string, string>} header - PDN header information
*/
/**
* @typedef {Object} CaptureState
* @property {string} position - Current board position
* @property {string} dirFrom - Direction came from (to avoid backtracking)
*/
/**
* @typedef {Object} DirectionStrings
* @property {string} NE - Northeast direction string
* @property {string} SE - Southeast direction string
* @property {string} SW - Southwest direction string
* @property {string} NW - Northwest direction string
*/
/*
||==================================================================================
|| DESCRIPTION OF IMPLEMENTATION PRINCIPLES
|| A. Position for rules (internal representation): string with length 56.
|| Special numbering for easy applying rules.
|| Valid characters: b B w W 0 -
|| b (black) B (black king) w (white) W (white king) 0 (empty) (- unused)
|| Examples:
|| '-bbbBBB000w-wwWWWwwwww-bbbbbbbbbb-000wwwwwww-00bbbwwWW0-'
|| '-0000000000-0000000000-0000000000-0000000000-0000000000-' (empty position)
|| '-bbbbbbbbbb-bbbbbbbbbb-0000000000-wwwwwwwwww-wwwwwwwwww-' (start position)
|| B. Position (external respresentation): string with length 51.
|| Square numbers are represented by the position of the characters.
|| Position 0 is reserved for the side to move (B or W)
|| Valid characters: b B w W 0
|| b (black) B (black king) w (white) W (white king) 0 (empty)
|| Examples:
|| 'B00000000000000000000000000000000000000000000000000' (empty position)
|| 'Wbbbbbbbbbbbbbbbbbbbb0000000000wwwwwwwwwwwwwwwwwwww' (start position)
|| 'WbbbbbbBbbbbb00bbbbb000000w0W00ww00wwwwww0wwwwwwwww' (random position)
||
|| External numbering Internal Numbering
|| -------------------- --------------------
|| 01 02 03 04 05 01 02 03 04 05
|| 06 07 08 09 10 06 07 08 09 10
|| 11 12 13 14 15 12 13 14 15 16
|| 16 17 18 19 20 17 18 19 20 21
|| 21 22 23 24 25 23 24 25 26 27
|| 26 27 28 29 30 28 29 30 31 32
|| 31 32 33 34 35 34 35 36 37 38
|| 36 37 38 39 40 39 40 41 42 43
|| 41 42 43 44 45 45 46 47 48 49
|| 46 47 48 49 50 50 51 52 53 54
|| -------------------- --------------------
||
|| Internal numbering has fixed direction increments for easy applying rules:
|| NW NE -5 -6
|| \ / \ /
|| sQr >> sQr
|| / \ / \
|| SW SE +5 +6
||
|| DIRECTION-STRINGS
|| Strings of variable length for each of four directions at one square.
|| Each string represents the position in that direction.
|| Directions: NE, SE, SW, NW (wind directions)
|| Example for square 29 (internal number):
|| NE: 29, 24, 19, 14, 09, 04 b00bb0
|| SE: 35, 41, 47, 53 bww0
|| SW: 34, 39 b0
|| NW: 23, 17 bw
|| CONVERSION internal to external representation of numbers.
|| N: external number, values 1..50
|| M: internal number, values 0..55 (invalid 0,11,22,33,44,55)
|| Formulas:
|| M = N + floor((N-1)/10)
|| N = M - floor((M-1)/11)
||
||==================================================================================
*/
/**
* Creates a new Draughts game instance
* @param {string} [fen] - FEN string to initialize the game position
* @constructor
*/
function Draughts(fen) {
// Game constants
const BLACK = 'B'
const WHITE = 'W'
const MAN = 'b'
const KING = 'w'
const SYMBOLS = 'bwBW'
const DEFAULT_FEN = 'W:W31-50:B1-20'
const DEFAULT_POSITION_INTERNAL = '-bbbbbbbbbb-bbbbbbbbbb-0000000000-wwwwwwwwww-wwwwwwwwww-'
const DEFAULT_POSITION_EXTERNAL = 'Wbbbbbbbbbbbbbbbbbbbb0000000000wwwwwwwwwwwwwwwwwwww'
const STEPS = { NE: -5, SE: 6, SW: 5, NW: -6 }
const POSSIBLE_RESULTS = ['2-0', '0-2', '1-1', '0-0', '*', '1-0', '0-1']
const FLAGS = {
NORMAL: 'n',
CAPTURE: 'c',
PROMOTION: 'p'
}
const UNICODES = {
'w': '\u26C0',
'b': '\u26C2',
'B': '\u26C3',
'W': '\u26C1',
'0': '\u0020\u0020'
}
const SIGNS = {
n: '-',
c: 'x'
}
const BITS = {
NORMAL: 1,
CAPTURE: 2,
PROMOTION: 4
}
// Game state variables
let position
let turn = WHITE
let moveNumber = 1
let history = []
let header = {}
/**
* Clears the board to empty state
* @returns {void}
*/
const clear = () => {
position = DEFAULT_POSITION_INTERNAL
turn = WHITE
moveNumber = 1
history = []
header = {}
update_setup(generate_fen())
}
/**
* Resets the game to the starting position
* @returns {void}
*/
const reset = () => {
load(DEFAULT_FEN)
}
/**
* Loads a position from FEN string
* @param {string} fen - FEN string representing the position
* @returns {boolean} True if FEN was loaded successfully, false otherwise
*/
const load = (fen) => {
// Handle default FEN
if (!fen || fen === DEFAULT_FEN) {
position = DEFAULT_POSITION_INTERNAL
update_setup(generate_fen(position))
return true
}
const checkedFen = validate_fen(fen)
if (!checkedFen.valid) {
console.error('Fen Error', fen, checkedFen)
return false
}
clear()
// Clean up FEN string
fen = fen.replace(/\s+/g, '').replace(/\..*$/, '')
const tokens = fen.split(':')
// Set which side to move
turn = tokens[0].slice(0, 1)
let externalPosition = DEFAULT_POSITION_EXTERNAL
for (let i = 1; i <= externalPosition.length; i++) {
externalPosition = setCharAt(externalPosition, i, 0)
}
externalPosition = setCharAt(externalPosition, 0, turn)
// Process both sides (white and black)
for (let k = 1; k <= 2; k++) {
const color = tokens[k].slice(0, 1)
const sideString = tokens[k].slice(1)
if (sideString.length === 0) continue
const numbers = sideString.split(',')
for (let i = 0; i < numbers.length; i++) {
let numSquare = numbers[i]
const isKing = numSquare.slice(0, 1) === 'K'
numSquare = isKing ? numSquare.slice(1) : numSquare // strip K
const range = numSquare.split('-')
if (range.length === 2) {
const from = parseInt(range[0], 10)
const to = parseInt(range[1], 10)
for (let j = from; j <= to; j++) {
const pieceChar = isKing ? color.toUpperCase() : color.toLowerCase()
externalPosition = setCharAt(externalPosition, j, pieceChar)
}
} else {
const squareNum = parseInt(numSquare, 10)
const pieceChar = isKing ? color.toUpperCase() : color.toLowerCase()
externalPosition = setCharAt(externalPosition, squareNum, pieceChar)
}
}
}
position = convertPosition(externalPosition, 'internal')
update_setup(generate_fen(position))
return true
}
/**
* Validates a FEN string for correctness
* @param {string} fen - FEN string to validate
* @returns {ValidationResult} Object containing validation result and error info
*/
const validate_fen = (fen) => {
const errors = [
{ code: 0, message: 'no errors' },
{ code: 1, message: 'fen position not a string' },
{ code: 2, message: 'fen position has not colon at second position' },
{ code: 3, message: 'fen position has not 2 colons' },
{ code: 4, message: 'side to move of fen position not valid' },
{ code: 5, message: 'color(s) of sides of fen position not valid' },
{ code: 6, message: 'squares of fen position not integer' },
{ code: 7, message: 'squares of fen position not valid' },
{ code: 8, message: 'empty fen position' }
]
if (typeof fen !== 'string') {
return { valid: false, error: errors[0], fen }
}
fen = fen.replace(/\s+/g, '')
// Handle empty FEN exceptions
if (fen === 'B::' || fen === 'W::' || fen === '?::') {
return { valid: true, fen: `${fen}:B:W` }
}
fen = fen.trim().replace(/\..*$/, '')
if (fen === '') {
return { valid: false, error: errors[7], fen }
}
if (fen.slice(1, 2) !== ':') {
return { valid: false, error: errors[1], fen }
}
// FEN should be 3 sections separated by colons
const parts = fen.split(':')
if (parts.length !== 3) {
return { valid: false, error: errors[2], fen }
}
// Validate side to move
const turnColor = parts[0]
if (!['B', 'W', '?'].includes(turnColor)) {
return { valid: false, error: errors[3], fen }
}
// Check colors of both sides
const colors = parts[1].slice(0, 1) + parts[2].slice(0, 1)
if (!['BW', 'WB'].includes(colors)) {
return { valid: false, error: errors[4], fen }
}
// Validate pieces for both sides
for (let k = 1; k <= 2; k++) {
const sideString = parts[k].slice(1) // Strip color
if (sideString.length === 0) continue
const numbers = sideString.split(',')
for (const numberStr of numbers) {
let numSquare = numberStr
const isKing = numSquare.slice(0, 1) === 'K'
numSquare = isKing ? numSquare.slice(1) : numSquare
const range = numSquare.split('-')
if (range.length === 2) {
// Validate range
for (const rangeVal of range) {
if (!isInteger(rangeVal)) {
return { valid: false, error: errors[5], fen, range: rangeVal }
}
const num = parseInt(rangeVal, 10)
if (num < 1 || num > 100) {
return { valid: false, error: errors[6], fen }
}
}
} else {
// Validate single square
if (!isInteger(numSquare)) {
return { valid: false, error: errors[5], fen }
}
const num = parseInt(numSquare, 10)
if (num < 1 || num > 100) {
return { valid: false, error: errors[6], fen }
}
}
}
}
return { valid: true, error_number: 0, error: errors[0] }
}
/**
* Generates FEN string from current position
* @returns {string} FEN string representing the current position
*/
const generate_fen = () => {
const black = []
const white = []
const externalPosition = convertPosition(position, 'external')
for (let i = 0; i < externalPosition.length; i++) {
const piece = externalPosition[i]
switch (piece) {
case 'w':
white.push(i)
break
case 'W':
white.push(`K${i}`)
break
case 'b':
black.push(i)
break
case 'B':
black.push(`K${i}`)
break
default:
// Empty square or invalid piece
break
}
}
return `${turn.toUpperCase()}:W${white.join(',')}:B${black.join(',')}`
}
/**
* Generates PDN (Portable Draughts Notation) string from current game
* @param {Object} [options] - Options for PDN generation
* @param {string} [options.newline_char='\n'] - Character to use for newlines
* @param {number} [options.maxWidth=0] - Maximum line width (0 = no limit)
* @returns {string} PDN string representing the game
*/
const generatePDN = (options = {}) => {
const { newline_char: newline = '\n', maxWidth = 0 } = options
const result = []
let headerExists = false
// Add header information
for (const [key, value] of Object.entries(header)) {
result.push(`[${key} "${value}"]${newline}`)
headerExists = true
}
if (headerExists && history.length) {
result.push(newline)
}
const tempHistory = clone(history)
const moves = []
let moveString = ''
let moveNum = 1
// Process move history
while (tempHistory.length > 0) {
const historyEntry = tempHistory.shift()
if (historyEntry.turn === 'W') {
moveString += `${moveNum}. `
}
moveString += historyEntry.move.from
moveString += historyEntry.move.flags === 'c' ? 'x' : '-'
moveString += `${historyEntry.move.to} `
moveNum++
}
if (moveString.length) {
moves.push(moveString)
}
// Add game result if available
if (header.Result !== undefined) {
moves.push(header.Result)
}
if (maxWidth === 0) {
return result.join('') + moves.join(' ')
}
// Handle line width constraints
let currentWidth = 0
for (let i = 0; i < moves.length; i++) {
if (currentWidth + moves[i].length > maxWidth && i !== 0) {
if (result[result.length - 1] === ' ') {
result.pop()
}
result.push(newline)
currentWidth = 0
} else if (i !== 0) {
result.push(' ')
currentWidth++
}
result.push(' ')
currentWidth += moves[i].length
}
return result.join('')
}
/**
* Sets header properties from arguments array
* @param {string[]} args - Array of alternating key-value pairs
* @returns {Object<string, string>} Updated header object
*/
const set_header = (args) => {
for (let i = 0; i < args.length; i += 2) {
if (typeof args[i] === 'string' && typeof args[i + 1] === 'string') {
header[args[i]] = args[i + 1]
}
}
return header
}
/**
* Updates setup properties in header when board position changes
* Only updates if no moves have been made yet
* @param {string} fen - FEN string of current position
* @returns {boolean|void} False if moves have been made, void otherwise
*/
const update_setup = (fen) => {
if (history.length > 0) {
return false
}
if (fen !== DEFAULT_FEN) {
header.SetUp = '1'
header.FEN = fen
} else {
delete header.SetUp
delete header.FEN
}
}
/**
* Parses a PDN (Portable Draughts Notation) string and loads the game
* @param {string} pdn - PDN string to parse
* @param {Object} [options] - Parsing options
* @param {string} [options.newline_char='\r?\n'] - Newline character pattern
* @returns {boolean} True if PDN was parsed successfully, false otherwise
*/
const parsePDN = (pdn, options = {}) => {
const { newline_char = '\r?\n' } = options
/**
* Escapes special regex characters
* @param {string} str - String to escape
* @returns {string} Escaped string
*/
const mask = (str) => str.replace(/\\/g, '\\')
const regex = new RegExp(`^(\\[(.|${mask(newline_char)})*\\])` +
`(${mask(newline_char)})*` +
`1.(${mask(newline_char)}|.)*$`, 'g')
/**
* Parses PDN header section
* @param {string} headerStr - Header string to parse
* @param {Object} opts - Options object
* @returns {Object<string, string>} Parsed header object
*/
const parsePDNHeader = (headerStr, opts) => {
const headerObj = {}
const headers = headerStr.split(new RegExp(mask(newline_char)))
for (const headerLine of headers) {
const key = headerLine.replace(/^\[([A-Z][A-Za-z]*)\s.*\]$/, '$1')
const value = headerLine.replace(/^\[[A-Za-z]+\s"(.*)"\]$/, '$1')
if (trim(key).length > 0) {
headerObj[key] = value
}
}
return headerObj
}
let headerString = pdn.replace(regex, '$1')
if (headerString[0] !== '[') {
headerString = ''
}
reset()
const headers = parsePDNHeader(headerString, options)
// Set header properties
for (const [key, value] of Object.entries(headers)) {
set_header([key, value])
}
// Handle custom setup
if (headers.Setup === '1') {
if (!('FEN' in headers) || !load(headers.FEN)) {
console.error('fen invalid')
return false
}
} else {
position = DEFAULT_POSITION_INTERNAL
}
// Extract moves from PDN
let moveStr = pdn.replace(headerString, '').replace(new RegExp(mask(newline_char), 'g'), ' ')
// Clean up move string
moveStr = moveStr
.replace(/(\{[^}]+\})+?/g, '') // Remove comments
.replace(/\d+\./g, '') // Remove move numbers
.replace(/\.\.\./g, '') // Remove black-to-move indicators
// Remove recursive annotation variations
const ravRegex = /(\([^\(\)]+\))+?/g
while (ravRegex.test(moveStr)) {
moveStr = moveStr.replace(ravRegex, '')
}
// Parse moves
let moves = trim(moveStr).split(/\s+/)
moves = moves.join(',').replace(/,,+/g, ',').split(',')
// Process moves
for (let halfMove = 0; halfMove < moves.length - 1; halfMove++) {
const move = getMoveObject(moves[halfMove])
if (!move) {
return false
}
makeMove(move)
}
// Handle final move or result
const lastEntry = moves[moves.length - 1]
if (POSSIBLE_RESULTS.includes(lastEntry)) {
if (headers.Result === undefined) {
set_header(['Result', lastEntry])
}
} else {
const move = getMoveObject(lastEntry)
if (!move) {
return false
}
makeMove(move)
}
return true
}
/**
* Creates a move object from algebraic notation
* @param {string} move - Move in algebraic notation (e.g., '1-5' or '1x10')
* @returns {MoveObject|false} Move object if valid, false otherwise
*/
const getMoveObject = (move) => {
const tempMove = {}
const matches = move.split(/[x|-]/)
tempMove.from = parseInt(matches[0], 10)
tempMove.to = parseInt(matches[1], 10)
const moveTypeMatch = move.match(/[x|-]/)
if (!moveTypeMatch) return false
const moveType = moveTypeMatch[0]
tempMove.flags = moveType === '-' ? FLAGS.NORMAL : FLAGS.CAPTURE
tempMove.piece = position.charAt(convertNumber(tempMove.from, 'internal'))
const legalMoves = convertMoves(getLegalMoves(tempMove.from), 'external')
// Find matching legal move
for (const legalMove of legalMoves) {
if (tempMove.to === legalMove.to && tempMove.from === legalMove.from) {
if (legalMove.takes.length > 0) {
tempMove.flags = FLAGS.CAPTURE
tempMove.captures = legalMove.takes
tempMove.takes = legalMove.takes
tempMove.piecesCaptured = legalMove.piecesTaken
}
return tempMove
}
}
console.log(legalMoves, tempMove)
return false
}
/**
* Executes a move on the board
* @param {MoveObject} move - Move object to execute
* @returns {void}
*/
const makeMove = (move) => {
move.piece = position.charAt(convertNumber(move.from, 'internal'))
position = setCharAt(position, convertNumber(move.to, 'internal'), move.piece)
position = setCharAt(position, convertNumber(move.from, 'internal'), 0)
move.flags = FLAGS.NORMAL
// Handle captures
if (move.takes && move.takes.length) {
move.flags = FLAGS.CAPTURE
move.captures = move.takes
move.piecesCaptured = move.piecesTaken
for (const captureSquare of move.takes) {
position = setCharAt(position, convertNumber(captureSquare, 'internal'), 0)
}
}
// Handle promotions
const isWhitePromotion = move.to <= 5 && move.piece === 'w'
const isBlackPromotion = move.to >= 46 && move.piece === 'b'
if (isWhitePromotion || isBlackPromotion) {
move.flags = FLAGS.PROMOTION
position = setCharAt(position, convertNumber(move.to, 'internal'), move.piece.toUpperCase())
}
push(move)
if (turn === BLACK) {
moveNumber += 1
}
turn = swap_color(turn)
}
/**
* Gets the piece at a given square
* @param {number} square - Square number (1-50)
* @returns {string} Piece at square ('b', 'B', 'w', 'W', '0' for empty)
*/
const get = (square) => {
return position.charAt(convertNumber(square, 'internal'))
}
/**
* Places a piece on a square
* @param {string} piece - Piece to place ('b', 'B', 'w', 'W')
* @param {number} square - Square number (1-50)
* @returns {boolean} True if piece was placed successfully, false otherwise
*/
const put = (piece, square) => {
// Validate piece
if (!SYMBOLS.includes(piece)) {
return false
}
// Validate square
if (outsideBoard(convertNumber(square, 'internal'))) {
return false
}
position = setCharAt(position, convertNumber(square, 'internal'), piece)
update_setup(generate_fen())
return true
}
/**
* Removes a piece from a square
* @param {number} square - Square number (1-50)
* @returns {string} The piece that was removed
*/
const remove = (square) => {
const piece = get(square)
position = setCharAt(position, convertNumber(square, 'internal'), 0)
update_setup(generate_fen())
return piece
}
/**
* Builds a move object with given parameters
* @param {Object} board - Board representation
* @param {number} from - Source square
* @param {number} to - Destination square
* @param {number} flags - Move flags (BITS constants)
* @param {boolean} promotion - Whether this is a promotion
* @returns {MoveObject} Constructed move object
*/
const build_move = (board, from, to, flags, promotion) => {
const move = {
color: turn,
from,
to,
flags,
piece: board[from].type
}
if (promotion) {
move.flags |= BITS.PROMOTION
}
if (board[to]) {
move.captured = board[to].type
} else if (flags & BITS.CAPTURE) {
move.captured = MAN
}
return move
}
/**
* Generates all legal moves for current position or specific square
* @param {number} [square] - Optional specific square to get moves for
* @returns {MoveObject[]} Array of legal move objects
*/
const generate_moves = (square) => {
let moves = []
if (square) {
moves = getLegalMoves(square)
} else {
const captures = getCaptures()
// Captures are mandatory - if available, return only captures
if (captures.length) {
return captures.map(capture => ({
...capture,
flags: FLAGS.CAPTURE,
captures: capture.jumps,
piecesCaptured: capture.piecesTaken
}))
}
moves = getMoves()
}
// Flatten nested arrays
return moves.flat()
}
/**
* Gets all legal moves for a specific square
* @param {number} index - Square number (external notation 1-50)
* @returns {MoveObject[]} Array of legal moves from the square
*/
const getLegalMoves = (index) => {
let legalMoves = []
const squareNum = parseInt(index, 10)
if (!Number.isNaN(squareNum)) {
const internalIndex = convertNumber(squareNum, 'internal')
const captureState = { position, dirFrom: '' }
const captureObj = { jumps: [internalIndex], takes: [], piecesTaken: [] }
let captures = capturesAtSquare(internalIndex, captureState, captureObj)
captures = longestCapture(captures)
// Captures are mandatory if available
legalMoves = captures.length > 0 ? captures : movesAtSquare(internalIndex)
}
return convertMoves(legalMoves, 'external')
}
/**
* Gets all legal moves for the current player
* @param {number} [index] - Optional square index (unused parameter)
* @returns {MoveObject[]} Array of all legal moves for current player
*/
const getMoves = (index) => {
const moves = []
const currentPlayer = turn
for (let i = 1; i < position.length; i++) {
const piece = position[i]
if (piece === currentPlayer || piece === currentPlayer.toLowerCase()) {
const squareMoves = movesAtSquare(i)
if (squareMoves.length) {
moves.push(...convertMoves(squareMoves, 'external'))
}
}
}
return moves
}
/**
* Sets character at specific index in position string
* @param {string} posStr - Position string to modify
* @param {number} idx - Index to modify
* @param {string|number} chr - Character to set
* @returns {string} Modified position string
*/
const setCharAt = (posStr, idx, chr) => {
const index = parseInt(idx, 10)
if (index > posStr.length - 1) {
return posStr.toString()
}
return `${posStr.slice(0, index)}${chr}${posStr.slice(index + 1)}`
}
/**
* Gets all possible moves from a specific square
* @param {number} square - Internal square index
* @returns {MoveObject[]} Array of possible moves from the square
*/
const movesAtSquare = (square) => {
const moves = []
const piece = position.charAt(square)
switch (piece) {
case 'b':
case 'w': {
// Regular pieces (men) can only move one square diagonally
const dirStrings = directionStrings(position, square, 2)
for (const [dir, str] of Object.entries(dirStrings)) {
const matchArray = str.match(/^[bw]0/) // piece followed by empty square
if (matchArray && validDir(piece, dir)) {
const posTo = square + STEPS[dir]
moves.push({ from: square, to: posTo, takes: [], jumps: [] })
}
}
break
}
case 'W':
case 'B': {
// Kings can move multiple squares diagonally
const dirStrings = directionStrings(position, square, 2)
for (const [dir, str] of Object.entries(dirStrings)) {
const matchArray = str.match(/^[BW]0+/) // king followed by empty squares
if (matchArray) {
// Can move to any empty square in this direction
for (let i = 1; i < matchArray[0].length; i++) {
const posTo = square + (i * STEPS[dir])
moves.push({ from: square, to: posTo, takes: [], jumps: [] })
}
}
}
break
}
default:
// Invalid piece or empty square
break
}
return moves
}
/**
* Gets all possible captures for the current player
* @returns {MoveObject[]} Array of capture moves
*/
const getCaptures = () => {
const currentPlayer = turn
let captures = []
for (let i = 0; i < position.length; i++) {
const piece = position[i]
if (piece === currentPlayer || piece === currentPlayer.toLowerCase()) {
const state = { position, dirFrom: '' }
const captureObj = {
jumps: [i],
takes: [],
from: i,
to: '',
piecesTaken: []
}
const squareCaptures = capturesAtSquare(i, state, captureObj)
if (squareCaptures.length) {
captures.push(...convertMoves(squareCaptures, 'external'))
}
}
}
return longestCapture(captures)
}
/**
* Recursively finds all possible captures from a given square
* @param {number} posFrom - Starting position (internal notation)
* @param {CaptureState} state - Current board state
* @param {Object} capture - Current capture sequence
* @returns {MoveObject[]} Array of possible capture sequences
*/
const capturesAtSquare = (posFrom, state, capture) => {
const piece = state.position.charAt(posFrom)
if (!['b', 'w', 'B', 'W'].includes(piece)) {
return [capture]
}
// Get direction strings based on piece type
const dirString = (piece === 'b' || piece === 'w')
? directionStrings(state.position, posFrom, 3)
: directionStrings(state.position, posFrom)
let finished = true
const captureArrayForDir = {}
for (const [dir, str] of Object.entries(dirString)) {
// Skip the direction we came from
if (dir === state.dirFrom) continue
switch (piece) {
case 'b':
case 'w': {
// Regular pieces: look for enemy piece followed by empty square
const matchArray = str.match(/^b[wW]0|^w[bB]0/)
if (matchArray) {
const posTo = posFrom + (2 * STEPS[dir])
const posTake = posFrom + STEPS[dir]
// Can't capture the same piece twice
if (capture.takes.includes(posTake)) continue
const updateCapture = { ...clone(capture) }
updateCapture.to = posTo
updateCapture.jumps.push(posTo)
updateCapture.takes.push(posTake)
updateCapture.piecesTaken.push(position.charAt(posTake))
updateCapture.from = posFrom
const updateState = { ...clone(state) }
updateState.dirFrom = oppositeDir(dir)
const pieceCode = updateState.position.charAt(posFrom)
updateState.position = setCharAt(updateState.position, posFrom, 0)
updateState.position = setCharAt(updateState.position, posTo, pieceCode)
finished = false
captureArrayForDir[dir] = capturesAtSquare(posTo, updateState, updateCapture)
}
break
}
case 'B':
case 'W': {
// Kings: look for enemy piece with empty squares after it
const matchArray = str.match(/^B0*[wW]0+|^W0*[bB]0+/)
if (matchArray) {
const matchStr = matchArray[0]
const matchArraySubstr = matchStr.match(/[wW]0+$|[bB]0+$/)
const matchSubstr = matchArraySubstr[0]
const takeIndex = matchStr.length - matchSubstr.length
const posTake = posFrom + (takeIndex * STEPS[dir])
// Can't capture the same piece twice
if (capture.takes.includes(posTake)) continue
// King can land on any empty square after the captured piece
for (let i = 1; i < matchSubstr.length; i++) {
const posTo = posFrom + ((takeIndex + i) * STEPS[dir])
const updateCapture = { ...clone(capture) }
updateCapture.jumps.push(posTo)
updateCapture.to = posTo
updateCapture.takes.push(posTake)
updateCapture.piecesTaken.push(position.charAt(posTake))
updateCapture.posFrom = posFrom
const updateState = { ...clone(state) }
updateState.dirFrom = oppositeDir(dir)
const pieceCode = updateState.position.charAt(posFrom)
updateState.position = setCharAt(updateState.position, posFrom, 0)
updateState.position = setCharAt(updateState.position, posTo, pieceCode)
finished = false
const dirIndex = `${dir}${i}`
captureArrayForDir[dirIndex] = capturesAtSquare(posTo, updateState, updateCapture)
}
}
break
}
default:
break
}
}
// Collect all capture sequences
let captureArray = []
if (finished && capture.takes.length) {
// No more captures possible, finalize this sequence
capture.from = capture.jumps[0]
captureArray = [capture]
} else {
// Continue with further captures
for (const sequences of Object.values(captureArrayForDir)) {
captureArray.push(...sequences)
}
}
return captureArray
}
/**
* Adds a move to the game history
* @param {MoveObject} move - Move to add to history
* @returns {void}
*/
const push = (move) => {
history.push({
move,
turn,
moveNumber
})
}
/**
* Undoes the last move made
* @returns {MoveObject|null} The undone move object, or null if no moves to undo
*/
const undoMove = () => {
const lastEntry = history.pop()
if (!lastEntry) {
return null
}
const { move, turn: oldTurn, moveNumber: oldMoveNumber } = lastEntry
turn = oldTurn
moveNumber = oldMoveNumber
// Restore piece to original position
position = setCharAt(position, convertNumber(move.from, 'internal'), move.piece)
position = setCharAt(position, convertNumber(move.to, 'internal'), 0)
if (move.flags === 'c') {
// Restore captured pieces
for (let i = 0; i < move.captures.length; i++) {
const capturePos = convertNumber(move.captures[i], 'internal')
position = setCharAt(position, capturePos, move.piecesCaptured[i])
}
} else if (move.flags === 'p') {
// Handle promotion undo
if (move.captures) {
for (let i = 0; i < move.captures.length; i++) {
const capturePos = convertNumber(move.captures[i], 'internal')
position = setCharAt(position, capturePos, move.piecesCaptured[i])
}
}
// Demote the piece back to regular piece
position = setCharAt(position, convertNumber(move.from, 'internal'), move.piece.toLowerCase())
}
return move
}
/**
* Gets disambiguator for a move (placeholder function)
* @param {MoveObject} move - Move to get disambiguator for
* @returns {void} Currently not implemented
*/
const get_disambiguator = (move) => {
// TODO: Implementation needed
}
/**
* Swaps the color from white to black or vice versa
* @param {string} c - Color to swap ('W' or 'B')
* @returns {string} Opposite color
*/
const swap_color = (c) => c === WHITE ? BLACK : WHITE
/**
* Checks if a value is an integer
* @param {*} int - Value to check
* @returns {boolean} True if the value is an integer string
*/
const isInteger = (int) => /^\d+$/.test(int)
/**
* Filters captures to only include the longest sequences (mandatory capture rule)
* @param {MoveObject[]} captures - Array of capture moves
* @returns {MoveObject[]} Array of longest capture moves only
*/
const longestCapture = (captures) => {
if (captures.length === 0) return []
// Find the maximum number of jumps in any capture sequence
const maxJumpCount = Math.max(...captures.map(capture => capture.jumps.length))
// Must be at least 2 jumps to be a capture (from -> to)
if (maxJumpCount < 2) {
return []
}
// Return only captures with the maximum number of jumps
return captures.filter(capture => capture.jumps.length === maxJumpCount)
}
/**
* Converts moves between internal and external notation
* @param {MoveObject[]} moves - Array of moves to convert
* @param {string} type - Target notation ('internal' or 'external')
* @returns {MoveObject[]} Array of converted moves
*/
const convertMoves = (moves, type) => {
if (!type || moves.length === 0) {
return []
}
return moves.map(move => ({
jumps: move.jumps.map(jump => convertNumber(jump, type)),
takes: move.takes.map(take => convertNumber(take, type)),
from: convertNumber(move.from, type),
to: convertNumber(move.to, type),
piecesTaken: move.piecesTaken
}))
}
/**
* Converts between internal and external square numbering systems
* @param {number} number - Square number to convert
* @param {string} notation - Target notation ('internal' or 'external')
* @returns {number} Converted square number
*/
const convertNumber = (number, notation) => {
const num = parseInt(number, 10)
switch (notation) {
case 'internal':
return num + Math.floor((num - 1) / 10)
case 'external':
return num - Math.floor((num - 1) / 11)
default:
return num
}
}
/**
* Converts position between internal and external representations
* @param {string} pos - Position string to convert
* @param {string} notation - Target notation ('internal' or 'external')
* @returns {string} Converted position string
*/
const convertPosition = (pos, notation) => {
switch (notation) {
case 'internal': {
const sub1 = pos.slice(1, 11)
const sub2 = pos.slice(11, 21)
const sub3 = pos.slice(21, 31)
const sub4 = pos.slice(31, 41)
const sub5 = pos.slice(41, 51)
return `-${sub1}-${sub2}-${sub3}-${sub4}-${sub5}-`
}
case 'external': {
const sub1 = pos.slice(1, 11)
const sub2 = pos.slice(12, 22)
const sub3 = pos.slice(23, 33)
const sub4 = pos.slice(34, 44)
const sub5 = pos.slice(45, 55)
return `?${sub1}${sub2}${sub3}${sub4}${sub5}`
}
default:
return pos
}
}
/**
* Checks if a square is outside the board (internal notation only)
* @param {number} square - Square number to check
* @returns {boolean} True if square is outside the board
*/
const outsideBoard = (square) => {
const n = parseInt(square, 10)
return !(n >= 0 && n <= 55 && (n % 11) !== 0)
}
/**
* Creates direction strings for a square showing pieces in each direction
* @param {string} tempPosition - Position string to analyze
* @param {number} square - Square to get directions from (internal notation)
* @param {number} [maxLength=100] - Maximum length of direction strings
* @returns {DirectionStrings|number} Object with direction strings or error code
*/
const directionStrings = (tempPosition, square, maxLength = 100) => {
if (outsideBoard(square)) {
return 334 // Error code for outside board
}
const dirStrings = {}
for (const [dir, step] of Object.entries(STEPS)) {
const dirArray = []
let i = 0
let index = square
do {
dirArray[i] = tempPosition.charAt(index)
i++
index = square + (i * step)
} while (!outsideBoard(index) && i < maxLength)
dirStrings[dir] = dirArray.join('')
}
return dirStrings
}
/**
* Gets the opposite direction
* @param {string} direction - Direction ('NE', 'SE', 'SW', 'NW')
* @returns {string} Opposite direction
*/
const oppositeDir = (direction) => {
const opposites = { NE: 'SW', SE: 'NW', SW: 'NE', NW: 'SE' }
return opposites[direction]
}
/**
* Checks if a direction is valid for a piece type
* @param {string} piece - Piece type ('w' for white, 'b' for black)
* @param {string} dir - Direction to check ('NE', 'SE', 'SW', 'NW')
* @returns {boolean} True if direction is valid for the piece
*/
const validDir = (piece, dir) => {
const validDirs = {
w: { NE: true, SE: false, SW: false, NW: true },
b: { NE: false, SE: true, SW: true, NW: false }
}
return validDirs[piece]?.[dir] ?? false
}
/**
* Generates ASCII representation of the current board position
* @param {boolean} [unicode=false] - Whether to use Unicode symbols for pieces
* @returns {string} ASCII board representation
*/
const ascii = (unicode = false) => {
const extPosition = convertPosition(position, 'external')
let board = '\n+-------------------------------+\n'
let squareIndex = 1
for (let row = 1; row <= 10; row++) {
board += '|\t'
// Add leading spaces for odd rows
if (row % 2 !== 0) {
board += ' '
}
for (let col = 1; col <= 10; col++) {
if (col % 2 === 0) {
board += ' '
squareIndex++
} else {
const piece = extPosition[squareIndex]
board += unicode ? ` ${UNICODES[piece]}` : ` ${piece}`
}
}
// Add trailing spaces for even rows
if (row % 2 === 0) {
board += ' '
}
board += '\t|\n'
}
return `${board}+-------------------------------+\n`
}
/**
* Checks if the game is over (no moves available or no pieces left)
* @returns {boolean} True if game is over
*/
const gameOver = () => {
// Check if current player has any pieces left
let hasPlayerPieces = false
for (let i = 0; i < position.length; i++) {
if (position[i].toLowerCase() === turn.toLowerCase()) {
hasPlayerPieces = true
break
}
}
if (!hasPlayerPieces) {
return true
}
// Check if current player has any legal moves
return generate_moves().length === 0
}
/**
* Gets the move history in various formats
* @param {Object} [options] - Options for history format
* @param {boolean} [options.verbose=false] - Whether to return detailed move objects
* @returns {(string[]|Object[])} Array of moves in requested format
*/
const getHistory = (options = {}) => {
const tempHistory = clone(history)
const moveHistory = []
const { verbose = false } = options
while (tempHistory.length > 0) {
const historyEntry = tempHistory.shift()
if (verbose) {
moveHistory.push(makePretty(historyEntry))
} else {
const { move } = historyEntry
moveHistory.push(`${move.from}${SIGNS[move.flags]}${move.to}`)
}
}
return moveHistory
}
/**
* Gets the current board position in external notation
* @returns {string} Position string in external notation
*/
const getPosition = () => convertPosition(position, 'external')
/**
* Formats a history entry into a prettier move object
* @param {HistoryEntry} uglyMove - Raw history entry
* @returns {Object} Formatted move object
*/
const makePretty = (uglyMove) => {
const { move, moveNumber } = uglyMove
const prettyMove = {
from: move.from,
to: move.to,
flags: move.flags,
moveNumber,
piece: move.piece
}
if (move.flags === 'c' && move.captures) {
prettyMove.captures = move.captures.join(',')
}
return prettyMove
}
/**
* Creates a deep clone of an object
* @param {*} obj - Object to clone
* @returns {*} Deep clone of the object
*/
const clone = (obj) => JSON.parse(JSON.stringify(obj))
/**
* Trims whitespace from both ends of a string
* @param {string} str - String to trim
* @returns {string} Trimmed string
*/
const trim = (str) => str.replace(/^\s+|\s+$/g, '')
/**
* Performance test function - counts nodes at given depth
* @param {number} depth - Search depth
* @returns {number} Number of nodes at the given depth
*/
const perft = (depth) => {
const moves = generate_moves({ legal: false })
let nodes = 0
for (const move of moves) {
makeMove(move)
if (depth - 1 > 0) {
nodes += perft(depth - 1)
} else {
nodes++
}
undoMove()
}
return nodes
}
// Assign methods to this instance
Object.assign(this, {
// Constants
WHITE,
BLACK,
MAN,
KING,
FLAGS,
SQUARES: 'A8',
// Game setup methods
load: (fen) => load(fen),
reset: () => reset(),
clear: () => clear(),
// Move generation and validation
moves: generate_moves,
getMoves,
getLegalMoves,
captures: getCaptures,
// Game state methods
gameOver,
inDraw: () => false,
turn: () => turn.toLowerCase(),
// Move execution
move: (moveObj) => {
if (typeof moveObj.to === 'undefined' && typeof moveObj.from === 'undefined') {
return false
}
const move = {
to: parseInt(moveObj.to, 10),
from: parseInt(moveObj.from, 10)
}
const legalMoves = generate_moves()
const matchingMove = legalMoves.find(legalMove =>
move.to === legalMove.to && move.from === legalMove.from
)
if (matchingMove) {
makeMove(matchingMove)
return matchingMove
}
return false
},
undo: () => undoMove() || null,
// Board manipulation
put: (piece, square) => put(piece, square),
get: (square) => get(square),
remove: (square) => remove(square),
position: getPosition,
// FEN and PDN support
validate_fen,
fen: generate_fen,
pdn: generatePDN,
load_pdn: (pdn, options) => {}, // TODO: Implementation needed
parsePDN,
header: (...args) => set_header(args),
// History and display
history: getHistory,
ascii,
// Utility functions
convertMoves,
convertNumber,
convertPosition,
outsideBoard,
directionStrings,
oppositeDir,
validDir,
clone,
makePretty,
// Performance testing
perft: (depth) => perft(depth)
})
// Initialize game position after all functions are defined
if (!fen) {
position = DEFAULT_POSITION_INTERNAL
load(DEFAULT_FEN)
} else {
position = DEFAULT_POSITION_INTERNAL
load(fen)
}
}
// Module exports for different environments
if (typeof exports !== 'undefined') {
// CommonJS
exports.Draughts = Draughts
module.exports = Draughts
module.exports.Draughts = Draughts
}
if (typeof define !== 'undefined') {
// AMD
define(() => Draughts)
}
// For ES6 modules, uncomment the following line when using .mjs extension:
// export default Draughts