UNPKG

chessops

Version:

Chess and chess variant rules and operations

664 lines (589 loc) 23 kB
import { Result } from '@badrap/result'; import { attacks, between, bishopAttacks, kingAttacks, knightAttacks, pawnAttacks, queenAttacks, ray, rookAttacks, } from './attacks.js'; import { Board, boardEquals } from './board.js'; import { Material, RemainingChecks, Setup } from './setup.js'; import { SquareSet } from './squareSet.js'; import { ByCastlingSide, ByColor, CASTLING_SIDES, CastlingSide, Color, COLORS, isDrop, Move, NormalMove, Outcome, Piece, Rules, Square, } from './types.js'; import { defined, kingCastlesTo, opposite, rookCastlesTo, squareRank } from './util.js'; export enum IllegalSetup { Empty = 'ERR_EMPTY', OppositeCheck = 'ERR_OPPOSITE_CHECK', PawnsOnBackrank = 'ERR_PAWNS_ON_BACKRANK', Kings = 'ERR_KINGS', Variant = 'ERR_VARIANT', } export class PositionError extends Error {} const attacksTo = (square: Square, attacker: Color, board: Board, occupied: SquareSet): SquareSet => board[attacker].intersect( rookAttacks(square, occupied) .intersect(board.rooksAndQueens()) .union(bishopAttacks(square, occupied).intersect(board.bishopsAndQueens())) .union(knightAttacks(square).intersect(board.knight)) .union(kingAttacks(square).intersect(board.king)) .union(pawnAttacks(opposite(attacker), square).intersect(board.pawn)), ); export class Castles { castlingRights: SquareSet; rook: ByColor<ByCastlingSide<Square | undefined>>; path: ByColor<ByCastlingSide<SquareSet>>; private constructor() {} static default(): Castles { const castles = new Castles(); castles.castlingRights = SquareSet.corners(); castles.rook = { white: { a: 0, h: 7 }, black: { a: 56, h: 63 }, }; castles.path = { white: { a: new SquareSet(0xe, 0), h: new SquareSet(0x60, 0) }, black: { a: new SquareSet(0, 0x0e000000), h: new SquareSet(0, 0x60000000) }, }; return castles; } static empty(): Castles { const castles = new Castles(); castles.castlingRights = SquareSet.empty(); castles.rook = { white: { a: undefined, h: undefined }, black: { a: undefined, h: undefined }, }; castles.path = { white: { a: SquareSet.empty(), h: SquareSet.empty() }, black: { a: SquareSet.empty(), h: SquareSet.empty() }, }; return castles; } clone(): Castles { const castles = new Castles(); castles.castlingRights = this.castlingRights; castles.rook = { white: { a: this.rook.white.a, h: this.rook.white.h }, black: { a: this.rook.black.a, h: this.rook.black.h }, }; castles.path = { white: { a: this.path.white.a, h: this.path.white.h }, black: { a: this.path.black.a, h: this.path.black.h }, }; return castles; } private add(color: Color, side: CastlingSide, king: Square, rook: Square): void { const kingTo = kingCastlesTo(color, side); const rookTo = rookCastlesTo(color, side); this.castlingRights = this.castlingRights.with(rook); this.rook[color][side] = rook; this.path[color][side] = between(rook, rookTo) .with(rookTo) .union(between(king, kingTo).with(kingTo)) .without(king) .without(rook); } static fromSetup(setup: Setup): Castles { const castles = Castles.empty(); const rooks = setup.castlingRights.intersect(setup.board.rook); for (const color of COLORS) { const backrank = SquareSet.backrank(color); const king = setup.board.kingOf(color); if (!defined(king) || !backrank.has(king)) continue; const side = rooks.intersect(setup.board[color]).intersect(backrank); const aSide = side.first(); if (defined(aSide) && aSide < king) castles.add(color, 'a', king, aSide); const hSide = side.last(); if (defined(hSide) && king < hSide) castles.add(color, 'h', king, hSide); } return castles; } discardRook(square: Square): void { if (this.castlingRights.has(square)) { this.castlingRights = this.castlingRights.without(square); for (const color of COLORS) { for (const side of CASTLING_SIDES) { if (this.rook[color][side] === square) this.rook[color][side] = undefined; } } } } discardColor(color: Color): void { this.castlingRights = this.castlingRights.diff(SquareSet.backrank(color)); this.rook[color].a = undefined; this.rook[color].h = undefined; } } export interface Context { king: Square | undefined; blockers: SquareSet; checkers: SquareSet; variantEnd: boolean; mustCapture: boolean; } export abstract class Position { board: Board; pockets: Material | undefined; turn: Color; castles: Castles; epSquare: Square | undefined; remainingChecks: RemainingChecks | undefined; halfmoves: number; fullmoves: number; protected constructor(readonly rules: Rules) {} reset() { this.board = Board.default(); this.pockets = undefined; this.turn = 'white'; this.castles = Castles.default(); this.epSquare = undefined; this.remainingChecks = undefined; this.halfmoves = 0; this.fullmoves = 1; } protected setupUnchecked(setup: Setup) { this.board = setup.board.clone(); this.board.promoted = SquareSet.empty(); this.pockets = undefined; this.turn = setup.turn; this.castles = Castles.fromSetup(setup); this.epSquare = validEpSquare(this, setup.epSquare); this.remainingChecks = undefined; this.halfmoves = setup.halfmoves; this.fullmoves = setup.fullmoves; } // When subclassing overwrite at least: // // - static default() // - static fromSetup() // - static clone() // // - dests() // - isVariantEnd() // - variantOutcome() // - hasInsufficientMaterial() // - isStandardMaterial() kingAttackers(square: Square, attacker: Color, occupied: SquareSet): SquareSet { return attacksTo(square, attacker, this.board, occupied); } protected playCaptureAt(square: Square, captured: Piece): void { this.halfmoves = 0; if (captured.role === 'rook') this.castles.discardRook(square); if (this.pockets) this.pockets[opposite(captured.color)][captured.promoted ? 'pawn' : captured.role]++; } ctx(): Context { const variantEnd = this.isVariantEnd(); const king = this.board.kingOf(this.turn); if (!defined(king)) { return { king, blockers: SquareSet.empty(), checkers: SquareSet.empty(), variantEnd, mustCapture: false }; } const snipers = rookAttacks(king, SquareSet.empty()) .intersect(this.board.rooksAndQueens()) .union(bishopAttacks(king, SquareSet.empty()).intersect(this.board.bishopsAndQueens())) .intersect(this.board[opposite(this.turn)]); let blockers = SquareSet.empty(); for (const sniper of snipers) { const b = between(king, sniper).intersect(this.board.occupied); if (!b.moreThanOne()) blockers = blockers.union(b); } const checkers = this.kingAttackers(king, opposite(this.turn), this.board.occupied); return { king, blockers, checkers, variantEnd, mustCapture: false, }; } clone(): Position { const pos = new (this as any).constructor(); pos.board = this.board.clone(); pos.pockets = this.pockets?.clone(); pos.turn = this.turn; pos.castles = this.castles.clone(); pos.epSquare = this.epSquare; pos.remainingChecks = this.remainingChecks?.clone(); pos.halfmoves = this.halfmoves; pos.fullmoves = this.fullmoves; return pos; } protected validate(): Result<undefined, PositionError> { if (this.board.occupied.isEmpty()) return Result.err(new PositionError(IllegalSetup.Empty)); if (this.board.king.size() !== 2) return Result.err(new PositionError(IllegalSetup.Kings)); if (!defined(this.board.kingOf(this.turn))) return Result.err(new PositionError(IllegalSetup.Kings)); const otherKing = this.board.kingOf(opposite(this.turn)); if (!defined(otherKing)) return Result.err(new PositionError(IllegalSetup.Kings)); if (this.kingAttackers(otherKing, this.turn, this.board.occupied).nonEmpty()) { return Result.err(new PositionError(IllegalSetup.OppositeCheck)); } if (SquareSet.backranks().intersects(this.board.pawn)) { return Result.err(new PositionError(IllegalSetup.PawnsOnBackrank)); } return Result.ok(undefined); } dropDests(_ctx?: Context): SquareSet { return SquareSet.empty(); } dests(square: Square, ctx?: Context): SquareSet { ctx = ctx || this.ctx(); if (ctx.variantEnd) return SquareSet.empty(); const piece = this.board.get(square); if (!piece || piece.color !== this.turn) return SquareSet.empty(); let pseudo, legal; if (piece.role === 'pawn') { pseudo = pawnAttacks(this.turn, square).intersect(this.board[opposite(this.turn)]); const delta = this.turn === 'white' ? 8 : -8; const step = square + delta; if (0 <= step && step < 64 && !this.board.occupied.has(step)) { pseudo = pseudo.with(step); const canDoubleStep = this.turn === 'white' ? square < 16 : square >= 64 - 16; const doubleStep = step + delta; if (canDoubleStep && !this.board.occupied.has(doubleStep)) { pseudo = pseudo.with(doubleStep); } } if (defined(this.epSquare) && canCaptureEp(this, square, ctx)) { legal = SquareSet.fromSquare(this.epSquare); } } else if (piece.role === 'bishop') pseudo = bishopAttacks(square, this.board.occupied); else if (piece.role === 'knight') pseudo = knightAttacks(square); else if (piece.role === 'rook') pseudo = rookAttacks(square, this.board.occupied); else if (piece.role === 'queen') pseudo = queenAttacks(square, this.board.occupied); else pseudo = kingAttacks(square); pseudo = pseudo.diff(this.board[this.turn]); if (defined(ctx.king)) { if (piece.role === 'king') { const occ = this.board.occupied.without(square); for (const to of pseudo) { if (this.kingAttackers(to, opposite(this.turn), occ).nonEmpty()) pseudo = pseudo.without(to); } return pseudo.union(castlingDest(this, 'a', ctx)).union(castlingDest(this, 'h', ctx)); } if (ctx.checkers.nonEmpty()) { const checker = ctx.checkers.singleSquare(); if (!defined(checker)) return SquareSet.empty(); pseudo = pseudo.intersect(between(checker, ctx.king).with(checker)); } if (ctx.blockers.has(square)) pseudo = pseudo.intersect(ray(square, ctx.king)); } if (legal) pseudo = pseudo.union(legal); return pseudo; } isVariantEnd(): boolean { return false; } variantOutcome(_ctx?: Context): Outcome | undefined { return; } hasInsufficientMaterial(color: Color): boolean { if (this.board[color].intersect(this.board.pawn.union(this.board.rooksAndQueens())).nonEmpty()) return false; if (this.board[color].intersects(this.board.knight)) { return ( this.board[color].size() <= 2 && this.board[opposite(color)].diff(this.board.king).diff(this.board.queen).isEmpty() ); } if (this.board[color].intersects(this.board.bishop)) { const sameColor = !this.board.bishop.intersects(SquareSet.darkSquares()) || !this.board.bishop.intersects(SquareSet.lightSquares()); return sameColor && this.board.pawn.isEmpty() && this.board.knight.isEmpty(); } return true; } // The following should be identical in all subclasses toSetup(): Setup { return { board: this.board.clone(), pockets: this.pockets?.clone(), turn: this.turn, castlingRights: this.castles.castlingRights, epSquare: legalEpSquare(this), remainingChecks: this.remainingChecks?.clone(), halfmoves: Math.min(this.halfmoves, 150), fullmoves: Math.min(Math.max(this.fullmoves, 1), 9999), }; } isInsufficientMaterial(): boolean { return COLORS.every(color => this.hasInsufficientMaterial(color)); } hasDests(ctx?: Context): boolean { ctx = ctx || this.ctx(); for (const square of this.board[this.turn]) { if (this.dests(square, ctx).nonEmpty()) return true; } return this.dropDests(ctx).nonEmpty(); } isLegal(move: Move, ctx?: Context): boolean { if (isDrop(move)) { if (!this.pockets || this.pockets[this.turn][move.role] <= 0) return false; if (move.role === 'pawn' && SquareSet.backranks().has(move.to)) return false; return this.dropDests(ctx).has(move.to); } else { if (move.promotion === 'pawn') return false; if (move.promotion === 'king' && this.rules !== 'antichess') return false; if (!!move.promotion !== (this.board.pawn.has(move.from) && SquareSet.backranks().has(move.to))) return false; const dests = this.dests(move.from, ctx); return dests.has(move.to) || dests.has(normalizeMove(this, move).to); } } isCheck(): boolean { const king = this.board.kingOf(this.turn); return defined(king) && this.kingAttackers(king, opposite(this.turn), this.board.occupied).nonEmpty(); } isEnd(ctx?: Context): boolean { if (ctx ? ctx.variantEnd : this.isVariantEnd()) return true; return this.isInsufficientMaterial() || !this.hasDests(ctx); } isCheckmate(ctx?: Context): boolean { ctx = ctx || this.ctx(); return !ctx.variantEnd && ctx.checkers.nonEmpty() && !this.hasDests(ctx); } isStalemate(ctx?: Context): boolean { ctx = ctx || this.ctx(); return !ctx.variantEnd && ctx.checkers.isEmpty() && !this.hasDests(ctx); } outcome(ctx?: Context): Outcome | undefined { const variantOutcome = this.variantOutcome(ctx); if (variantOutcome) return variantOutcome; ctx = ctx || this.ctx(); if (this.isCheckmate(ctx)) return { winner: opposite(this.turn) }; else if (this.isInsufficientMaterial() || this.isStalemate(ctx)) return { winner: undefined }; else return; } allDests(ctx?: Context): Map<Square, SquareSet> { ctx = ctx || this.ctx(); const d = new Map(); if (ctx.variantEnd) return d; for (const square of this.board[this.turn]) { d.set(square, this.dests(square, ctx)); } return d; } play(move: Move): void { const turn = this.turn; const epSquare = this.epSquare; const castling = castlingSide(this, move); this.epSquare = undefined; this.halfmoves += 1; if (turn === 'black') this.fullmoves += 1; this.turn = opposite(turn); if (isDrop(move)) { this.board.set(move.to, { role: move.role, color: turn }); if (this.pockets) this.pockets[turn][move.role]--; if (move.role === 'pawn') this.halfmoves = 0; } else { const piece = this.board.take(move.from); if (!piece) return; let epCapture: Piece | undefined; if (piece.role === 'pawn') { this.halfmoves = 0; if (move.to === epSquare) { epCapture = this.board.take(move.to + (turn === 'white' ? -8 : 8)); } const delta = move.from - move.to; if (Math.abs(delta) === 16 && 8 <= move.from && move.from <= 55) { this.epSquare = (move.from + move.to) >> 1; } if (move.promotion) { piece.role = move.promotion; piece.promoted = !!this.pockets; } } else if (piece.role === 'rook') { this.castles.discardRook(move.from); } else if (piece.role === 'king') { if (castling) { const rookFrom = this.castles.rook[turn][castling]; if (defined(rookFrom)) { const rook = this.board.take(rookFrom); this.board.set(kingCastlesTo(turn, castling), piece); if (rook) this.board.set(rookCastlesTo(turn, castling), rook); } } this.castles.discardColor(turn); } if (!castling) { const capture = this.board.set(move.to, piece) || epCapture; if (capture) this.playCaptureAt(move.to, capture); } } if (this.remainingChecks) { if (this.isCheck()) this.remainingChecks[turn] = Math.max(this.remainingChecks[turn] - 1, 0); } } } export class Chess extends Position { private constructor() { super('chess'); } static default(): Chess { const pos = new this(); pos.reset(); return pos; } static fromSetup(setup: Setup): Result<Chess, PositionError> { const pos = new this(); pos.setupUnchecked(setup); return pos.validate().map(_ => pos); } clone(): Chess { return super.clone() as Chess; } } const validEpSquare = (pos: Position, square: Square | undefined): Square | undefined => { if (!defined(square)) return; const epRank = pos.turn === 'white' ? 5 : 2; const forward = pos.turn === 'white' ? 8 : -8; if (squareRank(square) !== epRank) return; if (pos.board.occupied.has(square + forward)) return; const pawn = square - forward; if (!pos.board.pawn.has(pawn) || !pos.board[opposite(pos.turn)].has(pawn)) return; return square; }; const legalEpSquare = (pos: Position): Square | undefined => { if (!defined(pos.epSquare)) return; const ctx = pos.ctx(); const ourPawns = pos.board.pieces(pos.turn, 'pawn'); const candidates = ourPawns.intersect(pawnAttacks(opposite(pos.turn), pos.epSquare)); for (const candidate of candidates) { if (pos.dests(candidate, ctx).has(pos.epSquare)) return pos.epSquare; } return; }; const canCaptureEp = (pos: Position, pawnFrom: Square, ctx: Context): boolean => { if (!defined(pos.epSquare)) return false; if (!pawnAttacks(pos.turn, pawnFrom).has(pos.epSquare)) return false; if (!defined(ctx.king)) return true; const delta = pos.turn === 'white' ? 8 : -8; const captured = pos.epSquare - delta; return pos .kingAttackers( ctx.king, opposite(pos.turn), pos.board.occupied.toggle(pawnFrom).toggle(captured).with(pos.epSquare), ) .without(captured) .isEmpty(); }; const castlingDest = (pos: Position, side: CastlingSide, ctx: Context): SquareSet => { if (!defined(ctx.king) || ctx.checkers.nonEmpty()) return SquareSet.empty(); const rook = pos.castles.rook[pos.turn][side]; if (!defined(rook)) return SquareSet.empty(); if (pos.castles.path[pos.turn][side].intersects(pos.board.occupied)) return SquareSet.empty(); const kingTo = kingCastlesTo(pos.turn, side); const kingPath = between(ctx.king, kingTo); const occ = pos.board.occupied.without(ctx.king); for (const sq of kingPath) { if (pos.kingAttackers(sq, opposite(pos.turn), occ).nonEmpty()) return SquareSet.empty(); } const rookTo = rookCastlesTo(pos.turn, side); const after = pos.board.occupied.toggle(ctx.king).toggle(rook).toggle(rookTo); if (pos.kingAttackers(kingTo, opposite(pos.turn), after).nonEmpty()) return SquareSet.empty(); return SquareSet.fromSquare(rook); }; export const pseudoDests = (pos: Position, square: Square, ctx: Context): SquareSet => { if (ctx.variantEnd) return SquareSet.empty(); const piece = pos.board.get(square); if (!piece || piece.color !== pos.turn) return SquareSet.empty(); let pseudo = attacks(piece, square, pos.board.occupied); if (piece.role === 'pawn') { let captureTargets = pos.board[opposite(pos.turn)]; if (defined(pos.epSquare)) captureTargets = captureTargets.with(pos.epSquare); pseudo = pseudo.intersect(captureTargets); const delta = pos.turn === 'white' ? 8 : -8; const step = square + delta; if (0 <= step && step < 64 && !pos.board.occupied.has(step)) { pseudo = pseudo.with(step); const canDoubleStep = pos.turn === 'white' ? square < 16 : square >= 64 - 16; const doubleStep = step + delta; if (canDoubleStep && !pos.board.occupied.has(doubleStep)) { pseudo = pseudo.with(doubleStep); } } return pseudo; } else { pseudo = pseudo.diff(pos.board[pos.turn]); } if (square === ctx.king) return pseudo.union(castlingDest(pos, 'a', ctx)).union(castlingDest(pos, 'h', ctx)); else return pseudo; }; export const equalsIgnoreMoves = (left: Position, right: Position): boolean => left.rules === right.rules && boardEquals(left.board, right.board) && ((right.pockets && left.pockets?.equals(right.pockets)) || (!left.pockets && !right.pockets)) && left.turn === right.turn && left.castles.castlingRights.equals(right.castles.castlingRights) && legalEpSquare(left) === legalEpSquare(right) && ((right.remainingChecks && left.remainingChecks?.equals(right.remainingChecks)) || (!left.remainingChecks && !right.remainingChecks)); export const castlingSide = (pos: Position, move: Move): CastlingSide | undefined => { if (isDrop(move)) return; const delta = move.to - move.from; if (Math.abs(delta) !== 2 && !pos.board[pos.turn].has(move.to)) return; if (!pos.board.king.has(move.from)) return; return delta > 0 ? 'h' : 'a'; }; export const normalizeMove = (pos: Position, move: Move): Move => { const side = castlingSide(pos, move); if (!side) return move; const rookFrom = pos.castles.rook[pos.turn][side]; return { from: (move as NormalMove).from, to: defined(rookFrom) ? rookFrom : move.to, }; }; export const isStandardMaterialSide = (board: Board, color: Color): boolean => { const promoted = Math.max(board.pieces(color, 'queen').size() - 1, 0) + Math.max(board.pieces(color, 'rook').size() - 2, 0) + Math.max(board.pieces(color, 'knight').size() - 2, 0) + Math.max(board.pieces(color, 'bishop').intersect(SquareSet.lightSquares()).size() - 1, 0) + Math.max(board.pieces(color, 'bishop').intersect(SquareSet.darkSquares()).size() - 1, 0); return board.pieces(color, 'pawn').size() + promoted <= 8; }; export const isStandardMaterial = (pos: Chess): boolean => COLORS.every(color => isStandardMaterialSide(pos.board, color)); export const isImpossibleCheck = (pos: Position): boolean => { const ourKing = pos.board.kingOf(pos.turn); if (!defined(ourKing)) return false; const checkers = pos.kingAttackers(ourKing, opposite(pos.turn), pos.board.occupied); if (checkers.isEmpty()) return false; if (defined(pos.epSquare)) { // The pushed pawn must be the only checker, or it has uncovered // check by a single sliding piece. const pushedTo = pos.epSquare ^ 8; const pushedFrom = pos.epSquare ^ 24; return ( checkers.moreThanOne() || (checkers.first()! !== pushedTo && pos .kingAttackers(ourKing, opposite(pos.turn), pos.board.occupied.without(pushedTo).with(pushedFrom)) .nonEmpty()) ); } else if (pos.rules === 'atomic') { // Other king moving away can cause many checks to be given at the same // time. Not checking details, or even that the king is close enough. return false; } else { // Sliding checkers aligned with king. return checkers.size() > 2 || (checkers.size() === 2 && ray(checkers.first()!, checkers.last()!).has(ourKing)) // Sliding checkers aligned with king || checkers.intersect(pos.board.steppers()).moreThanOne(); } };