pgn2 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +40 -7
- data/bench/baseline_parse.txt +6 -6
- data/lib/pgn/lexer.rb +43 -7
- data/lib/pgn/notation.rb +474 -0
- data/lib/pgn/pgn_parser.rb +4 -2
- data/lib/pgn/pgn_parser.y +4 -2
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +1 -0
- data/spec/notation_spec.rb +126 -0
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d90a73cdcc2fc1b2765b35315935c6f20272a0e2374863d0d7c491a21f815ff
|
|
4
|
+
data.tar.gz: e4b34dfcb1e2d0c63ed479d338a0d58f3808f965db99000844cf7dcfa3af8fc9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b222778b4e25b13fbe5e62fe694d1e5189099d8cafd7b5a15f579e3df7413aa60f43a69ee299d3a37fa312c3baf25e798a54e432ad199ec118ebd56e35b9949b
|
|
7
|
+
data.tar.gz: eacb82757bb6876272f320b6b5402a449d3954d0c39052ef4304682c00435b559b49a29379f77a9c7f34dbbf210c4b1865b59df66f19ae1a72bfb5c0050e1df7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
### Summary
|
|
6
|
+
|
|
7
|
+
New `PGN::Notation` module that *generates* Standard Algebraic Notation
|
|
8
|
+
(SAN) for a single coordinate move (origin square, destination square,
|
|
9
|
+
optional promotion) given the position before the move. The rest of the
|
|
10
|
+
gem only *parses* SAN; this is the reverse direction, needed to render
|
|
11
|
+
moves stored as coordinates (e.g. `e2`-`e4`) in standard chess notation.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `PGN::Notation.san(position, from, to, promotion = nil)` and the
|
|
15
|
+
convenience `PGN::Notation.san_from_fen(fen, from, to, promotion = nil)`.
|
|
16
|
+
Builds full SAN: piece letter, capture (`x`), castling (`O-O`/`O-O-O`),
|
|
17
|
+
pawn capture file (`exd5`), promotion (`=Q`), legal-move disambiguation
|
|
18
|
+
(file / rank / full square, respecting pins), and check (`+`) / checkmate
|
|
19
|
+
(`#`) suffixes. Checkmate detection drives full legal-move generation for
|
|
20
|
+
the side to move (the gem's first move generator). Raise `ArgumentError`
|
|
21
|
+
if the origin square is empty.
|
|
22
|
+
- New `spec/notation_spec.rb`; all 201 specs green. A round-trip harness over
|
|
23
|
+
every parseable fixture reproduces the original SAN of all 277 moves
|
|
24
|
+
exactly (including disambiguation and `+`/`#` suffixes).
|
|
25
|
+
|
|
26
|
+
### Changed (parser performance)
|
|
27
|
+
- `PGN::Lexer#scan_one` now dispatches on the leading byte of the next
|
|
28
|
+
token via a frozen `BYTE_DISPATCH` table (with an `ALL_RULES` fallback),
|
|
29
|
+
trying only the 1-2 rules that can match that byte instead of walking all
|
|
30
|
+
nine rules in order. Profiling had `StringScanner#scan` at ~23% of parse
|
|
31
|
+
CPU and the rule loop ~38% inclusive; the dispatch nearly halves scan time.
|
|
32
|
+
- `PGN::PgnParser#next_token` mutates the lexer's `[type, value]` pair in
|
|
33
|
+
place rather than allocating a second translated pair — one array per token
|
|
34
|
+
is the Racc floor.
|
|
35
|
+
- Net (500 immortal games): parse-only throughput +25% (305 → 203 ms/i),
|
|
36
|
+
parse allocations −17% (347037 → 288537 objects / 17977414 → 15640374
|
|
37
|
+
bytes), parse+replay allocations −7% (831530 → 773030 objects). Serialized
|
|
38
|
+
PGN/FEN output stays byte-identical; all 201 specs green.
|
|
39
|
+
|
|
3
40
|
## 1.3.0 (2026-08-13)
|
|
4
41
|
|
|
5
42
|
### Summary
|
data/README.md
CHANGED
|
@@ -122,6 +122,29 @@ _ _ _ ♙ _ _ _ _
|
|
|
122
122
|
=> r1bk3r/p2pBpNp/n4n2/1p1NP2P/6P1/3P4/P1P1K3/q5b1 b - - 1 22
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
+
### Generating SAN from coordinates
|
|
126
|
+
|
|
127
|
+
{PGN::Notation} is the reverse of {PGN::Move}: it *builds* Standard
|
|
128
|
+
Algebraic Notation for a coordinate move (origin square, destination square,
|
|
129
|
+
optional promotion) given the position before the move. Use it to render
|
|
130
|
+
moves stored as coordinates in standard chess notation.
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
> PGN::Notation.san(PGN::Position.start, "g1", "f3")
|
|
134
|
+
=> "Nf3"
|
|
135
|
+
|
|
136
|
+
> PGN::Notation.san_from_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "e2", "e4")
|
|
137
|
+
=> "e4"
|
|
138
|
+
|
|
139
|
+
> fen = "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1"
|
|
140
|
+
> PGN::Notation.san_from_fen(fen, "e1", "g1")
|
|
141
|
+
=> "O-O"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
It handles captures, en passant, promotions, legal-move disambiguation
|
|
145
|
+
(file / rank / full square, respecting pins), and check (`+`) / checkmate
|
|
146
|
+
(`#`) suffixes.
|
|
147
|
+
|
|
125
148
|
## Benchmarks
|
|
126
149
|
|
|
127
150
|
A reproducible profiling harness lives in `bench/`. It measures the
|
|
@@ -181,12 +204,12 @@ Parser — 500 immortal games (`bench/profile_parse.rb`):
|
|
|
181
204
|
|
|
182
205
|
| Metric | original `pgn` | pgn2 | Δ |
|
|
183
206
|
|---|---:|---:|---:|
|
|
184
|
-
| Parse-only allocations (objects) | 1248065 |
|
|
185
|
-
| Parse-only allocations (bytes) | 120370470 |
|
|
186
|
-
| Parse + replay allocations (objects) | 3778073 |
|
|
187
|
-
| Parse + replay allocations (bytes) | 249570048 |
|
|
188
|
-
| Parse-only throughput | 1461 ms/i |
|
|
189
|
-
| Parse + replay throughput | 1938 ms/i |
|
|
207
|
+
| Parse-only allocations (objects) | 1248065 | 288537 | -959528 (-76.9%) |
|
|
208
|
+
| Parse-only allocations (bytes) | 120370470 | 15640374 | -104730096 (-87.0%) |
|
|
209
|
+
| Parse + replay allocations (objects) | 3778073 | 773030 | -3005043 (-79.5%) |
|
|
210
|
+
| Parse + replay allocations (bytes) | 249570048 | 45626128 | -203943920 (-81.7%) |
|
|
211
|
+
| Parse-only throughput | 1461 ms/i | 203 ms/i | ~7.2x faster |
|
|
212
|
+
| Parse + replay throughput | 1938 ms/i | 484 ms/i | ~4.0x faster |
|
|
190
213
|
|
|
191
214
|
What changed to get there:
|
|
192
215
|
|
|
@@ -239,9 +262,19 @@ What changed to get there:
|
|
|
239
262
|
must clone the index every move and every move pays maintenance that pawns
|
|
240
263
|
(the common case, geometry-fixed origins) can't use — so it was rejected
|
|
241
264
|
and reverted. The 0x88 board alone is the winner.
|
|
265
|
+
15. `PGN::Lexer#scan_one` — byte-dispatch: the leading byte of the next
|
|
266
|
+
token selects the 1-2 `RULES` that can possibly match it (a frozen
|
|
267
|
+
`BYTE_DISPATCH` table; `ALL_RULES` fallback) instead of walking all nine
|
|
268
|
+
rules in order. `StringScanner#scan` was ~23% of parse CPU and the rule
|
|
269
|
+
loop ~38% inclusive; the dispatch nearly halves scan time. `PgnParser#
|
|
270
|
+
next_token` now mutates the lexer's `[type, value]` pair in place instead
|
|
271
|
+
of allocating a second translated pair (one array per token is the Racc
|
|
272
|
+
floor). Parse-only throughput +25% (305 → 203 ms/i), parse allocations
|
|
273
|
+
−17% (347037 → 288537 objects / 17977414 → 15640374 bytes for 500 games).
|
|
274
|
+
Output byte-identical.
|
|
242
275
|
|
|
243
276
|
Public output (FEN, PGN) is byte-identical to the original gem; the full
|
|
244
|
-
suite (
|
|
277
|
+
suite (201 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
|
|
245
278
|
before/after deltas that produced these tables.
|
|
246
279
|
|
|
247
280
|
## Installation
|
data/bench/baseline_parse.txt
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
Corpus: 500 copies of the immortal game
|
|
2
2
|
|
|
3
3
|
=== 1. Parse-only allocations (500 games) ===
|
|
4
|
-
total_allocated objects:
|
|
5
|
-
total_allocated bytes:
|
|
4
|
+
total_allocated objects: 288537
|
|
5
|
+
total_allocated bytes: 15640374
|
|
6
6
|
|
|
7
7
|
=== 2. Parse + replay allocations (500 games) ===
|
|
8
|
-
total_allocated objects:
|
|
9
|
-
total_allocated bytes:
|
|
8
|
+
total_allocated objects: 773030
|
|
9
|
+
total_allocated bytes: 45626128
|
|
10
10
|
|
|
11
11
|
=== 3. Parse-only throughput (ips) ===
|
|
12
12
|
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
13
13
|
Warming up --------------------------------------
|
|
14
14
|
parse 500 games 1.000 i/100ms
|
|
15
15
|
Calculating -------------------------------------
|
|
16
|
-
parse 500 games 4.
|
|
16
|
+
parse 500 games 4.926 (±20.3%) i/s (203.01 ms/i) - 25.000 in 5.075163s
|
|
17
17
|
|
|
18
18
|
=== 4. Parse + replay throughput (ips) ===
|
|
19
19
|
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
20
20
|
Warming up --------------------------------------
|
|
21
21
|
parse+replay 500 games 1.000 i/100ms
|
|
22
22
|
Calculating -------------------------------------
|
|
23
|
-
parse+replay 500 games
|
|
23
|
+
parse+replay 500 games 2.065 (± 0.0%) i/s (484.34 ms/i) - 11.000 in 5.327699s
|
|
24
24
|
|
|
25
25
|
Done. Compare this file against bench/baseline_parse.txt after optimizations.
|
data/lib/pgn/lexer.rb
CHANGED
|
@@ -117,6 +117,39 @@ module PGN
|
|
|
117
117
|
[:tag_name, TAG_NAME, false]
|
|
118
118
|
].freeze.each(&:freeze)
|
|
119
119
|
|
|
120
|
+
# Byte-dispatch table: maps the leading byte of the next token to the
|
|
121
|
+
# ordered list of RULES indices that could possibly match it. This lets
|
|
122
|
+
# {#scan_one} try one or two regexes for the common tokens instead of
|
|
123
|
+
# walking all nine rules in order (StringScanner#scan was ~23% of parse
|
|
124
|
+
# CPU and the rule loop ~38% inclusive per profiling). The order within
|
|
125
|
+
# each list mirrors RULES, so tokenization is byte-compatible with the
|
|
126
|
+
# linear scan. Indices: 0 wsp, 1 pgn_comment, 2 game_termination,
|
|
127
|
+
# 3 san_move, 4 move_number, 5 nag, 6 comment, 7 string, 8 tag_name.
|
|
128
|
+
BYTE_DISPATCH = begin
|
|
129
|
+
h = {
|
|
130
|
+
9 => [0], 10 => [0], 11 => [0], 12 => [0], 13 => [0], 32 => [0], # whitespace
|
|
131
|
+
37 => [1], # % pgn_comment
|
|
132
|
+
42 => [2], # * game_termination
|
|
133
|
+
34 => [7], # " string
|
|
134
|
+
123 => [6], # { comment
|
|
135
|
+
36 => [5], 63 => [5], 33 => [5], # $ ? ! nag
|
|
136
|
+
48 => [2, 3, 4, 8], 49 => [2, 4, 8], # 0, 1 (term/castle/num/tag)
|
|
137
|
+
95 => [8] # _ tag_name
|
|
138
|
+
}
|
|
139
|
+
(50..57).each { |b| h[b] = [4, 8] } # 2..9 move_number/tag_name
|
|
140
|
+
[66, 75, 78, 79, 81, 82].each { |b| h[b] = [3, 8] } # B K N O Q R san_move/tag
|
|
141
|
+
(97..104).each { |b| h[b] = [3, 8] } # a-h pawn san_move/tag
|
|
142
|
+
# other tag_name letters
|
|
143
|
+
((65..90).to_a + (105..122).to_a - [66, 75, 78, 79, 81, 82]).each do |b|
|
|
144
|
+
h[b] = [8]
|
|
145
|
+
end
|
|
146
|
+
h.each_value(&:freeze)
|
|
147
|
+
h.freeze
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Fallback for bytes not in BYTE_DISPATCH: try every rule in order.
|
|
151
|
+
ALL_RULES = (0...RULES.length).to_a.freeze
|
|
152
|
+
|
|
120
153
|
# Single-character literals, matched by their byte value: [type, frozen value].
|
|
121
154
|
LITERAL_BYTES = {
|
|
122
155
|
91 => [:lbracket, '['], # [
|
|
@@ -185,14 +218,17 @@ module PGN
|
|
|
185
218
|
# Try each terminal rule in order; return the matched string for the
|
|
186
219
|
# first match (stashing its type and discarded flag in +@scan_type+ /
|
|
187
220
|
# +@scan_discarded+ so the caller avoids allocating a 3-element tuple),
|
|
188
|
-
# or raise if nothing matches at the current position.
|
|
221
|
+
# or raise if nothing matches at the current position. Uses
|
|
222
|
+
# {BYTE_DISPATCH} to try only the rules that can match the leading byte.
|
|
189
223
|
def scan_one
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
224
|
+
indices = BYTE_DISPATCH[@input.getbyte(@ss.pos)] || ALL_RULES
|
|
225
|
+
indices.each do |i|
|
|
226
|
+
type, re, discarded = RULES[i]
|
|
227
|
+
next unless (m = @ss.scan(re))
|
|
228
|
+
|
|
229
|
+
@scan_type = type
|
|
230
|
+
@scan_discarded = discarded
|
|
231
|
+
return m
|
|
196
232
|
end
|
|
197
233
|
raise UnconsumedInputError,
|
|
198
234
|
"Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{@line}"
|
data/lib/pgn/notation.rb
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGN
|
|
4
|
+
# {PGN::Notation} generates Standard Algebraic Notation (SAN) for a single
|
|
5
|
+
# move described in coordinate form -- an origin square, a destination
|
|
6
|
+
# square, and an optional promotion piece -- given the {PGN::Position}
|
|
7
|
+
# *before* the move is played.
|
|
8
|
+
#
|
|
9
|
+
# Unlike {PGN::Move}, which *parses* an existing SAN string, {PGN::Notation}
|
|
10
|
+
# *builds* SAN from coordinates. This requires the kind of legality analysis
|
|
11
|
+
# the rest of the gem does not perform: attack detection, "does this move
|
|
12
|
+
# leave the mover's king in check", disambiguation among same-type pieces
|
|
13
|
+
# that can legally reach the destination, and check (+) / checkmate (#)
|
|
14
|
+
# suffix detection (the latter needing full legal-move generation for the
|
|
15
|
+
# side to move).
|
|
16
|
+
#
|
|
17
|
+
# The board is addressed with the same 0x88 integer indices the rest of the
|
|
18
|
+
# gem uses (see {PGN::Board}); an index is on-board when
|
|
19
|
+
# `(idx & 0x88).zero?`.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# PGN::Notation.san(PGN::Position.start, 'g1', 'f3') #=> "Nf3"
|
|
23
|
+
# PGN::Notation.san_from_fen(PGN::FEN::INITIAL, 'e2', 'e4') #=> "e4"
|
|
24
|
+
#
|
|
25
|
+
class Notation
|
|
26
|
+
# 0x88 single-step offsets for a knight (symmetric, so they double as
|
|
27
|
+
# attack deltas).
|
|
28
|
+
KNIGHT_OFFS = [33, 31, -31, -33, 18, 14, -14, -18].freeze
|
|
29
|
+
# 0x88 single-step offsets for a king (symmetric).
|
|
30
|
+
KING_OFFS = [-1, 1, -16, 16, -15, 15, -17, 17].freeze
|
|
31
|
+
BISHOP_DIRS = [-15, 15, -17, 17].freeze
|
|
32
|
+
ROOK_DIRS = [-1, 1, -16, 16].freeze
|
|
33
|
+
|
|
34
|
+
# Build SAN for a coordinate move from a {PGN::Position}.
|
|
35
|
+
#
|
|
36
|
+
# @param position [PGN::Position] the position *before* the move
|
|
37
|
+
# @param from [String] origin square in algebraic notation ("e2")
|
|
38
|
+
# @param to [String] destination square in algebraic notation ("e4")
|
|
39
|
+
# @param promotion [String, nil] promotion piece letter ("q"/"Q"/"n"...);
|
|
40
|
+
# case-insensitive; SAN always emits an uppercase letter
|
|
41
|
+
# @return [String] the move in SAN, e.g. "Nf3", "exd5", "O-O", "Ra8#"
|
|
42
|
+
#
|
|
43
|
+
def self.san(position, from, to, promotion = nil)
|
|
44
|
+
new(position).san(from, to, promotion)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Convenience: build SAN directly from a FEN string.
|
|
48
|
+
#
|
|
49
|
+
# @param fen [String] the FEN of the position *before* the move
|
|
50
|
+
# @param (see .san)
|
|
51
|
+
# @return [String] the move in SAN
|
|
52
|
+
#
|
|
53
|
+
def self.san_from_fen(fen, from, to, promotion = nil)
|
|
54
|
+
san(PGN::FEN.new(fen).to_position, from, to, promotion)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def initialize(position)
|
|
58
|
+
@position = position
|
|
59
|
+
@board = position.board
|
|
60
|
+
@player = position.player
|
|
61
|
+
@mover = (@player == :white ? 'w' : 'b')
|
|
62
|
+
@enemy = (@player == :white ? 'b' : 'w')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param (see .san)
|
|
66
|
+
# @return [String]
|
|
67
|
+
def san(from, to, promotion = nil)
|
|
68
|
+
from_idx = @board.index_of(from)
|
|
69
|
+
to_idx = @board.index_of(to)
|
|
70
|
+
piece = @board.at_index(from_idx)
|
|
71
|
+
raise ArgumentError, "no piece on #{from}" if piece.nil?
|
|
72
|
+
|
|
73
|
+
promotion = promotion.upcase if promotion
|
|
74
|
+
|
|
75
|
+
castling = piece.upcase == 'K' && ((from_idx & 0x0F) - (to_idx & 0x0F)).abs == 2
|
|
76
|
+
capture = castling ? false : capture?(piece, from_idx, to_idx)
|
|
77
|
+
|
|
78
|
+
body =
|
|
79
|
+
if castling
|
|
80
|
+
to_idx < from_idx ? 'O-O-O' : 'O-O'
|
|
81
|
+
elsif piece.upcase == 'P'
|
|
82
|
+
pawn_body(from_idx, to_idx, capture, promotion)
|
|
83
|
+
else
|
|
84
|
+
piece_body(piece, from_idx, to_idx, capture)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
body + suffix(from_idx, to_idx, piece, promotion, castling)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# -- body construction -------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def pawn_body(from_idx, to_idx, capture, promotion)
|
|
95
|
+
dest = @board.position_for([to_idx & 0x0F, to_idx >> 4])
|
|
96
|
+
promo = promotion ? "=#{promotion}" : ''
|
|
97
|
+
if capture
|
|
98
|
+
"#{Board::INDEX_TO_FILE[from_idx & 0x0F]}x#{dest}#{promo}"
|
|
99
|
+
else
|
|
100
|
+
"#{dest}#{promo}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def piece_body(piece, from_idx, to_idx, capture)
|
|
105
|
+
disamb = disambiguation(piece, from_idx, to_idx)
|
|
106
|
+
dest = @board.position_for([to_idx & 0x0F, to_idx >> 4])
|
|
107
|
+
parts = [piece.upcase, disamb]
|
|
108
|
+
parts << 'x' if capture
|
|
109
|
+
parts << dest
|
|
110
|
+
parts.join
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Disambiguate a non-pawn, non-castling move among same-type pieces that
|
|
114
|
+
# can *legally* reach the destination. Returns '' when none can.
|
|
115
|
+
def disambiguation(piece, from_idx, to_idx)
|
|
116
|
+
others = same_type_legals(piece, from_idx, to_idx)
|
|
117
|
+
return '' if others.empty?
|
|
118
|
+
|
|
119
|
+
from_file = from_idx & 0x0F
|
|
120
|
+
from_rank = from_idx >> 4
|
|
121
|
+
file_clash = others.any? { |o| (o & 0x0F) == from_file }
|
|
122
|
+
rank_clash = others.any? { |o| (o >> 4) == from_rank }
|
|
123
|
+
|
|
124
|
+
if !file_clash
|
|
125
|
+
Board::INDEX_TO_FILE[from_file]
|
|
126
|
+
elsif !rank_clash
|
|
127
|
+
Board::INDEX_TO_RANK[from_rank]
|
|
128
|
+
else
|
|
129
|
+
Board::INDEX_TO_FILE[from_file] + Board::INDEX_TO_RANK[from_rank]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Other squares holding `piece` whose move to `to_idx` is legal.
|
|
134
|
+
def same_type_legals(piece, from_idx, to_idx)
|
|
135
|
+
(0...128).each_with_object([]) do |idx, acc|
|
|
136
|
+
next if (idx & 0x88) != 0
|
|
137
|
+
next if idx == from_idx
|
|
138
|
+
next unless @board.at_index(idx) == piece
|
|
139
|
+
|
|
140
|
+
acc << idx if reaches?(piece, idx, to_idx) && legal_after?(idx, to_idx, piece)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# -- check / checkmate suffix -----------------------------------------
|
|
145
|
+
|
|
146
|
+
def suffix(from_idx, to_idx, piece, promotion, castling)
|
|
147
|
+
nb = apply_move(from_idx, to_idx, piece, promotion, castling)
|
|
148
|
+
opp_king = king_idx(nb, @enemy)
|
|
149
|
+
return '' unless opp_king && attacked?(nb, opp_king, @mover)
|
|
150
|
+
|
|
151
|
+
result_pos = PGN::Position.new(
|
|
152
|
+
nb,
|
|
153
|
+
@player == :white ? :black : :white,
|
|
154
|
+
[],
|
|
155
|
+
new_ep_square(piece, from_idx, to_idx),
|
|
156
|
+
0,
|
|
157
|
+
@position.fullmove
|
|
158
|
+
)
|
|
159
|
+
any_legal_move?(result_pos) ? '+' : '#'
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# -- move application -------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def apply_move(from_idx, to_idx, piece, promotion, castling)
|
|
165
|
+
nb = @board.dup
|
|
166
|
+
return castle!(nb, from_idx, to_idx, piece) if castling
|
|
167
|
+
|
|
168
|
+
ep = ep_capture_square(piece, from_idx, to_idx)
|
|
169
|
+
nb.update_index(ep, nil) if ep
|
|
170
|
+
nb.update_index(from_idx, nil)
|
|
171
|
+
nb.update_index(to_idx, promotion ? promoted(promotion) : piece)
|
|
172
|
+
nb
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def castle!(board, from_idx, to_idx, piece)
|
|
176
|
+
rank = from_idx >> 4
|
|
177
|
+
rook = piece.upcase == 'K' ? 'R' : 'r'
|
|
178
|
+
board.update_index(from_idx, nil)
|
|
179
|
+
board.update_index(to_idx, piece)
|
|
180
|
+
if to_idx < from_idx # queenside
|
|
181
|
+
board.update_index(rank * 16, nil)
|
|
182
|
+
board.update_index((rank * 16) + 3, rook)
|
|
183
|
+
else # kingside
|
|
184
|
+
board.update_index((rank * 16) + 7, nil)
|
|
185
|
+
board.update_index((rank * 16) + 5, rook)
|
|
186
|
+
end
|
|
187
|
+
board
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def promoted(promotion)
|
|
191
|
+
@player == :white ? promotion.upcase : promotion.downcase
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# En-passant captured-pawn square (or nil): a pawn moving diagonally to
|
|
195
|
+
# an empty destination must be capturing en passant.
|
|
196
|
+
def ep_capture_square(piece, from_idx, to_idx)
|
|
197
|
+
return nil unless piece.upcase == 'P'
|
|
198
|
+
return nil if (from_idx & 0x0F) == (to_idx & 0x0F)
|
|
199
|
+
|
|
200
|
+
@board.at_index(to_idx).nil? ? ((from_idx >> 4) * 16) + (to_idx & 0x0F) : nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The en-passant target square left behind by a double pawn push, for the
|
|
204
|
+
# resulting position (so an escaping ep capture is considered for mate).
|
|
205
|
+
def new_ep_square(piece, from_idx, to_idx)
|
|
206
|
+
return nil unless piece.upcase == 'P'
|
|
207
|
+
return nil if ((from_idx >> 4) - (to_idx >> 4)).abs != 2
|
|
208
|
+
|
|
209
|
+
mid = ((from_idx >> 4) + (to_idx >> 4)) / 2
|
|
210
|
+
Board::INDEX_TO_FILE[from_idx & 0x0F] + Board::INDEX_TO_RANK[mid]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# -- legality ---------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
# Is `piece` from `from_idx` to `to_idx` legal (own king safe after)?
|
|
216
|
+
def legal_after?(from_idx, to_idx, piece)
|
|
217
|
+
nb = @board.dup
|
|
218
|
+
ep = ep_capture_square(piece, from_idx, to_idx)
|
|
219
|
+
nb.update_index(ep, nil) if ep
|
|
220
|
+
nb.update_index(from_idx, nil)
|
|
221
|
+
nb.update_index(to_idx, piece)
|
|
222
|
+
king = king_idx(nb, @mover)
|
|
223
|
+
king && !attacked?(nb, king, @enemy)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Does `piece` at `from_idx` pseudo-legally reach `to_idx` on the board?
|
|
227
|
+
# (Sliders honor blockers; the destination may be empty or enemy-occupied.)
|
|
228
|
+
def reaches?(piece, from_idx, to_idx)
|
|
229
|
+
return false if from_idx == to_idx
|
|
230
|
+
|
|
231
|
+
case piece.upcase
|
|
232
|
+
when 'N'
|
|
233
|
+
KNIGHT_OFFS.any? { |o| (from_idx + o) == to_idx }
|
|
234
|
+
when 'K'
|
|
235
|
+
KING_OFFS.any? { |o| (from_idx + o) == to_idx }
|
|
236
|
+
when 'B', 'R', 'Q'
|
|
237
|
+
slider_reaches?(piece.upcase, from_idx, to_idx)
|
|
238
|
+
else
|
|
239
|
+
false
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def slider_reaches?(piece_up, from_idx, to_idx)
|
|
244
|
+
slider_dirs(piece_up).any? do |off|
|
|
245
|
+
i = from_idx + off
|
|
246
|
+
while (i & 0x88).zero?
|
|
247
|
+
return true if i == to_idx
|
|
248
|
+
break if @board.at_index(i)
|
|
249
|
+
|
|
250
|
+
i += off
|
|
251
|
+
end
|
|
252
|
+
false
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Capture detection for the *played* move (used in the SAN body).
|
|
257
|
+
def capture?(piece, from_idx, to_idx)
|
|
258
|
+
if piece.upcase == 'P'
|
|
259
|
+
(from_idx & 0x0F) != (to_idx & 0x0F) # any diagonal pawn move captures
|
|
260
|
+
else
|
|
261
|
+
target = @board.at_index(to_idx)
|
|
262
|
+
target && !own?(target, @mover)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def own?(piece, color)
|
|
267
|
+
color == 'w' ? piece == piece.upcase : piece == piece.downcase
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Slider ray directions for a piece letter ('B'/'R'/'Q').
|
|
271
|
+
def slider_dirs(piece_up)
|
|
272
|
+
case piece_up
|
|
273
|
+
when 'B' then BISHOP_DIRS
|
|
274
|
+
when 'R' then ROOK_DIRS
|
|
275
|
+
else BISHOP_DIRS + ROOK_DIRS
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# -- attack / legality helpers ----------------------------------------
|
|
280
|
+
|
|
281
|
+
def king_idx(board, color)
|
|
282
|
+
king = color == 'w' ? 'K' : 'k'
|
|
283
|
+
(0...128).each do |idx|
|
|
284
|
+
next if (idx & 0x88) != 0
|
|
285
|
+
|
|
286
|
+
return idx if board.at_index(idx) == king
|
|
287
|
+
end
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Is `target` attacked by `color`-colored pieces on `board`?
|
|
292
|
+
def attacked?(board, target, color)
|
|
293
|
+
pawn_attacked?(board, target, color) ||
|
|
294
|
+
knight_attacked?(board, target, color) ||
|
|
295
|
+
king_attacked?(board, target, color) ||
|
|
296
|
+
slider_attacked?(board, target, color)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def pawn_attacked?(board, target, color)
|
|
300
|
+
offs = color == 'w' ? [-15, -17] : [15, 17]
|
|
301
|
+
pawn = color == 'w' ? 'P' : 'p'
|
|
302
|
+
offs.any? do |off|
|
|
303
|
+
i = target + off
|
|
304
|
+
(i & 0x88).zero? && board.at_index(i) == pawn
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def knight_attacked?(board, target, color)
|
|
309
|
+
knight = color == 'w' ? 'N' : 'n'
|
|
310
|
+
KNIGHT_OFFS.any? do |off|
|
|
311
|
+
i = target + off
|
|
312
|
+
(i & 0x88).zero? && board.at_index(i) == knight
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def king_attacked?(board, target, color)
|
|
317
|
+
king = color == 'w' ? 'K' : 'k'
|
|
318
|
+
KING_OFFS.any? do |off|
|
|
319
|
+
i = target + off
|
|
320
|
+
(i & 0x88).zero? && board.at_index(i) == king
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def slider_attacked?(board, target, color)
|
|
325
|
+
bishop = color == 'w' ? 'B' : 'b'
|
|
326
|
+
rook = color == 'w' ? 'R' : 'r'
|
|
327
|
+
queen = color == 'w' ? 'Q' : 'q'
|
|
328
|
+
ray_hit?(board, target, BISHOP_DIRS) { |p| p == bishop || p == queen } ||
|
|
329
|
+
ray_hit?(board, target, ROOK_DIRS) { |p| p == rook || p == queen }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def ray_hit?(board, target, dirs)
|
|
333
|
+
dirs.each do |off|
|
|
334
|
+
i = target + off
|
|
335
|
+
while (i & 0x88).zero?
|
|
336
|
+
piece = board.at_index(i)
|
|
337
|
+
if piece
|
|
338
|
+
return true if yield(piece)
|
|
339
|
+
break
|
|
340
|
+
end
|
|
341
|
+
i += off
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
false
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# -- legal-move generation (for checkmate detection) ------------------
|
|
348
|
+
|
|
349
|
+
def any_legal_move?(position)
|
|
350
|
+
board = position.board
|
|
351
|
+
color = position.player == :white ? 'w' : 'b'
|
|
352
|
+
ep_idx = ep_target_idx(position, board)
|
|
353
|
+
|
|
354
|
+
(0...128).any? do |idx|
|
|
355
|
+
next if (idx & 0x88) != 0
|
|
356
|
+
|
|
357
|
+
piece = board.at_index(idx)
|
|
358
|
+
piece && own?(piece, color) && move_from?(board, idx, piece, color, ep_idx)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def ep_target_idx(position, board)
|
|
363
|
+
ep = position.en_passant
|
|
364
|
+
return nil if ep.nil? || ep == '-' || ep.empty?
|
|
365
|
+
|
|
366
|
+
board.index_of(ep)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Yields true if `piece` at `idx` has any legal move (escapes check).
|
|
370
|
+
def move_from?(board, idx, piece, color, ep_idx)
|
|
371
|
+
up = piece.upcase
|
|
372
|
+
case up
|
|
373
|
+
when 'N' then leaper_moves?(board, idx, piece, color, KNIGHT_OFFS)
|
|
374
|
+
when 'K' then leaper_moves?(board, idx, piece, color, KING_OFFS)
|
|
375
|
+
when 'B', 'R', 'Q'
|
|
376
|
+
slider_moves?(board, idx, piece, color, slider_dirs(up))
|
|
377
|
+
when 'P'
|
|
378
|
+
pawn_moves?(board, idx, piece, color, ep_idx)
|
|
379
|
+
else
|
|
380
|
+
false
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def leaper_moves?(board, idx, piece, color, offs)
|
|
385
|
+
offs.any? do |off|
|
|
386
|
+
t = idx + off
|
|
387
|
+
(t & 0x88).zero? && landable?(board, t, color) && safe?(board, idx, t, piece, nil)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def slider_moves?(board, idx, piece, color, dirs)
|
|
392
|
+
dirs.any? do |off|
|
|
393
|
+
t = idx + off
|
|
394
|
+
while (t & 0x88).zero?
|
|
395
|
+
tp = board.at_index(t)
|
|
396
|
+
if tp.nil?
|
|
397
|
+
return true if safe?(board, idx, t, piece, nil)
|
|
398
|
+
else
|
|
399
|
+
return true if !own?(tp, color) && safe?(board, idx, t, piece, nil)
|
|
400
|
+
break
|
|
401
|
+
end
|
|
402
|
+
t += off
|
|
403
|
+
end
|
|
404
|
+
false
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def pawn_moves?(board, idx, piece, color, ep_idx)
|
|
409
|
+
pawn_pushes?(board, idx, piece, color) ||
|
|
410
|
+
pawn_captures?(board, idx, piece, color, ep_idx)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def pawn_pushes?(board, idx, piece, color)
|
|
414
|
+
dir = color == 'w' ? 16 : -16
|
|
415
|
+
start_rank = color == 'w' ? 1 : 6
|
|
416
|
+
promo_rank = color == 'w' ? 7 : 0
|
|
417
|
+
|
|
418
|
+
t = idx + dir
|
|
419
|
+
return false unless (t & 0x88).zero? && board.at_index(t).nil?
|
|
420
|
+
|
|
421
|
+
promo = (t >> 4 == promo_rank) ? 'Q' : nil
|
|
422
|
+
return true if safe?(board, idx, t, piece, promo)
|
|
423
|
+
|
|
424
|
+
t2 = idx + (dir * 2)
|
|
425
|
+
(idx >> 4) == start_rank && board.at_index(t2).nil? && safe?(board, idx, t2, piece, nil)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def pawn_captures?(board, idx, piece, color, ep_idx) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
429
|
+
promo_rank = color == 'w' ? 7 : 0
|
|
430
|
+
caps = color == 'w' ? [15, 17] : [-15, -17]
|
|
431
|
+
caps.any? do |off|
|
|
432
|
+
t = idx + off
|
|
433
|
+
next false unless (t & 0x88).zero?
|
|
434
|
+
|
|
435
|
+
tp = board.at_index(t)
|
|
436
|
+
promo = (t >> 4 == promo_rank) ? 'Q' : nil
|
|
437
|
+
if tp && !own?(tp, color)
|
|
438
|
+
safe?(board, idx, t, piece, promo)
|
|
439
|
+
elsif tp.nil? && t == ep_idx
|
|
440
|
+
safe?(board, idx, t, piece, nil)
|
|
441
|
+
else
|
|
442
|
+
false
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def landable?(board, target, color)
|
|
448
|
+
tp = board.at_index(target)
|
|
449
|
+
tp.nil? || !own?(tp, color)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Apply a pseudo-move (with ep + promotion) on a copy and test own-king
|
|
453
|
+
# safety. The moving side is derived from `piece`'s case.
|
|
454
|
+
def safe?(board, from_idx, to_idx, piece, promotion)
|
|
455
|
+
nb = board.dup
|
|
456
|
+
if piece.upcase == 'P' && (from_idx & 0x0F) != (to_idx & 0x0F) && board.at_index(to_idx).nil?
|
|
457
|
+
nb.update_index(((from_idx >> 4) * 16) + (to_idx & 0x0F), nil) # ep capture
|
|
458
|
+
end
|
|
459
|
+
nb.update_index(from_idx, nil)
|
|
460
|
+
nb.update_index(to_idx, place_piece(piece, promotion))
|
|
461
|
+
color = own?(piece, 'w') ? 'w' : 'b'
|
|
462
|
+
king = king_idx(nb, color)
|
|
463
|
+
king && !attacked?(nb, king, color == 'w' ? 'b' : 'w')
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# The piece letter to place on the destination after a pseudo-move.
|
|
467
|
+
def place_piece(piece, promotion)
|
|
468
|
+
return piece unless promotion
|
|
469
|
+
|
|
470
|
+
white = piece == piece.upcase
|
|
471
|
+
white ? promotion.upcase : promotion.downcase
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
data/lib/pgn/pgn_parser.rb
CHANGED
|
@@ -21,8 +21,10 @@ module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 97)
|
|
|
21
21
|
def next_token
|
|
22
22
|
pair = @lexer.next_token_pair
|
|
23
23
|
return [false, false] unless pair
|
|
24
|
-
type, value
|
|
25
|
-
|
|
24
|
+
# Mutate the lexer's [type, value] pair in place rather than allocating
|
|
25
|
+
# a second translated pair -- one array per token is the Racc floor.
|
|
26
|
+
pair[0] = translate_type(pair[0])
|
|
27
|
+
pair
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
private
|
data/lib/pgn/pgn_parser.y
CHANGED
|
@@ -106,8 +106,10 @@ rule
|
|
|
106
106
|
def next_token
|
|
107
107
|
pair = @lexer.next_token_pair
|
|
108
108
|
return [false, false] unless pair
|
|
109
|
-
type, value
|
|
110
|
-
|
|
109
|
+
# Mutate the lexer's [type, value] pair in place rather than allocating
|
|
110
|
+
# a second translated pair -- one array per token is the Racc floor.
|
|
111
|
+
pair[0] = translate_type(pair[0])
|
|
112
|
+
pair
|
|
111
113
|
end
|
|
112
114
|
|
|
113
115
|
private
|
data/lib/pgn/version.rb
CHANGED
data/lib/pgn.rb
CHANGED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
# Helper: build a Position from a FEN string.
|
|
6
|
+
def pos(fen)
|
|
7
|
+
PGN::FEN.new(fen).to_position
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
describe PGN::Notation do
|
|
11
|
+
# ------------------------------------------------------------------
|
|
12
|
+
# Public API
|
|
13
|
+
# ------------------------------------------------------------------
|
|
14
|
+
describe '.san' do
|
|
15
|
+
it 'generates a plain pawn push from coordinates' do
|
|
16
|
+
expect(PGN::Notation.san(pos('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
|
|
17
|
+
'e2', 'e4')).to eq('e4')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'generates a knight move with the piece letter' do
|
|
21
|
+
expect(PGN::Notation.san(pos('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
|
|
22
|
+
'g1', 'f3')).to eq('Nf3')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'generates a pawn capture with the origin file' do
|
|
26
|
+
fen = 'rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 2'
|
|
27
|
+
expect(PGN::Notation.san(pos(fen), 'e4', 'd5')).to eq('exd5')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'generates an en passant capture' do
|
|
31
|
+
fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
|
|
32
|
+
expect(PGN::Notation.san(pos(fen), 'e5', 'd6')).to eq('exd6')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'generates a pawn promotion' do
|
|
36
|
+
fen = '8/P7/6k1/8/8/8/8/4K3 w - - 0 1'
|
|
37
|
+
expect(PGN::Notation.san(pos(fen), 'a7', 'a8', 'q')).to eq('a8=Q')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'generates a capture promotion' do
|
|
41
|
+
fen = '1n6/P7/6k1/8/8/8/8/4K3 w - - 0 1'
|
|
42
|
+
expect(PGN::Notation.san(pos(fen), 'a7', 'b8', 'q')).to eq('axb8=Q')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'generates kingside castling' do
|
|
46
|
+
fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
|
|
47
|
+
expect(PGN::Notation.san(pos(fen), 'e1', 'g1')).to eq('O-O')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'generates queenside castling' do
|
|
51
|
+
fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
|
|
52
|
+
expect(PGN::Notation.san(pos(fen), 'e1', 'c1')).to eq('O-O-O')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'generates kingside castling for black' do
|
|
56
|
+
fen = 'r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1'
|
|
57
|
+
expect(PGN::Notation.san(pos(fen), 'e8', 'g8')).to eq('O-O')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe '.san disambiguation' do
|
|
62
|
+
it 'disambiguates knights by file' do
|
|
63
|
+
# Knights on c3 and d4 both reach b5.
|
|
64
|
+
fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
|
|
65
|
+
expect(PGN::Notation.san(pos(fen), 'd4', 'b5')).to eq('Ndb5')
|
|
66
|
+
expect(PGN::Notation.san(pos(fen), 'c3', 'b5')).to eq('Ncb5')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'disambiguates rooks by rank when on the same file' do
|
|
70
|
+
# White rooks on a1 and a8 both reach a4 (file a clear).
|
|
71
|
+
fen = 'R7/8/8/4k3/8/8/8/R3K3 w - - 0 1'
|
|
72
|
+
expect(PGN::Notation.san(pos(fen), 'a1', 'a4')).to eq('R1a4')
|
|
73
|
+
expect(PGN::Notation.san(pos(fen), 'a8', 'a4')).to eq('R8a4')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'disambiguates queens by full square when file and rank both clash' do
|
|
77
|
+
# Queens on c1, f1, f4 all reach c4. The f1 queen shares file f with
|
|
78
|
+
# f4 and rank 1 with c1, so only the full square disambiguates.
|
|
79
|
+
fen = '7k/8/8/8/5Q2/8/8/2Q2Q1K w - - 0 1'
|
|
80
|
+
expect(PGN::Notation.san(pos(fen), 'f1', 'c4')).to eq('Qf1c4')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'omits disambiguation when only one piece can legally move there' do
|
|
84
|
+
# Two knights both pseudo-reach d2, but the e4 knight is pinned
|
|
85
|
+
# on the e-file (moving it exposes the king on e1 to the rook on e8),
|
|
86
|
+
# so only the b1 knight can legally play Nd2 — no disambiguation.
|
|
87
|
+
fen = '4r2k/8/8/8/4N3/8/8/1N2K3 w - - 0 1'
|
|
88
|
+
expect(PGN::Notation.san(pos(fen), 'b1', 'd2')).to eq('Nd2')
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe '.san check and checkmate suffixes' do
|
|
93
|
+
it 'appends + for a checking move' do
|
|
94
|
+
fen = '7k/8/8/8/8/8/6R1/6K1 w - - 0 1'
|
|
95
|
+
expect(PGN::Notation.san(pos(fen), 'g2', 'g8')).to eq('Rg8+')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'appends # for a checkmating move' do
|
|
99
|
+
fen = '6k1/5ppp/8/8/8/8/8/R6K w - - 0 1'
|
|
100
|
+
expect(PGN::Notation.san(pos(fen), 'a1', 'a8')).to eq('Ra8#')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'appends # to a promotion that gives checkmate' do
|
|
104
|
+
# White pawn b7 promotes with check on a king cornered on h8.
|
|
105
|
+
fen = '7k/1P6/8/8/8/8/8/6K1 w - - 0 1'
|
|
106
|
+
expect(PGN::Notation.san(pos(fen), 'b7', 'b8', 'q')).to eq('b8=Q+')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'does not append a suffix for a quiet move' do
|
|
110
|
+
fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
|
|
111
|
+
expect(PGN::Notation.san(pos(fen), 'd2', 'd4')).to eq('d4')
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe '.san_from_fen' do
|
|
116
|
+
it 'computes SAN directly from a FEN string' do
|
|
117
|
+
expect(PGN::Notation.san_from_fen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
|
118
|
+
'e2', 'e4')).to eq('e4')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'computes SAN for black to move' do
|
|
122
|
+
fen = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1'
|
|
123
|
+
expect(PGN::Notation.san_from_fen(fen, 'd7', 'd5')).to eq('d5')
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
metadata
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pgn2
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stacey Touset
|
|
8
8
|
- Murilo Vasconcelos
|
|
9
|
-
autorequire:
|
|
10
9
|
bindir: bin
|
|
11
10
|
cert_chain: []
|
|
12
|
-
date:
|
|
11
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
13
12
|
dependencies:
|
|
14
13
|
- !ruby/object:Gem::Dependency
|
|
15
14
|
name: bundler
|
|
@@ -168,6 +167,7 @@ files:
|
|
|
168
167
|
- lib/pgn/lexer.rb
|
|
169
168
|
- lib/pgn/move.rb
|
|
170
169
|
- lib/pgn/move_calculator.rb
|
|
170
|
+
- lib/pgn/notation.rb
|
|
171
171
|
- lib/pgn/parser.rb
|
|
172
172
|
- lib/pgn/pgn_parser.rb
|
|
173
173
|
- lib/pgn/pgn_parser.y
|
|
@@ -181,6 +181,7 @@ files:
|
|
|
181
181
|
- spec/lexer_spec.rb
|
|
182
182
|
- spec/move_calculator_spec.rb
|
|
183
183
|
- spec/move_spec.rb
|
|
184
|
+
- spec/notation_spec.rb
|
|
184
185
|
- spec/parser_explicit_spec.rb
|
|
185
186
|
- spec/parser_spec.rb
|
|
186
187
|
- spec/pgn_files/alternate_castling.pgn
|
|
@@ -208,7 +209,6 @@ metadata:
|
|
|
208
209
|
source_code_uri: https://github.com/muriloime/pgn
|
|
209
210
|
bug_tracker_uri: https://github.com/muriloime/pgn/issues
|
|
210
211
|
changelog_uri: https://github.com/muriloime/pgn/blob/main/CHANGELOG.md
|
|
211
|
-
post_install_message:
|
|
212
212
|
rdoc_options: []
|
|
213
213
|
require_paths:
|
|
214
214
|
- lib
|
|
@@ -223,8 +223,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
223
223
|
- !ruby/object:Gem::Version
|
|
224
224
|
version: '0'
|
|
225
225
|
requirements: []
|
|
226
|
-
rubygems_version:
|
|
227
|
-
signing_key:
|
|
226
|
+
rubygems_version: 4.0.10
|
|
228
227
|
specification_version: 4
|
|
229
228
|
summary: A PGN parser for Ruby
|
|
230
229
|
test_files:
|
|
@@ -234,6 +233,7 @@ test_files:
|
|
|
234
233
|
- spec/lexer_spec.rb
|
|
235
234
|
- spec/move_calculator_spec.rb
|
|
236
235
|
- spec/move_spec.rb
|
|
236
|
+
- spec/notation_spec.rb
|
|
237
237
|
- spec/parser_explicit_spec.rb
|
|
238
238
|
- spec/parser_spec.rb
|
|
239
239
|
- spec/pgn_files/alternate_castling.pgn
|