28 files changed,
927 insertions(+),
0 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-03 23:26:00 +0300
Authored at:
2026-07-03 21:47:19 +0300
Change ID:
krzlsrsmmrzzwtswtmxlrpwytlotvyzs
jump to
A
board.typ
··· 1 +// Pieces 2 + 3 +#let piece-type(p) = { if p == "." { return "." }; upper(p) } 4 +#let is-white(p) = p != "." and p == upper(p) 5 +#let is-black(p) = p != "." and p == lower(p) 6 + 7 +#let piece-assets = ( 8 + "K": "wking.png", "k": "bking.png", 9 + "Q": "wqueen.png", "q": "bqueen.png", 10 + "R": "wrook.png", "r": "brook.png", 11 + "N": "wknight.png", "n": "bknight.png", 12 + "B": "wbishop.png", "b": "bbishop.png", 13 + "P": "wpawn.png", "p": "bpawn.png", 14 +) 15 + 16 +#let starting-grid = ( 17 + ("r", "n", "b", "q", "k", "b", "n", "r"), 18 + ("p", "p", "p", "p", "p", "p", "p", "p"), 19 + (".", ".", ".", ".", ".", ".", ".", "."), 20 + (".", ".", ".", ".", ".", ".", ".", "."), 21 + (".", ".", ".", ".", ".", ".", ".", "."), 22 + (".", ".", ".", ".", ".", ".", ".", "."), 23 + ("P", "P", "P", "P", "P", "P", "P", "P"), 24 + ("R", "N", "B", "Q", "K", "B", "N", "R"), 25 +) 26 + 27 +// Coordinates 28 + 29 +#let col-to-file = ("a", "b", "c", "d", "e", "f", "g", "h") 30 +#let file-to-col = ( 31 + "a": 0, "b": 1, "c": 2, "d": 3, 32 + "e": 4, "f": 5, "g": 6, "h": 7, 33 +) 34 + 35 +#let coords-to-square(col, row) = col-to-file.at(col - 1) + str(9 - row) 36 +#let square-to-coords(sq) = ((file-to-col.at(sq.at(0)) + 1), (9 - int(sq.at(1)))) 37 + 38 +// Grid 39 + 40 +#let copy-grid(g) = g.map(r => r.map(c => c)) 41 +#let on-board(col, row) = col >= 1 and col <= 8 and row >= 1 and row <= 8 42 +#let at(grid, col, row) = { 43 + if not on-board(col, row) { return "." } 44 + grid.at(row - 1).at(col - 1) 45 +} 46 + 47 +#let find-pieces(grid, color, type) = { 48 + let pieces = () 49 + let test = if color == "white" { is-white } else { is-black } 50 + for r in range(8) { 51 + for c in range(8) { 52 + let p = grid.at(r).at(c) 53 + if p != "." and piece-type(p) == type and test(p) { 54 + pieces.push((c + 1, r + 1)) 55 + } 56 + } 57 + } 58 + pieces 59 +} 60 + 61 +#let apply-move(grid, col, row, dest-col, dest-row, promotion: none, en-passant: none) = { 62 + let g = copy-grid(grid) 63 + let piece = g.at(row - 1).at(col - 1) 64 + g.at(row - 1).at(col - 1) = "." 65 + 66 + if en-passant != none { 67 + g.at(en-passant.at(1) - 1).at(en-passant.at(0) - 1) = "." 68 + } 69 + 70 + if promotion != none { 71 + let color = if is-white(piece) { upper } else { lower } 72 + g.at(dest-row - 1).at(dest-col - 1) = color(promotion) 73 + } else { 74 + g.at(dest-row - 1).at(dest-col - 1) = piece 75 + } 76 + g 77 +} 78 + 79 +// Board 80 + 81 +#let is-dark-sq(file, rank) = calc.rem(file + rank, 2) == 1 82 + 83 +#let render-board(board) = { 84 + let files = ("a", "b", "c", "d", "e", "f", "g", "h") 85 + let ranks = ("8", "7", "6", "5", "4", "3", "2", "1") 86 + 87 + let light-square = rgb("#f0d9b5") 88 + let dark-square = rgb("#b58863") 89 + 90 + let cell-size = 2.8em 91 + let coord-size = 0.6em 92 + 93 + align(center + horizon, grid( 94 + columns: (coord-size,) + (cell-size,) * 8, 95 + rows: (cell-size,) * 8 + (coord-size,), 96 + ..{ 97 + let rows = () 98 + for rank in range(8) { 99 + rows.push(text(size: coord-size, ranks.at(rank))) 100 + for file in range(8) { 101 + let piece = board.at(rank).at(file) 102 + let sq-color = if is-dark-sq(file, rank) { dark-square } else { light-square } 103 + let display = if piece == "." { [] } else { image("assets/" + piece-assets.at(piece), width: cell-size * 0.85) } 104 + rows.push(box(width: cell-size, 105 + height: cell-size, 106 + fill: sq-color, 107 + align(center + horizon, display))) 108 + } 109 + } 110 + rows.push([]) 111 + for f in files { rows.push(text(size: coord-size, f)) } 112 + 113 + rows 114 + }, 115 + )) 116 +}
A
game.typ
··· 1 +#import "board.typ": apply-move, at, copy-grid, file-to-col, find-pieces, is-black, is-white, on-board, piece-type, render-board, square-to-coords, starting-grid 2 +#import "notation.typ": parse-move 3 +#import "moves.typ": generate-moves-for, pawn-moves 4 +#import "rules.typ": has-legal-move, is-attacked, is-in-check 5 + 6 +#let initial-state() = ( 7 + grid: starting-grid, 8 + turn: "white", 9 + castles: "KQkq", 10 + ep: none, 11 + error: none, 12 + checkmate: false, 13 + stalemate: false, 14 +) 15 + 16 +#let apply-castling(state, side) = { 17 + let castles = state.castles 18 + let grid = copy-grid(state.grid) 19 + 20 + let row = if state.turn == "white" { 7 } else { 0 } 21 + let case-p = if state.turn == "white" { upper } else { lower } 22 + let (rook-from, rook-to, king-to) = if side == "kingside" { (7, 5, 6) } else { (0, 3, 2) } 23 + grid.at(row).at(4) = "." 24 + grid.at(row).at(rook-from) = "." 25 + grid.at(row).at(king-to) = case-p("K") 26 + grid.at(row).at(rook-to) = case-p("R") 27 + 28 + let lost = if state.turn == "white" { "KQ" } else { "kq" } 29 + for c in lost { castles = castles.replace(c, "") } 30 + 31 + ( 32 + grid: grid, 33 + turn: if state.turn == "white" { "black" } else { "white" }, 34 + castles: castles, 35 + ep: none, 36 + error: none, 37 + checkmate: false, 38 + stalemate: false, 39 + ) 40 +} 41 + 42 +#let apply-parsed-move(state, parsed) = { 43 + if parsed.kind == "error" { return (..state, error: parsed.raw + ": illegal notation") } 44 + if parsed.kind == "castle" { return apply-castling(state, parsed.side) } 45 + 46 + let dest-col = parsed.dest.at(0) 47 + let dest-row = parsed.dest.at(1) 48 + 49 + // find the source pieces that can reach the destination 50 + let candidates = () 51 + if parsed.piece == "P" { 52 + let pawn-file = none 53 + if parsed.disambig != none and parsed.disambig.type == "file" { 54 + pawn-file = file-to-col.at(parsed.disambig.value) + 1 55 + } 56 + 57 + for (col, row) in find-pieces(state.grid, state.turn, "P") { 58 + if pawn-file != none and col != pawn-file { continue } 59 + for m in pawn-moves(state.grid, col, row, state.turn, ep: state.ep) { 60 + if m.at(0) == dest-col and m.at(1) == dest-row { 61 + candidates.push((col, row)) 62 + break 63 + } 64 + } 65 + } 66 + } else { 67 + for (col, row) in find-pieces(state.grid, state.turn, parsed.piece) { 68 + let moves = generate-moves-for(state.grid, col, row, state.turn, ep: state.ep) 69 + for m in moves { 70 + if m.at(0) == dest-col and m.at(1) == dest-row { 71 + candidates.push((col, row)) 72 + } 73 + } 74 + } 75 + 76 + // apply disambiguation 77 + if candidates.len() > 1 and parsed.disambig != none { 78 + if parsed.disambig.type == "file" { 79 + let f = file-to-col.at(parsed.disambig.value) + 1 80 + candidates = candidates.filter(c => c.at(0) == f) 81 + 82 + } else if parsed.disambig.type == "rank" { 83 + let r = 9 - int(parsed.disambig.value) 84 + candidates = candidates.filter(c => c.at(1) == r) 85 + 86 + } else if parsed.disambig.type == "square" { 87 + let (cf, rf) = square-to-coords(parsed.disambig.value) 88 + candidates = candidates.filter(c => c.at(0) == cf and c.at(1) == rf) 89 + } 90 + } 91 + } 92 + 93 + // auto-promote to queen 94 + let promotion = parsed.promotion 95 + if promotion == none and parsed.piece == "P" and (dest-row == 1 or dest-row == 8) { promotion = "Q" } 96 + if promotion != none and "QRBN".position(upper(promotion)) == none { return (..state, error: parsed.raw + "=" + promotion + ": illegal notation") } 97 + 98 + // check if it's an en passant capture 99 + let is-ep = parsed.piece == "P" and state.ep != none and dest-col == state.ep.at(0) and dest-row == state.ep.at(1) 100 + let ep-captured = if is-ep { (dest-col, if state.turn == "white" { dest-row + 1 } else { dest-row - 1 }) } else { none } 101 + 102 + // filter to only legal moves 103 + let legal-candidates = () 104 + for (sc, sr) in candidates { 105 + let sim = apply-move(state.grid, sc, sr, dest-col, dest-row, promotion: promotion, en-passant: ep-captured) 106 + if not is-in-check(sim, state.turn) { legal-candidates.push((sc, sr)) } 107 + } 108 + candidates = legal-candidates 109 + 110 + if candidates.len() == 0 { return (..state, error: parsed.raw + ": illegal move") } 111 + let (src-col, src-row) = candidates.at(0) 112 + 113 + let new-grid = apply-move(state.grid, src-col, src-row, dest-col, dest-row, promotion: promotion, en-passant: ep-captured) 114 + let new-turn = if state.turn == "white" { "black" } else { "white" } 115 + 116 + // update castling rights 117 + let castles = state.castles 118 + let lost = if parsed.piece == "K" { if state.turn == "white" { "KQ" } else { "kq" } } else { 119 + let flags = "" 120 + for (rc, rr, flag) in ((1, 8, "Q"), (8, 8, "K"), (1, 1, "q"), (8, 1, "k")) { 121 + if src-col == rc and src-row == rr { flags += flag } 122 + if dest-col == rc and dest-row == rr { flags += flag } 123 + } 124 + flags 125 + } 126 + for c in lost { castles = castles.replace(c, "") } 127 + 128 + // set en passant target for the next move 129 + let new-ep = none 130 + if parsed.piece == "P" and calc.abs(dest-row - src-row) == 2 { new-ep = (dest-col, int((src-row + dest-row) / 2)) } 131 + 132 + // check for checkmate or stalemate 133 + let in-check = is-in-check(new-grid, new-turn) 134 + let no-legal = not has-legal-move(new-grid, new-turn, ep: new-ep, castles: castles) 135 + 136 + ( 137 + grid: new-grid, 138 + turn: new-turn, 139 + castles: castles, 140 + ep: new-ep, 141 + error: none, 142 + checkmate: in-check and no-legal, 143 + stalemate: not in-check and no-legal, 144 + ) 145 +} 146 + 147 +#let apply-moves(moves-strs) = { 148 + let state = initial-state() 149 + let states = (state,) 150 + for ms in moves-strs { 151 + if state.at("checkmate", default: false) or state.at("stalemate", default: false) { break } 152 + let new-state = apply-parsed-move(state, parse-move(ms)) 153 + states += (new-state,) 154 + state = new-state 155 + } 156 + states 157 +} 158 + 159 +#let parse-moves(body) = { 160 + let extract(it) = { 161 + if it == [ ] { " " } else if it.func() == text { it.text } else if it.func() == [].func() { 162 + it.children.map(extract).join() 163 + } else { "" } 164 + } 165 + extract(body).split().filter(s => s != "") 166 +} 167 + 168 +#let render(state) = { 169 + let banner(body) = text(fill: rgb("#0a7e8c"), weight: "bold", size: 1.2em, body) 170 + 171 + let out = () 172 + if state.at("checkmate", default: false) { out.push(banner[Checkmate!]) } 173 + else if state.at("stalemate", default: false) { out.push(banner[Stalemate!]) } 174 + else { 175 + let err = state.at("error", default: none) 176 + if err != none { out.push(text(fill: red, weight: "bold", err)) } 177 + else if is-in-check(state.grid, state.turn) { out.push(text(fill: yellow, weight: "bold")[Check!]) } 178 + } 179 + out.push(v(0.3em)) 180 + out.push(render-board(state.grid)) 181 + out.join() 182 +}
A
lib.typ
··· 1 +#import "game.typ": render, apply-moves, parse-moves 2 + 3 +// Usage: 4 +// #show: chess.with() 5 +// g4 d5 Bg5 Bxg4 c4 c6 cxd5 cxd5 Qb3 6 +// 7 +#let chess(body) = { 8 + set page(height: auto, width: auto, margin: (top: 0.5in, bottom: 0.5in, rest: 0.5in)) 9 + let moves = parse-moves(body) 10 + let states = apply-moves(moves) 11 + render(states.last()) 12 +}
A
moves.typ
··· 1 +#import "board.typ": at, is-black, is-white, on-board, piece-type 2 + 3 +#let enemy(turn, target) = if turn == "white" { is-black(target) } else { is-white(target) } 4 + 5 +#let pawn-dir(turn) = if turn == "white" { -1 } else { 1 } 6 +#let pawn-moves(grid, col, row, turn, ep: none) = { // returns list of (dest-col, dest-row, prom?), ep == en passant 7 + let moves = () 8 + let start-row = if turn == "white" { 7 } else { 2 } 9 + let dir = pawn-dir(turn) 10 + 11 + // forward one 12 + let nr = row + dir 13 + if on-board(col, nr) and at(grid, col, nr) == "." { 14 + if nr == 1 or nr == 8 { 15 + for prom in ("Q", "R", "B", "N") { moves.push((col, nr, prom)) } 16 + } else { moves.push((col, nr)) } 17 + 18 + // forward two from start 19 + let nr2 = row + 2 * dir 20 + if row == start-row and at(grid, col, nr2) == "." { moves.push((col, nr2)) } 21 + } 22 + 23 + // captures 24 + for dc in (-1, 1) { 25 + let nc = col + dc 26 + let nr = row + dir 27 + if on-board(nc, nr) { 28 + if enemy(turn, at(grid, nc, nr)) { 29 + if nr == 1 or nr == 8 { 30 + for prom in ("Q", "R", "B", "N") { moves.push((nc, nr, prom)) } 31 + } else { 32 + moves.push((nc, nr)) 33 + } 34 + } 35 + 36 + // en passant capture: pawn ends on ep square, captured pawn is one rank behind 37 + if ep != none and nc == ep.at(0) { 38 + let captured-row = ep.at(1) - dir 39 + if enemy(turn, at(grid, nc, captured-row)) { moves.push((ep.at(0), ep.at(1))) } 40 + } 41 + } 42 + } 43 + 44 + moves 45 +} 46 + 47 +#let knight-offsets = ((-2, -1), (-2, 1), (-1, -2), (-1, 2), (1, -2), (1, 2), (2, -1), (2, 1)) 48 +#let knight-moves(grid, col, row, turn) = { 49 + let moves = () 50 + for (dc, dr) in knight-offsets { 51 + let nc = col + dc 52 + let nr = row + dr 53 + if on-board(nc, nr) { 54 + let target = at(grid, nc, nr) 55 + if target == "." or enemy(turn, target) { 56 + moves.push((nc, nr)) 57 + } 58 + } 59 + } 60 + moves 61 +} 62 + 63 +#let sliding-moves(grid, col, row, turn, dirs) = { 64 + let moves = () 65 + for (dc, dr) in dirs { 66 + let nc = col + dc 67 + let nr = row + dr 68 + while on-board(nc, nr) { 69 + let target = at(grid, nc, nr) 70 + if target == "." { 71 + moves.push((nc, nr)) 72 + } else if enemy(turn, target) { 73 + moves.push((nc, nr)) 74 + break 75 + } else { break } 76 + nc = nc + dc 77 + nr = nr + dr 78 + } 79 + } 80 + moves 81 +} 82 + 83 +#let bishop-dirs = ((-1, -1), (-1, 1), (1, -1), (1, 1)) 84 +#let bishop-moves(grid, col, row, turn) = sliding-moves(grid, col, row, turn, bishop-dirs) 85 + 86 +#let rook-dirs = ((-1, 0), (1, 0), (0, -1), (0, 1)) 87 +#let rook-moves(grid, col, row, turn) = sliding-moves(grid, col, row, turn, rook-dirs) 88 + 89 +#let queen-moves(grid, col, row, turn) = { 90 + let moves = () 91 + for m in bishop-moves(grid, col, row, turn) { moves.push(m) } 92 + for m in rook-moves(grid, col, row, turn) { moves.push(m) } 93 + moves 94 +} 95 + 96 +#let king-dirs = ((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)) 97 +#let king-moves(grid, col, row, turn) = { 98 + let moves = () 99 + for (dc, dr) in king-dirs { 100 + let nc = col + dc 101 + let nr = row + dr 102 + if on-board(nc, nr) { 103 + if at(grid, nc, nr) == "." or enemy(turn, at(grid, nc, nr)) { 104 + moves.push((nc, nr)) 105 + } 106 + } 107 + } 108 + moves 109 +} 110 + 111 +#let generate-moves-for(grid, col, row, turn, ep: none) = { 112 + let p = at(grid, col, row) 113 + if p == "." { return () } 114 + if turn == "white" and not is-white(p) { return () } 115 + if turn == "black" and not is-black(p) { return () } 116 + 117 + let type = piece-type(p) 118 + if type == "P" { return pawn-moves(grid, col, row, turn, ep: ep) } 119 + if type == "N" { return knight-moves(grid, col, row, turn) } 120 + if type == "B" { return bishop-moves(grid, col, row, turn) } 121 + if type == "R" { return rook-moves(grid, col, row, turn) } 122 + if type == "Q" { return queen-moves(grid, col, row, turn) } 123 + if type == "K" { return king-moves(grid, col, row, turn) } 124 + () 125 +}
A
notation.typ
··· 1 +#import "board.typ": file-to-col, square-to-coords 2 + 3 +#let str-index-of(s, c) = { 4 + for i in range(s.len()) { if s.at(i) == c { return i } } 5 + none 6 +} 7 + 8 +#let parse-move(move-str) = { 9 + let s = move-str.trim() 10 + let suffix = "" 11 + if s.len() > 0 and (s.at(-1) == "+" or s.at(-1) == "#") { 12 + suffix = s.at(-1) 13 + s = s.slice(0, -1) 14 + } 15 + 16 + // promotion (e8=Q, e8) 17 + let promotion = none 18 + let idx = str-index-of(s, "=") 19 + if idx != none { 20 + promotion = s.slice(idx + 1, s.len()) 21 + s = s.slice(0, idx) 22 + } 23 + 24 + // castling 25 + if lower(s) == "o-o" or s == "0-0" { return (kind: "castle", side: "kingside", suffix: suffix) } 26 + if lower(s) == "o-o-o" or s == "0-0-0" { return (kind: "castle", side: "queenside", suffix: suffix) } 27 + 28 + // determine piece type 29 + let ptype = "P" 30 + let pi = 0 31 + if s.len() > 0 and "KQRNBkqrn".contains(s.at(0)) { 32 + ptype = ("k": "K", "q": "Q", "r": "R", "n": "N").at(s.at(0), default: s.at(0)) 33 + pi = 1 34 + } 35 + 36 + // find dest square 37 + let dest-start = -1 38 + for i in range(pi, s.len() - 1) { 39 + if "abcdefgh".contains(s.at(i)) and "12345678".contains(s.at(i + 1)) { dest-start = i } 40 + } 41 + 42 + if dest-start == -1 { return (kind: "error", raw: s) } 43 + let dest = square-to-coords(s.at(dest-start) + s.at(dest-start + 1)) 44 + 45 + // check for capture by scanning for 'x' before destination 46 + let is-capture = false 47 + for i in range(pi, dest-start) { 48 + if s.at(i) == "x" { 49 + is-capture = true 50 + break 51 + } 52 + } 53 + 54 + // disambiguation between piece and destination 55 + let disambig = none 56 + if dest-start > pi { 57 + let between = s.slice(pi, dest-start) 58 + let capture-fix = if is-capture { between.replace("x", "") } else { between } 59 + if capture-fix.len() == 1 { 60 + if "abcdefgh".contains(capture-fix) { disambig = (type: "file", value: capture-fix) } 61 + else if "12345678".contains( capture-fix,) { disambig = (type: "rank", value: capture-fix) } 62 + } else if capture-fix.len() == 2 { 63 + if "abcdefgh".contains(capture-fix.at(0)) and "12345678".contains(capture-fix.at(1)) { 64 + disambig = (type: "square", value: capture-fix) 65 + } 66 + } 67 + } 68 + 69 + return ( 70 + kind: "move", 71 + piece: ptype, 72 + dest: dest, 73 + capture: is-capture, 74 + promotion: promotion, 75 + suffix: suffix, 76 + disambig: disambig, 77 + raw: s, 78 + ) 79 +}
A
rules.typ
··· 1 +#import "board.typ": at, copy-grid, find-pieces, is-black, is-white, on-board, piece-type 2 +#import "moves.typ": bishop-dirs, generate-moves-for, king-dirs, knight-offsets, pawn-moves, rook-dirs 3 + 4 +#let is-attacked(grid, col, row, by-color) = { 5 + let enemy(piece) = if by-color == "white" { is-white(piece) } else { is-black(piece) } 6 + 7 + // knight attacks 8 + for (dc, dr) in knight-offsets { 9 + let p = at(grid, col + dc, row + dr) 10 + if p != "." and piece-type(p) == "N" and enemy(p) { return true } 11 + } 12 + 13 + // king attacks 14 + for (dc, dr) in king-dirs { 15 + let p = at(grid, col + dc, row + dr) 16 + if p != "." and piece-type(p) == "K" and enemy(p) { return true } 17 + } 18 + 19 + // pawn attacks 20 + let pawn-checks = if by-color == "white" { ((-1, 1), (1, 1)) } else { ((-1, -1), (1, -1)) } 21 + for (dc, dr) in pawn-checks { 22 + let p = at(grid, col + dc, row + dr) 23 + if p != "." and piece-type(p) == "P" and enemy(p) { return true } 24 + } 25 + 26 + // sliding pieces 27 + for (dirs, types) in ((bishop-dirs, ("B", "Q")), (rook-dirs, ("R", "Q"))) { 28 + for (dc, dr) in dirs { 29 + let nc = col + dc 30 + let nr = row + dr 31 + while on-board(nc, nr) { 32 + let p = at(grid, nc, nr) 33 + if p != "." { 34 + if enemy(p) and types.contains(piece-type(p)) { return true } 35 + break // blocked by any piece 36 + } 37 + nc += dc 38 + nr += dr 39 + } 40 + } 41 + } 42 + 43 + false 44 +} 45 + 46 +#let is-in-check(grid, turn) = { 47 + let king-char = if turn == "white" { "K" } else { "k" } 48 + let enemy = if turn == "white" { "black" } else { "white" } 49 + for r in range(8) { 50 + for c in range(8) { 51 + if grid.at(r).at(c) == king-char { 52 + return is-attacked(grid, c + 1, r + 1, enemy) 53 + } 54 + } 55 + } 56 + false 57 +} 58 + 59 +#let can-castle(grid, row, empty-cols, check-cols, enemy) = { 60 + if is-attacked(grid, 5, row, enemy) { return false } 61 + for c in empty-cols { if at(grid, c, row) != "." { return false } } 62 + for c in check-cols { if is-attacked(grid, c, row, enemy) { return false } } 63 + true 64 +} 65 + 66 +#let has-legal-move(grid, turn, ep: none, castles: "") = { 67 + for r in range(8) { 68 + for c in range(8) { 69 + let p = grid.at(r).at(c) 70 + if p == "." { continue } 71 + if turn == "white" and not is-white(p) { continue } 72 + if turn == "black" and not is-black(p) { continue } 73 + 74 + let type = piece-type(p) 75 + 76 + let col = c + 1 77 + let row = r + 1 78 + let raw-moves = if type == "P" { pawn-moves(grid, col, row, turn, ep: ep) } else { 79 + generate-moves-for(grid, col, row, turn, ep: ep) 80 + } 81 + 82 + for m in raw-moves { 83 + let (dc, dr) = (m.at(0), m.at(1)) 84 + let new-grid = copy-grid(grid) 85 + new-grid.at(r).at(c) = "." 86 + let mover = p 87 + let is-pawn = type == "P" 88 + if is-pawn and (dr == 1 or dr == 8) { 89 + mover = if turn == "white" { "Q" } else { "q" } 90 + } 91 + new-grid.at(dr - 1).at(dc - 1) = mover 92 + if is-pawn and ep != none and dc == ep.at(0) and dr == ep.at(1) { 93 + let captured-row = if turn == "white" { dr + 1 } else { dr - 1 } 94 + new-grid.at(captured-row - 1).at(dc - 1) = "." 95 + } 96 + if not is-in-check(new-grid, turn) { return true } 97 + } 98 + } 99 + } 100 + 101 + let enemy = if turn == "white" { "black" } else { "white" } 102 + let options = if turn == "white" { 103 + (("K", 8, (6, 7), (6, 7)), ("Q", 8, (2, 3, 4), (3, 4))) 104 + } else { 105 + (("k", 1, (6, 7), (6, 7)), ("q", 1, (2, 3, 4), (3, 4))) 106 + } 107 + 108 + for (flag, row, empty, check) in options { 109 + if castles.contains(flag) and can-castle(grid, row, empty, check, enemy) { return true } 110 + } 111 + 112 + false 113 +}
A
tests/check.typ
··· 1 +#import "testutil.typ": assert-eq 2 +#import "../game.typ": apply-moves, initial-state 3 +#import "../rules.typ": has-legal-move, is-in-check 4 + 5 +// no one is in check at the start 6 +#assert(not is-in-check(initial-state().grid, "white")) 7 +#assert(not is-in-check(initial-state().grid, "black")) 8 + 9 +// queen gives check without bishop cover: Qxf7+ after e4 e5 Qh5 Nc6 10 +// and black king can escape by capturing the queen (king on e8, queen on f7) 11 +#let st = apply-moves(("e4", "e5", "Qh5", "Nc6", "Qxf7+")).last() 12 +#assert(is-in-check(st.grid, st.turn)) 13 +#assert(has-legal-move(st.grid, st.turn)) 14 +#assert(not st.checkmate) 15 + 16 +// scholar's mate 17 +#let st = apply-moves(("e4", "e5", "Qh5", "a6", "Bc4", "a5", "Qxf7")).last() 18 +#assert(is-in-check(st.grid, st.turn)) 19 +#assert(not has-legal-move(st.grid, st.turn)) 20 +#assert(st.checkmate) 21 + 22 +// stalemate 23 +#let stale = ( 24 + ..initial-state(), 25 + grid: ( 26 + ("k", ".", ".", ".", ".", ".", ".", "."), 27 + (".", ".", ".", ".", ".", ".", ".", "."), 28 + (".", "Q", ".", ".", ".", ".", ".", "."), 29 + (".", ".", ".", ".", ".", ".", ".", "."), 30 + (".", ".", ".", ".", ".", ".", ".", "."), 31 + (".", ".", ".", ".", ".", ".", ".", "."), 32 + (".", ".", ".", ".", ".", ".", ".", "."), 33 + ("K", ".", ".", ".", ".", ".", ".", "."), 34 + ), 35 + turn: "black", 36 + castles: "", 37 + ep: none, 38 +) 39 +#assert(not is-in-check(stale.grid, stale.turn)) 40 +#assert(not has-legal-move(stale.grid, stale.turn))
A
tests/errors.typ
··· 1 +#import "../game.typ": apply-moves 2 +#import "testutil.typ": assert-eq 3 + 4 +// Queen blocked by own pawn on d2 5 +#assert-eq(apply-moves(("Qd3",)).last().error, "Qd3: illegal move") 6 +#assert-eq(apply-moves(("qd3",)).last().error, "qd3: illegal move") 7 + 8 +// knight can't reach e4 from g8 9 +#let st = apply-moves(("e4", "e5", "Nc3", "c6", "Ne4")).last() 10 +#assert-eq(st.error, "Ne4: illegal move") 11 + 12 +// pawn move to occupied square 13 +#let st = apply-moves(("e4", "e5", "e4")).last() 14 +#assert-eq(st.error, "e4: illegal move") 15 + 16 +// bishop capture with no target 17 +#let st = apply-moves(("d4", "d5", "Bxe5")).last() 18 +#assert-eq(st.error, "Bxe5: illegal move") 19 + 20 +// eook on h1 after castling 21 +#let st = apply-moves(("e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5", "O-O", "Rh1")).last() 22 +#assert-eq(st.error, "Rh1: illegal move") 23 + 24 +// moving while in check without addressing it 25 +#let st = apply-moves(("e4", "e5", "Qh5", "a6", "Qe5", "a5")).last() 26 +#assert-eq(st.error, "a5: illegal move") 27 + 28 +// error clears on next valid move 29 +#let st = apply-moves(("zzz", "e4")).last() 30 +#assert-eq(st.error, none)
A
tests/game.typ
··· 1 +#import "../game.typ": apply-moves, initial-state 2 +#import "../board.typ": is-dark-sq, starting-grid 3 +#import "../notation.typ": parse-move 4 +#import "../rules.typ": has-legal-move, is-in-check 5 +#import "testutil.typ": assert-eq, assert-grid, assert-no-error 6 + 7 +// pawn advances and turn switching 8 +#let st = apply-moves(("e4",)).last() 9 +#assert-eq(st.turn, "black") 10 +#assert-no-error(st, "e4") 11 + 12 +#let st = apply-moves(("e4", "e5")).last() 13 +#assert-eq(st.turn, "white") 14 +#assert-grid(st, 5, 2, ".") // e2 empty 15 +#assert-grid(st, 5, 4, "P") // e4 16 +#assert-grid(st, 5, 7, ".") // e7 empty 17 +#assert-grid(st, 5, 5, "p") // e5 18 + 19 +// "how knight moves?" 20 +#let st = apply-moves(("e4", "e5", "Nf3")).last() 21 +#assert-grid(st, 6, 3, "N") // f3 22 +#assert-grid(st, 7, 1, ".") // g1 empty 23 + 24 +// bishop + pawn + capture 25 +#let st = apply-moves(("e4", "e5", "Nf3", "Nc6", "Bb5", "a6", "Bxc6")).last() 26 +#assert-no-error(st, "Bxc6") 27 +#assert-grid(st, 3, 6, "B") // bishop on c6 28 + 29 +// took move after clearing a pawn 30 +#let st = apply-moves(("a4", "d6", "Ra3")).last() 31 +#assert-no-error(st, "Ra3") 32 +#assert-grid(st, 1, 3, "R") // a3 33 +#assert-grid(st, 1, 1, ".") // a1 empty 34 + 35 +// lowercase notation 36 +#let st = apply-moves(("e4", "d5", "nf3")).last() 37 +#assert-no-error(st, "nf3") 38 +#assert-grid(st, 6, 3, "N") 39 + 40 +// castling white, kingside 41 +#let castling-setup = ("e4", "e5", "Nf3", "Nc6", "Bb5", "a6", "Bxc6", "dxc6") 42 +#let st = apply-moves(castling-setup + ("O-O",)).last() 43 +#assert-no-error(st, "O-O") 44 +#assert-grid(st, 5, 1, ".") // e1 empty 45 +#assert-grid(st, 7, 1, "K") // g1 46 +#assert-grid(st, 6, 1, "R") // f1 rook 47 +#assert-grid(st, 8, 1, ".") // h1 empty 48 +#assert(st.castles.find("K") == none) 49 +#assert(st.castles.find("Q") == none) 50 + 51 +// castling blac, kingside 52 +#let st = apply-moves(castling-setup + ("O-O", "O-O")).last() 53 +#assert-no-error(st, "O-O") 54 +#assert-grid(st, 5, 8, ".") // e8 empty 55 +#assert-grid(st, 7, 8, "k") // g8 56 +#assert-grid(st, 6, 8, "r") // f8 rook 57 + 58 +// en passant 59 +#let ep = apply-moves(("a4", "b5", "axb6")).last() 60 +#assert-eq(ep.turn, "black") 61 +#assert-grid(ep, 1, 4, ".") // a4 empty 62 +#assert-grid(ep, 2, 6, "P") // b6 63 +#assert-grid(ep, 2, 5, ".") // b5 captured 64 + 65 +#let ep = apply-moves(("e4", "d5", "e5", "d4", "c4", "dxc3")).last() 66 +#assert-grid(ep, 4, 4, ".") // d4 empty 67 +#assert-grid(ep, 3, 3, "p") // c3 68 +#assert-grid(ep, 3, 4, ".") // c4 captured 69 + 70 +#let st = apply-moves(("a4", "b5", "a5")).last() 71 +#assert-eq(st.ep, none) 72 + 73 +#let st = apply-moves(("e4", "d5", "e5", "d4", "exd6")).last() 74 +#assert-eq(st.error, "exd6: illegal move")
A
tests/move-gen.typ
··· 1 +#import "../moves.typ": generate-moves-for 2 +#import "../board.typ": starting-grid 3 +#import "testutil.typ": assert-eq 4 + 5 +// king, no moves in starting position 6 +#assert-eq(generate-moves-for(starting-grid, 5, 8, "white").len(), 0) 7 + 8 +// pawn e2 → e3, e4 9 +#let pmoves = generate-moves-for(starting-grid, 5, 7, "white") 10 +#assert(pmoves.len() >= 2) 11 +#assert-eq(pmoves.at(0), (5, 6)) 12 +#assert-eq(pmoves.at(1), (5, 5)) 13 + 14 +// knight b1 → a3, c3 15 +#let km = generate-moves-for(starting-grid, 2, 8, "white") 16 +#assert-eq(km.len(), 2) 17 +#assert-eq(km.at(0), (1, 6)) 18 +#assert-eq(km.at(1), (3, 6)) 19 + 20 +// en passant: white pawn on a4 with ep = b6 21 +#let ep-grid = ( 22 + (".", ".", ".", ".", ".", ".", ".", "."), 23 + (".", ".", ".", ".", ".", ".", ".", "."), 24 + (".", ".", ".", ".", ".", ".", ".", "."), 25 + (".", "p", ".", ".", ".", ".", ".", "."), 26 + ("P", ".", ".", ".", ".", ".", ".", "."), 27 + (".", ".", ".", ".", ".", ".", ".", "."), 28 + (".", ".", ".", ".", ".", ".", ".", "."), 29 + (".", ".", ".", ".", ".", ".", ".", "."), 30 +) 31 +#let epm = generate-moves-for(ep-grid, 1, 5, "white", ep: (2, 3)) 32 +#let found = false 33 +#for m in epm { if m.at(0) == 2 and m.at(1) == 3 { found = true; break } } 34 +#assert(found)
A
tests/parse.typ
··· 1 +#import "../notation.typ": parse-move 2 +#import "testutil.typ": assert-eq 3 + 4 +#let pm(s) = parse-move(s) 5 + 6 +#let m = pm("e4") 7 +#assert-eq(m.kind, "move") 8 +#assert-eq(m.piece, "P") 9 +#assert-eq(m.dest, (5, 5)) 10 +#assert-eq(m.capture, false) 11 + 12 +#let m = pm("Nf3") 13 +#assert-eq(m.piece, "N") 14 +#assert-eq(m.dest, (6, 6)) 15 + 16 +#let m = pm("Bxe5") 17 +#assert-eq(m.piece, "B") 18 +#assert-eq(m.dest, (5, 4)) 19 +#assert-eq(m.capture, true) 20 + 21 +#let m = pm("Qxf7+") 22 +#assert-eq(m.suffix, "+") 23 + 24 +#let m = pm("Qxf7#") 25 +#assert-eq(m.suffix, "#") 26 + 27 +#let m = pm("e8=Q") 28 +#assert-eq(m.piece, "P") 29 +#assert-eq(m.dest, (5, 1)) 30 +#assert-eq(m.promotion, "Q") 31 + 32 +#assert-eq(pm("O-O").kind, "castle") 33 +#assert-eq(pm("O-O").side, "kingside") 34 +#assert-eq(pm("O-O-O").side, "queenside") 35 +#assert-eq(pm("0-0").side, "kingside") 36 + 37 +#let m = pm("Rae1") 38 +#assert-eq(m.disambig.type, "file") 39 +#assert-eq(m.disambig.value, "a") 40 + 41 +#let m = pm("R1a3") 42 +#assert-eq(m.disambig.type, "rank") 43 + 44 +#let m = pm("Qh4e1") 45 +#assert-eq(m.disambig.type, "square") 46 +#assert-eq(m.disambig.value, "h4") 47 + 48 +#let m = pm("exd6") 49 +#assert-eq(m.capture, true) 50 +#assert-eq(m.disambig.type, "file") 51 +#assert-eq(m.disambig.value, "e") 52 + 53 +#let m = pm("nf3") 54 +#assert-eq(m.piece, "N") 55 + 56 +#let m = pm("qd3") 57 +#assert-eq(m.piece, "Q") 58 +#assert-eq(m.dest, (4, 6)) 59 + 60 +#let m = pm("axb6") 61 +#assert-eq(m.kind, "move") 62 +#assert-eq(m.piece, "P") 63 +#assert-eq(m.dest, (2, 3)) 64 +#assert-eq(m.capture, true) 65 +#assert-eq(m.disambig.type, "file") 66 +#assert-eq(m.disambig.value, "a") 67 + 68 +#let m = pm("dxc3") 69 +#assert-eq(m.dest, (3, 6)) 70 +#assert-eq(m.disambig.value, "d") 71 + 72 +#assert-eq(pm("zzz").kind, "error") 73 +#assert-eq(pm("Qx?").kind, "error")
A
tests/testutil.typ
··· 1 +#let assert-eq(a, b) = assert(a == b) 2 + 3 +#let assert-grid(state, file, rank, expected) = { 4 + let actual = state.grid.at(8 - rank).at(file - 1) 5 + assert(actual == expected) 6 +} 7 + 8 +#let assert-no-error(state, label) = { 9 + let err = state.at("error", default: none) 10 + assert(err == none) 11 +} 12 + 13 +#let assert-error(state, expected) = { 14 + let err = state.at("error", default: none) 15 + assert(err != none) 16 + assert(err == expected) 17 +}
A
typst.toml
··· 1 +[package] 2 +name = "tyess" 3 +version = "0.1.0" 4 +compiler = "0.13.0" 5 +entrypoint = "lib.typ" 6 +repository = "https://github.com/olexsmir/tyess" 7 +authors = ["olexsmir"] 8 +license = "MIT" 9 +description = "Chess" 10 +keywords = ["game", "fun", "chess"] 11 +categories = ["fun"] 12 + 13 +[template] 14 +path = "template" 15 +entrypoint = "main.typ"