all repos

tyess @ 3f3048b6eabb39e39333e63cf4d67ba48587a099

chess in typst
28 files changed, 927 insertions(+), 0 deletions(-)
what's creamy
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
A assets/bbishop.png

Not showing binary file.

A assets/bking.png

Not showing binary file.

A assets/bknight.png

Not showing binary file.

A assets/bpawn.png

Not showing binary file.

A assets/bqueen.png

Not showing binary file.

A assets/brook.png

Not showing binary file.

A assets/wbishop.png

Not showing binary file.

A assets/wking.png

Not showing binary file.

A assets/wknight.png

Not showing binary file.

A assets/wpawn.png

Not showing binary file.

A assets/wqueen.png

Not showing binary file.

A assets/wrook.png

Not showing binary file.

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 readme
···
        
        1
        +TODO wirte readme

      
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 template/main.typ
···
        
        1
        +#import "../lib.typ": chess

      
        
        2
        +#show: chess.with()

      
        
        3
        +

      
        
        4
        +a4 b5

      
        
        5
        +axb5 Nc6

      
        
        6
        +b6 Ba6

      
        
        7
        +b7 qc8

      
        
        8
        +bxa8

      
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/run
···
        
        1
        +#!/usr/bin/env bash

      
        
        2
        +cd "$(dirname "$0")/.."

      
        
        3
        +tmp=$(mktemp -d)

      
        
        4
        +for f in tests/*.typ; do

      
        
        5
        +  typst compile --root . "$f" "$tmp/$(basename $f .typ).pdf" >/dev/null 2>&1 \

      
        
        6
        +    && echo "PASS $(basename $f)" || echo "FAIL $(basename $f)"

      
        
        7
        +done

      
        
        8
        +rm -rf "$tmp"

      
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"