## TinyChess — the game. ## ## chess.bend says how a piece steps on an empty board. This file is the ## rest: what stands where, what a capture is, when a king is attacked, ## and when a side is mated. Everything here is computable and has no ## holes, so the checker can settle a claim about a position by running ## it rather than by being argued with. ## ## The opening position, black at the top: ## ## a b c d ## 4 ♟ ♟ ♚ ♟ 12 13 14 15 ## 3 · · · · 8 9 10 11 ## 2 · · · · 4 5 6 7 ## 1 ♙ ♔ ♙ ♙ 0 1 2 3 ## ## Both back ranks are full and the kings stand on different files, so ## neither side begins in opposition and every pawn has somewhere to go. ## ## Sizes are the point of every loop below. There are sixteen squares and ## eight pieces, so a scan is sixteen steps, an attack test is sixteen ## scans, and the whole "can white win on the first move" question is a ## few tens of thousands of steps. That is small enough for the checker ## to simply evaluate, which is why the law about it needs no argument. import Base import ./chess.bend as C # ---- the pieces ---------------------------------------------------------- type Piece is Data: Empty{} WKing{} WPawn{} WQueen{} WKnight{} BKing{} BPawn{} BQueen{} BKnight{} def is_white(p: Piece) -> Bool: match p: case Empty{}: False{} case WKing{}: True{} case WPawn{}: True{} case WQueen{}: True{} case WKnight{}: True{} case BKing{}: False{} case BPawn{}: False{} case BQueen{}: False{} case BKnight{}: False{} def is_empty_piece(p: Piece) -> Bool: match p: case Empty{}: True{} case WKing{}: False{} case WPawn{}: False{} case WQueen{}: False{} case WKnight{}: False{} case BKing{}: False{} case BPawn{}: False{} case BQueen{}: False{} case BKnight{}: False{} def is_king(p: Piece) -> Bool: match p: case Empty{}: False{} case WKing{}: True{} case WPawn{}: False{} case WQueen{}: False{} case WKnight{}: False{} case BKing{}: True{} case BPawn{}: False{} case BQueen{}: False{} case BKnight{}: False{} # whether a piece belongs to the side to move def mine(+white: Bool, +p: Piece) -> Bool: match white: case True{}: is_white(p) case False{}: Bool.and(Bool.not(is_empty_piece(p)), Bool.not(is_white(p))) def theirs(+white: Bool, +p: Piece) -> Bool: mine(Bool.not(white), p) # ---- the board ----------------------------------------------------------- # the sixteen squares, a1 first. Base's List is kind-polymorphic and a # plain Data field wants a plain Data list, so this one is its own. type Cells is Data: End{} Put{head: Piece, tail: Cells} type Board is Data: Board{cells: Cells} type Step is Data: Step{from: Nat, to: Nat} def Step.from(m: Step) -> Nat: match m: case Step{from, to}: from def Step.to(m: Step) -> Nat: match m: case Step{from, to}: to def cell(c: Cells, s: Nat) -> Piece: match c: case End{}: Empty{} case Put{head, tail}: match s: case 0n: head case 1n+q: cell(tail, q) def put_cell(c: Cells, s: Nat, +p: Piece) -> Cells: match c: case End{}: End{} case Put{head, tail}: match s: case 0n: Put{p, tail} case 1n+q: Put{head, put_cell(tail, q, p)} # what stands on a square. Off the board reads as empty, which is what # makes the scans below safe to run past the last square. def occupant(+b: Board, +s: Nat) -> Piece: match b: case Board{cells}: cell(cells, s) def place(+b: Board, +s: Nat, +p: Piece) -> Board: match b: case Board{cells}: Board{put_cell(cells, s, p)} # choosing between two numbers, which the scans need and Bool.pick is not def pick_nat(c: Bool, +a: Nat, +b: Nat) -> Nat: match c: case True{}: a case False{}: b # ---- lines of sight ------------------------------------------------------ # y lies between x and z along one axis: strictly, or all three equal, # which is the case where the move does not travel on that axis at all def mid3(+x: Nat, +y: Nat, +z: Nat) -> Bool: Bool.or( Bool.and(C.nat_lt(x, y), C.nat_lt(y, z)), Bool.or( Bool.and(C.nat_lt(z, y), C.nat_lt(y, x)), Bool.and(C.nat_eq(x, y), C.nat_eq(y, z)))) # s is strictly inside the line from a to b. On a board four wide there # are at most two such squares, so testing all sixteen is cheaper than # working out a direction and stepping along it. def between(+a: Nat, +s: Nat, +b: Nat) -> Bool: Bool.and( Bool.and(mid3(C.file(a), C.file(s), C.file(b)), mid3(C.rank(a), C.rank(s), C.rank(b))), Bool.and(Bool.not(C.nat_eq(s, a)), Bool.not(C.nat_eq(s, b)))) def clear_go(+bd: Board, +a: Nat, +b: Nat, n: Nat) -> Bool: match n: case 0n: True{} case 1n+k0: +k = k0 blocked = Bool.and(between(a, k, b), Bool.not(is_empty_piece(occupant(bd, k)))) Bool.and(Bool.not(blocked), clear_go(bd, a, b, k)) # nothing stands between a and b def clear(+bd: Board, +a: Nat, +b: Nat) -> Bool: clear_go(bd, a, b, 16n) # ---- what a piece threatens --------------------------------------------- # whether the piece standing on `from` attacks `to`. A pawn attacks only # where it could take, never where it could push, which is the rule that # makes pawn structure mean anything. # the checker will not match on a computed value, so the piece arrives as # a parameter and the lookup happens in the caller def attacks_as(p: Piece, +bd: Board, +from: Nat, +to: Nat) -> Bool: match p: case Empty{}: False{} case WKing{}: C.king_step(from, to) case WPawn{}: C.pawn_capture(True{}, from, to) case WQueen{}: Bool.and(C.queen_line(from, to), clear(bd, from, to)) case WKnight{}: C.knight_step(from, to) case BKing{}: C.king_step(from, to) case BPawn{}: C.pawn_capture(False{}, from, to) case BQueen{}: Bool.and(C.queen_line(from, to), clear(bd, from, to)) case BKnight{}: C.knight_step(from, to) def attacks(+bd: Board, +from: Nat, +to: Nat) -> Bool: attacks_as(occupant(bd, from), bd, from, to) def attacked_go(+bd: Board, +w: Bool, +s: Nat, n: Nat) -> Bool: match n: case 0n: False{} case 1n+k0: +k = k0 here = Bool.and(mine(w, occupant(bd, k)), attacks(bd, k, s)) Bool.or(here, attacked_go(bd, w, s, k)) # whether the given side attacks a square def attacked(+bd: Board, +w: Bool, +s: Nat) -> Bool: attacked_go(bd, w, s, 16n) def king_go(+bd: Board, +w: Bool, n: Nat) -> Nat: match n: case 0n: 16n case 1n+k0: +k = k0 here = Bool.and(mine(w, occupant(bd, k)), is_king(occupant(bd, k))) pick_nat(here, k, king_go(bd, w, k)) # where the given side's king stands. A board with no king answers 16, # which is off the board and therefore attacked by nothing. def king_square(+bd: Board, +w: Bool) -> Nat: king_go(bd, w, 16n) def in_check(+bd: Board, +w: Bool) -> Bool: attacked(bd, Bool.not(w), king_square(bd, w)) # ---- moves --------------------------------------------------------------- def play(+bd: Board, +m: Step) -> Board: +f = Step.from(m) t = Step.to(m) moved = occupant(bd, f) place(place(bd, t, moved), f, Empty{}) # the geometry, plus what the position allows: your own piece is not a # target, a queen needs a clear line, a pawn pushes only onto an empty # square and takes only onto an occupied one. def shape_ok(moving: Piece, +target: Piece, +bd: Board, +w: Bool, +f: Nat, +t: Nat) -> Bool: match moving: case Empty{}: False{} case WKing{}: C.king_step(f, t) case WPawn{}: Bool.or( Bool.and(C.pawn_push(True{}, f, t), is_empty_piece(target)), Bool.and(C.pawn_capture(True{}, f, t), theirs(w, target))) case WQueen{}: Bool.and(C.queen_line(f, t), clear(bd, f, t)) case WKnight{}: C.knight_step(f, t) case BKing{}: C.king_step(f, t) case BPawn{}: Bool.or( Bool.and(C.pawn_push(False{}, f, t), is_empty_piece(target)), Bool.and(C.pawn_capture(False{}, f, t), theirs(w, target))) case BQueen{}: Bool.and(C.queen_line(f, t), clear(bd, f, t)) case BKnight{}: C.knight_step(f, t) def pseudo_legal(+bd: Board, +w: Bool, +m: Step) -> Bool: +f = Step.from(m) +t = Step.to(m) +moving = occupant(bd, f) +target = occupant(bd, t) ok_owner = Bool.and(mine(w, moving), Bool.not(mine(w, target))) Bool.and(ok_owner, shape_ok(moving, target, bd, w, f, t)) # and the rule that separates a move from a step: you may not leave your # own king attacked def legal(+bd: Board, +w: Bool, +m: Step) -> Bool: Bool.and(pseudo_legal(bd, w, m), Bool.not(in_check(play(bd, m), w))) # ---- mate ---------------------------------------------------------------- def any_to(+bd: Board, +w: Bool, +f: Nat, n: Nat) -> Bool: match n: case 0n: False{} case 1n+k0: +k = k0 Bool.or(legal(bd, w, Step{f, k}), any_to(bd, w, f, k)) def any_from(+bd: Board, +w: Bool, n: Nat) -> Bool: match n: case 0n: False{} case 1n+k0: +k = k0 Bool.or(any_to(bd, w, k, 16n), any_from(bd, w, k)) def has_legal_move(+bd: Board, +w: Bool) -> Bool: any_from(bd, w, 16n) # in check, and nothing to do about it def is_checkmate(+bd: Board, +w: Bool) -> Bool: Bool.and(in_check(bd, w), Bool.not(has_legal_move(bd, w))) def is_stalemate(+bd: Board, +w: Bool) -> Bool: Bool.and(Bool.not(in_check(bd, w)), Bool.not(has_legal_move(bd, w))) # ---- can white win at once? --------------------------------------------- # "there isn't one". Square 16 is off the board, so no real move can # collide with it, and a failed law prints this against the move it found. def no_move() -> Step: Step{16n, 16n} def found(+m: Step) -> Bool: Bool.not(C.nat_eq(Step.from(m), 16n)) def pick_move(c: Bool, +a: Step, +b: Step) -> Step: match c: case True{}: a case False{}: b def wins_to(+bd: Board, +f: Nat, n: Nat) -> Step: match n: case 0n: no_move() case 1n+k0: +k = k0 here = Bool.and(legal(bd, True{}, Step{f, k}), is_checkmate(play(bd, Step{f, k}), False{})) pick_move(here, Step{f, k}, wins_to(bd, f, k)) def wins_from(+bd: Board, n: Nat) -> Step: match n: case 0n: no_move() case 1n+k0: +k = k0 +here = wins_to(bd, k, 16n) pick_move(found(here), here, wins_from(bd, k)) # The move that mates, or no_move() when there is none. Returning the move # rather than a Bool is what lets a broken law name its own counterexample: # the checker prints what it observed, so the error carries Step{from, to}. def winning_first_move(+bd: Board) -> Step: wins_from(bd, 16n) # whether white has a first move that mates black on the spot def white_wins_in_one(+bd: Board) -> Bool: found(winning_first_move(bd)) # ---- the positions ------------------------------------------------------- def row(a: Piece, b: Piece, c: Piece, d: Piece, rest: Cells) -> Cells: Put{a, Put{b, Put{c, Put{d, rest}}}} def empty_row(rest: Cells) -> Cells: row(Empty{}, Empty{}, Empty{}, Empty{}, rest) # rank 1 first: white pawn, white king, white pawn, white pawn, then two # empty ranks, then black's pawn, pawn, king, pawn def initial() -> Board: Board{row(WPawn{}, WKing{}, WPawn{}, WPawn{}, empty_row( empty_row( row(BPawn{}, BPawn{}, BKing{}, BPawn{}, End{}))))} # the same, with the pawn beside white's king traded for a queen def initial_with_queen() -> Board: Board{row(WPawn{}, WKing{}, WQueen{}, WPawn{}, empty_row( empty_row( row(BPawn{}, BPawn{}, BKing{}, BPawn{}, End{}))))} # a queen each, on the square beside each king, so the position stays # symmetric under turning the board around def initial_with_queens() -> Board: Board{row(WPawn{}, WKing{}, WQueen{}, WPawn{}, empty_row( empty_row( row(BPawn{}, BQueen{}, BKing{}, BPawn{}, End{}))))} # a knight for white alone, on the square the queen took def initial_with_knight() -> Board: Board{row(WPawn{}, WKing{}, WKnight{}, WPawn{}, empty_row( empty_row( row(BPawn{}, BPawn{}, BKing{}, BPawn{}, End{}))))} # and the same two squares, knights instead def initial_with_knights() -> Board: Board{row(WPawn{}, WKing{}, WKnight{}, WPawn{}, empty_row( empty_row( row(BPawn{}, BKnight{}, BKing{}, BPawn{}, End{}))))}