all repos

tyess @ 8691552

chess in typst

tyess/game.typ (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
only export chess as part of public api; merge moves.typ and rules.typ, 27 days ago
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 "rules.typ": generate-moves-for, has-legal-move, is-attacked, is-in-check, pawn-moves
4
5
#let initial-state() = (
6
  grid: starting-grid,
7
  turn: "white",
8
  castles: "KQkq",
9
  ep: none,
10
  error: none,
11
  checkmate: false,
12
  stalemate: false,
13
)
14
15
#let apply-castling(state, side) = {
16
  let castles = state.castles
17
  let grid = copy-grid(state.grid)
18
19
  let row = if state.turn == "white" { 7 } else { 0 }
20
  let case-p = if state.turn == "white" { upper } else { lower }
21
  let (rook-from, rook-to, king-to) = if side == "kingside" { (7, 5, 6) } else { (0, 3, 2) }
22
  grid.at(row).at(4) = "."
23
  grid.at(row).at(rook-from) = "."
24
  grid.at(row).at(king-to) = case-p("K")
25
  grid.at(row).at(rook-to) = case-p("R")
26
27
  let lost = if state.turn == "white" { "KQ" } else { "kq" }
28
  for c in lost { castles = castles.replace(c, "") }
29
30
  (
31
    grid: grid,
32
    turn: if state.turn == "white" { "black" } else { "white" },
33
    castles: castles,
34
    ep: none,
35
    error: none,
36
    checkmate: false,
37
    stalemate: false,
38
  )
39
}
40
41
#let apply-parsed-move(state, parsed) = {
42
  if parsed.kind == "error" { return (..state, error: parsed.raw + ": illegal notation") }
43
  if parsed.kind == "castle" { return apply-castling(state, parsed.side) }
44
45
  let dest-col = parsed.dest.at(0)
46
  let dest-row = parsed.dest.at(1)
47
48
  // find the source pieces that can reach the destination
49
  let candidates = ()
50
  if parsed.piece == "P" {
51
    let pawn-file = none
52
    if parsed.disambig != none and parsed.disambig.type == "file" {
53
      pawn-file = file-to-col.at(parsed.disambig.value) + 1
54
    }
55
56
    for (col, row) in find-pieces(state.grid, state.turn, "P") {
57
      if pawn-file != none and col != pawn-file { continue }
58
      for m in pawn-moves(state.grid, col, row, state.turn, ep: state.ep) {
59
        if m.at(0) == dest-col and m.at(1) == dest-row {
60
          candidates.push((col, row))
61
          break
62
        }
63
      }
64
    }
65
  } else {
66
    for (col, row) in find-pieces(state.grid, state.turn, parsed.piece) {
67
      let moves = generate-moves-for(state.grid, col, row, state.turn, ep: state.ep)
68
      for m in moves {
69
        if m.at(0) == dest-col and m.at(1) == dest-row {
70
          candidates.push((col, row))
71
        }
72
      }
73
    }
74
75
    // apply disambiguation
76
    if candidates.len() > 1 and parsed.disambig != none {
77
      if parsed.disambig.type == "file" {
78
        let f = file-to-col.at(parsed.disambig.value) + 1
79
        candidates = candidates.filter(c => c.at(0) == f)
80
81
      } else if parsed.disambig.type == "rank" {
82
        let r = 9 - int(parsed.disambig.value)
83
        candidates = candidates.filter(c => c.at(1) == r)
84
85
      } else if parsed.disambig.type == "square" {
86
        let (cf, rf) = square-to-coords(parsed.disambig.value)
87
        candidates = candidates.filter(c => c.at(0) == cf and c.at(1) == rf)
88
      }
89
    }
90
  }
91
92
  // auto-promote to queen
93
  let promotion = parsed.promotion
94
  if promotion == none and parsed.piece == "P" and (dest-row == 1 or dest-row == 8) { promotion = "Q" }
95
  if promotion != none and "QRBN".position(upper(promotion)) == none { return (..state, error: parsed.raw + "=" + promotion + ": illegal notation") }
96
97
  // check if it's an en passant capture
98
  let is-ep = parsed.piece == "P" and state.ep != none and dest-col == state.ep.at(0) and dest-row == state.ep.at(1)
99
  let ep-captured = if is-ep { (dest-col, if state.turn == "white" { dest-row + 1 } else { dest-row - 1 }) } else { none }
100
101
  // filter to only legal moves
102
  let legal-candidates = ()
103
  for (sc, sr) in candidates {
104
    let sim = apply-move(state.grid, sc, sr, dest-col, dest-row, promotion: promotion, en-passant: ep-captured)
105
    if not is-in-check(sim, state.turn) { legal-candidates.push((sc, sr)) }
106
  }
107
  candidates = legal-candidates
108
109
  if candidates.len() == 0 { return (..state, error: parsed.raw + ": illegal move") }
110
  let (src-col, src-row) = candidates.at(0)
111
112
  let new-grid = apply-move(state.grid, src-col, src-row, dest-col, dest-row, promotion: promotion, en-passant: ep-captured)
113
  let new-turn = if state.turn == "white" { "black" } else { "white" }
114
115
  // update castling rights
116
  let castles = state.castles
117
  let lost = if parsed.piece == "K" { if state.turn == "white" { "KQ" } else { "kq" } } else {
118
    let flags = ""
119
    for (rc, rr, flag) in ((1, 8, "Q"), (8, 8, "K"), (1, 1, "q"), (8, 1, "k")) {
120
      if src-col == rc and src-row == rr { flags += flag }
121
      if dest-col == rc and dest-row == rr { flags += flag }
122
    }
123
    flags
124
  }
125
  for c in lost { castles = castles.replace(c, "") }
126
127
  // set en passant target for the next move
128
  let new-ep = none
129
  if parsed.piece == "P" and calc.abs(dest-row - src-row) == 2 { new-ep = (dest-col, int((src-row + dest-row) / 2)) }
130
131
  // check for checkmate or stalemate
132
  let in-check = is-in-check(new-grid, new-turn)
133
  let no-legal = not has-legal-move(new-grid, new-turn, ep: new-ep, castles: castles)
134
135
  (
136
    grid: new-grid,
137
    turn: new-turn,
138
    castles: castles,
139
    ep: new-ep,
140
    error: none,
141
    checkmate: in-check and no-legal,
142
    stalemate: not in-check and no-legal,
143
  )
144
}
145
146
#let apply-moves(moves-strs) = {
147
  let state = initial-state()
148
  let states = (state,)
149
  for ms in moves-strs {
150
    if state.at("checkmate", default: false) or state.at("stalemate", default: false) { break }
151
    let new-state = apply-parsed-move(state, parse-move(ms))
152
    states += (new-state,)
153
    state = new-state
154
  }
155
  states
156
}
157
158
#let parse-moves(body) = {
159
  let extract(it) = {
160
    if it == [ ] { " " } else if it.func() == text { it.text } else if it.func() == [].func() {
161
      it.children.map(extract).join()
162
    } else { "" }
163
  }
164
  extract(body).split().filter(s => s != "")
165
}
166
167
#let render(state) = {
168
  let banner(body) = text(fill: rgb("#0a7e8c"), weight: "bold", size: 1.2em, body)
169
170
  let out = ()
171
  if state.at("checkmate", default: false)      { out.push(banner[Checkmate!]) }
172
  else if state.at("stalemate", default: false) { out.push(banner[Stalemate!]) }
173
  else {
174
    let err = state.at("error", default: none)
175
    if err != none                              { out.push(text(fill: red, weight: "bold", err)) }
176
    else if is-in-check(state.grid, state.turn) { out.push(text(fill: yellow, weight: "bold")[Check!]) }
177
  }
178
  out.push(v(0.3em))
179
  out.push(render-board(state.grid))
180
  out.join()
181
}
182
183
// Usage:
184
//   #show: chess.with()
185
//   g4 d5 Bg5 Bxg4 c4 c6 cxd5 cxd5 Qb3
186
//
187
#let chess(body) = {
188
  set page(height: auto, width: auto, margin: (top: 0.5in, bottom: 0.5in, rest: 0.5in))
189
  let moves = parse-moves(body)
190
  let states = apply-moves(moves)
191
  render(states.last())
192
}