"""Make a digitized board placement into a legal, playable position. Digitization can misclassify squares, producing impossible boards (a pawn on the back rank, two kings of a colour, too many pieces). This fixes the common cases so chess.js / python-chess accept the position, and reports what it changed. """ from __future__ import annotations import chess MAX_PER_SIDE = 16 MAX_PAWNS = 8 COLOR_NAME = {chess.WHITE: "white", chess.BLACK: "black"} def _parse(placement: str) -> dict[int, chess.Piece]: pm: dict[int, chess.Piece] = {} rank, file = 7, 0 for ch in placement.split()[0]: if ch == "/": rank, file = rank - 1, 0 elif ch.isdigit(): file += int(ch) elif 0 <= rank <= 7 and 0 <= file <= 7: try: pm[chess.square(file, rank)] = chess.Piece.from_symbol(ch) except ValueError: pass file += 1 return pm def sanitize(placement: str) -> dict: """Returns {fen, placement, corrections, valid}.""" pm = _parse(placement) fixes: list[str] = [] # 1. pawns can't be on rank 1 or 8 for sq, p in list(pm.items()): if p.piece_type == chess.PAWN and chess.square_rank(sq) in (0, 7): del pm[sq] fixes.append(f"removed pawn on {chess.square_name(sq)} (impossible rank)") # 2. exactly one king per colour for color in (chess.WHITE, chess.BLACK): kings = [sq for sq, p in pm.items() if p.piece_type == chess.KING and p.color == color] for extra in kings[1:]: del pm[extra] fixes.append(f"removed extra {COLOR_NAME[color]} king on {chess.square_name(extra)}") if not kings: home = chess.E1 if color == chess.WHITE else chess.E8 target = home if home not in pm else next(s for s in chess.SQUARES if s not in pm) pm[target] = chess.Piece(chess.KING, color) fixes.append(f"added missing {COLOR_NAME[color]} king on {chess.square_name(target)}") # 3. cap pawns (<=8) and total pieces (<=16) per side for color in (chess.WHITE, chess.BLACK): pawns = [sq for sq, p in pm.items() if p.piece_type == chess.PAWN and p.color == color] for sq in pawns[MAX_PAWNS:]: del pm[sq] fixes.append(f"removed extra {COLOR_NAME[color]} pawn on {chess.square_name(sq)}") own = [sq for sq, p in pm.items() if p.color == color] if len(own) > MAX_PER_SIDE: removable = [s for s in own if pm[s].piece_type != chess.KING] for sq in removable[: len(own) - MAX_PER_SIDE]: del pm[sq] fixes.append(f"removed excess {COLOR_NAME[color]} piece on {chess.square_name(sq)}") # 4. bishops: at most one per square colour (the natural pair → max 2 per side) for color in (chess.WHITE, chess.BLACK): seen = set() for sq, p in list(pm.items()): if p.piece_type == chess.BISHOP and p.color == color: sq_color = (chess.square_file(sq) + chess.square_rank(sq)) % 2 # 0 dark, 1 light if sq_color in seen: del pm[sq] fixes.append(f"removed extra {COLOR_NAME[color]} bishop on " f"{chess.square_name(sq)} (same square colour)") else: seen.add(sq_color) # build board, drop castling/en-passant (can't be inferred from an image) board = chess.Board(None) for sq, p in pm.items(): board.set_piece_at(sq, p) board.castling_rights = 0 # 4. choose a side to move that is legal (the side not to move must not be in check) board.turn = chess.WHITE if not board.is_valid(): board.turn = chess.BLACK return { "fen": board.fen(), "placement": board.board_fen(), "corrections": fixes, "valid": board.is_valid(), }