all repos

tyess @ 3f3048b

chess in typst

tyess/game.typ (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
what's creamy, 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 "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
}