pgn2 1.2.1 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 963f48df8fbfe5d69097030a9434a05b82e211c235772273f03bd11178afb8a5
4
- data.tar.gz: 6bc38e0387e7061e3649fe52d22e5f01dabdc7029f8c27294d56b62a3779a76c
3
+ metadata.gz: 33036a2920baf0e170efc5e1c387c2a53ae91fe19ad19cf745d4d6f049c9fe20
4
+ data.tar.gz: 93ccbe0289a72f4af5fb8367a4d1f5bdc0c1d5eb77f1a65e3381b7d17f5d3822
5
5
  SHA512:
6
- metadata.gz: e81b88377f5d1ab6102a5b15d4d006feb4201647125b5b3d434b826dfa55cac60c82048fc8d4d2a4c8aa7d1600d6ba4edd472f27d063d258703d8e225c0832cc
7
- data.tar.gz: 06cb00152b81f2124749fbc743356a25a7ee44e049c7779fb397660a84ebfefee8afcf4679f86ac633ff12b771db007123308cd5c65520ae8329e7334b340ab9
6
+ metadata.gz: 25ed517772982909d896b4b372308d235574db54aa44f3ff98f5201f9055120fc64d19fcd634c6676f7bfb66cf5e4de483a2a9ae13293b3162781339434bfac1
7
+ data.tar.gz: bf2a935f6aa4fedffb5976b0febc5eb351354165275d8b248eac99f7a713d74cf6acb3cb9e0be51c6030a55481663173ab5c9008cf5dfa67904bd09ac4e271f5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 (2026-08-13)
4
+
5
+ ### Summary
6
+
7
+ Replay (move-application) board-representation rewrite: `PGN::Board`
8
+ internals move to the classic 0x88 scheme (a 128-cell array indexed by
9
+ `rank*16+file`) and `PGN::MoveCalculator` works entirely in single-integer
10
+ square indices, so the replay hot path no longer allocates `[file,rank]`
11
+ coordinate arrays or square-name strings. No public API changes; serialized
12
+ PGN/FEN output stays byte-identical.
13
+
14
+ ### Changed
15
+ - **`PGN::Board`**: internals rewritten to a 0x88 array (`@cells`, 128
16
+ entries). The public file-major `squares` 8x8 API, `at` (string/coord
17
+ overloads), `update`, `change!`, `position_for`, `coordinates_for`, and
18
+ `dup` are preserved (computed/translated at the API boundary, off the
19
+ hot path). New internal 0x88 accessors `index_of`, `index_for`, `at_index`,
20
+ `update_index`, and `apply!` (integer-keyed batch update) serve the hot
21
+ path. `dup` copies the 128-cell array (cheaper than the prior column COW:
22
+ 136 → 91 objects / 10336 → 3856 bytes per 45 dupes).
23
+ - **`PGN::MoveCalculator`**: rewritten to address squares as 0x88 integer
24
+ indices throughout. `#compute_origin` returns an index; `#changes` is an
25
+ integer-keyed hash applied via `Board#apply!`; ray stepping (`first_piece`)
26
+ and off-board checks use a single integer add and the `(idx & 0x88).zero?`
27
+ bitmask (≈1.6x faster than a 0..7 four-integer bounds check, measured);
28
+ `castling_restrictions`/`en_passant_*` use integer corner indices. The
29
+ public `#origin` reader still returns an algebraic square string. Scan and
30
+ disambiguation algorithms are unchanged, so output is byte-identical.
31
+ - **Plan B (piece-location index) — attempted, rejected**: a `piece → 0x88
32
+ indices` index maintained in `update`/`apply!`, used for O(1)
33
+ slider/leaper/king origin lookups, was implemented on top of the 0x88
34
+ board and passed all 182 specs, but regressed: replay 526 → 727 µs/i
35
+ (+38% slower), allocations 976 → 1591 objects (+63%). `Board#dup` (called
36
+ every move) must clone the index (≈12 piece arrays: Board#dup 91 → 676
37
+ objects), and every move pays per-update index maintenance that pawns —
38
+ the most common move type, whose origins are geometry-fixed and can't use
39
+ the index — pay for no benefit. The index helps move-*generation*
40
+ libraries (chess.js, python-chess) that enumerate all legal moves, but
41
+ not replay, which validates one given move where ray-scanning from the
42
+ destination is already cheap. Reverted; the 0x88 board alone is the
43
+ winner. Rationale recorded in `TODO.md`.
44
+ - Performance vs 1.2.1 (immortal game, 45 plies; A/B batched median, 200
45
+ replies × 7, fresh process each): replay throughput 798 → 535 µs/i
46
+ (+49%); replay allocations 1571 → 976 objects (−38%) and 92440 → 62064
47
+ bytes (−33%). Full pipeline (`bench/profile_parse.rb`, 500 games):
48
+ parse+replay throughput 659 → 534 ms/i (+23%), allocations 1101586 →
49
+ 831530 objects (−24.5%) and 62164136 → 47966112 bytes (−22.8%);
50
+ parse-only unchanged. 182 specs pass; byte-identical FEN/PGN output;
51
+ zero new rubocop offenses vs 1.2.1 (the rewrite is shorter on
52
+ ClassLength/AbcSize and leaves the same pre-existing metric offenses on
53
+ the same methods).
54
+
3
55
  ## 1.2.1 (2026-08-13)
4
56
 
5
57
  ### Summary
data/README.md CHANGED
@@ -169,13 +169,13 @@ Move pipeline — immortal game, 45 plies (`bench/profile_moves.rb`):
169
169
 
170
170
  | Metric | original `pgn` | pgn2 | Δ |
171
171
  |---|---:|---:|---:|
172
- | Replay allocations (objects) | 5124 | 1710 | -3414 (-66.7%) |
173
- | Replay allocations (bytes) | 262608 | 103760 | -158848 (-60.5%) |
174
- | `Board#dup` x45 (objects) | 451 | 136 | -315 (-69.8%) |
175
- | `Board#dup` x45 (bytes) | 43096 | 10336 | -32760 (-76.0%) |
172
+ | Replay allocations (objects) | 5124 | 976 | -4148 (-80.9%) |
173
+ | Replay allocations (bytes) | 262608 | 62064 | -200544 (-76.4%) |
174
+ | `Board#dup` x45 (objects) | 451 | 91 | -360 (-79.8%) |
175
+ | `Board#dup` x45 (bytes) | 43096 | 3856 | -39240 (-91.1%) |
176
176
  | `Board#at(str)` x1000 (objects) | 6000 | 0 | -6000 (-100%) |
177
177
  | `Board#at(str)` x1000 (bytes) | 240000 | 0 | -240000 (-100%) |
178
- | Replay throughput | 841 µs/i | 766 µs/i | ~1.10x faster |
178
+ | Replay throughput | 841 µs/i | 517 µs/i | ~1.63x faster |
179
179
 
180
180
  Parser — 500 immortal games (`bench/profile_parse.rb`):
181
181
 
@@ -183,10 +183,10 @@ Parser — 500 immortal games (`bench/profile_parse.rb`):
183
183
  |---|---:|---:|---:|
184
184
  | Parse-only allocations (objects) | 1248065 | 347037 | -901028 (-72.2%) |
185
185
  | Parse-only allocations (bytes) | 120370470 | 17977414 | -102393056 (-85.1%) |
186
- | Parse + replay allocations (objects) | 3778073 | 1170586 | -2607487 (-69.0%) |
187
- | Parse + replay allocations (bytes) | 249570048 | 67804136 | -181765912 (-72.8%) |
186
+ | Parse + replay allocations (objects) | 3778073 | 831530 | -2946543 (-78.0%) |
187
+ | Parse + replay allocations (bytes) | 249570048 | 47966112 | -201603936 (-80.8%) |
188
188
  | Parse-only throughput | 1461 ms/i | 305 ms/i | ~4.8x faster |
189
- | Parse + replay throughput | 1938 ms/i | 816 ms/i | ~2.4x faster |
189
+ | Parse + replay throughput | 1938 ms/i | 534 ms/i | ~3.6x faster |
190
190
 
191
191
  What changed to get there:
192
192
 
@@ -216,6 +216,29 @@ What changed to get there:
216
216
  tuple, so the parser hot path now allocates only the single `[type, value]`
217
217
  array Racc requires per token. Cuts parse allocations ~42% (603537 → 347037
218
218
  objects for 500 games).
219
+ 13. `PGN::MoveCalculator#valid_square?` — integer bounds (`file >= 0 && file < 8`)
220
+ instead of `(0..7).include?` (≈3.4x faster per call, zero-alloc, in the
221
+ board-scan inner loops); `#compute_origin` — string `case` dispatch instead
222
+ of regex `/[brq]/i` matches; `#first_piece` — returns only the `[file, rank]`
223
+ square via a `piece_at` helper instead of a `[piece, square]` tuple. Replay
224
+ throughput +8.2% (766 → 741 µs/i), replay allocations −8% (1710 → 1571
225
+ objects). Board-scanning origin lookup is ~46% of replay CPU; the
226
+ piece-location-index rewrite that would cut it remains deferred.
227
+ 14. `PGN::Board` / `PGN::MoveCalculator` — 0x88 board representation. `Board`
228
+ internals are now a 128-cell array indexed by `rank*16+file` (the classic
229
+ 0x88 scheme), and `MoveCalculator` works entirely in single-integer square
230
+ indices via `Board#at_index`/`#apply!`, so the replay hot path no longer
231
+ allocates `[file,rank]` coordinate arrays or square-name strings.
232
+ Off-board is a single bitmask (`(idx & 0x88).zero?`, ~1.6x faster than a
233
+ 0..7 bounds check) and ray stepping is a single integer add. Algorithm
234
+ unchanged → byte-identical output. Replay throughput +49% (798 → 517 µs/i),
235
+ replay allocations −38% (1571 → 976 objects) / −33% (92440 → 62064 bytes),
236
+ parse+replay +21% throughput. A companion piece-location index (piece →
237
+ 0x88 indices for O(1) origin/king lookups) was implemented on top, passed
238
+ all specs, but *regressed* (replay 526→727 µs/i, allocations +63%): `dup`
239
+ must clone the index every move and every move pays maintenance that pawns
240
+ (the common case, geometry-fixed origins) can't use — so it was rejected
241
+ and reverted. The 0x88 board alone is the winner.
219
242
 
220
243
  Public output (FEN, PGN) is byte-identical to the original gem; the full
221
244
  suite (182 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
data/TODO.md CHANGED
@@ -15,21 +15,33 @@
15
15
  `scan_one`'s `[type, m, discarded]` tuple to a single returned string
16
16
  (type/discarded stashed in ivars). The full `Token` is kept only for the
17
17
  `#tokens` spec helper. Parse allocations −42% (603537 → 347037 / 500 games).
18
- - Speed up replay via a board-representation rewrite (deferred "Approach B"):
19
- per-line profiling shows the remaining replay allocations are architectural
20
- `MoveCalculator#first_piece` scan-return arrays (~5/ply, the #1 site) and
21
- `Board#position_for` string joins (~3/ply). Do these as ONE coherent
22
- rewrite (not separately, to avoid throwing away work):
23
- (a) a piece-location index (piece squares) so king/disambiguation/origin
24
- lookups are O(1) instead of scanning 64 squares kills `first_piece` scan
25
- arrays AND the dominant replay compute (`valid_square?`/`at` 15/ply calls);
26
- (b) a coordinate-only internal board (int square keys, no `"e4"` strings on
27
- the hot path) kills `position_for` strings + `changes` string keys.
28
- Caveat: `change!`/`update`/`position_for`/`coordinates_for`/`squares` are
29
- spec-tested public API, so the new representation must be additive (string
30
- API kept). Realistic ceiling ~2× replay allocation + ~1.5–2× throughput;
31
- medium-high risk (Board/MoveCalculator/Position/FEN). Only worth it given a
32
- real hot-loop need (replay is already ~0.8 ms/ply).
18
+ - Speed up replay via a board-representation rewrite ("Approach B"): done.
19
+ (b) (done in 1.3.0) Rewrote `Board` internals to the classic 0x88
20
+ representation (128-cell array indexed by `rank*16+file`) and rewrote
21
+ `MoveCalculator` to work entirely in single-integer square indices via
22
+ `Board#at_index`/`#apply!`, so the replay hot path no longer allocates
23
+ `[file,rank]` coordinate arrays or square-name strings. Off-board is a
24
+ single bitmask (`(idx & 0x88).zero?`, ~1.6x faster than a 0..7 bounds
25
+ check) and ray stepping is a single integer add. Algorithm unchanged, so
26
+ output is byte-identical. Measured (immortal game): replay 798→535 µs/i
27
+ (+49% throughput), allocations 1571→976 objects (−38%) / 92440→62064 bytes
28
+ (−33%); parse+replay +21% throughput. 182 specs green, 0 new rubocop
29
+ offenses vs main. The public string/coord API is preserved (additive).
30
+ (a) ✗ (attempted, rejected) A piece-location index (piece 0x88 indices)
31
+ maintained in `update`/`apply!` and used for O(1) slider/leaper/king
32
+ origin lookups. Implemented on top of (b), all 182 specs green, but it
33
+ **regressed**: replay 526→727 µs/i (+38% slower), allocations 976→1591
34
+ objects (+63%). Root cause: `Board#dup` (called every move) must clone
35
+ the index (`transform_values(&:dup)` ≈ 12 piece arrays) — Board#dup went
36
+ 91→676 objects — and every move pays per-update index maintenance
37
+ (`<<`/`delete`) that pawns (the most common move type, whose origins are
38
+ geometry-fixed and can't use the index) pay for no benefit. The index
39
+ helps sliders/leapers (minority of moves) but the dup + maintenance cost
40
+ is paid by every move. Conclusion: a global piece index is a loss for
41
+ replay (where only ONE given move is validated, so ray-scanning from the
42
+ destination is already cheap); it pays in move-*generation* libraries
43
+ (chess.js/python-chess) that enumerate ALL legal moves. Not worth a COW
44
+ variant either (maintenance + pawns). Reverted; (b) alone is the winner.
33
45
  - Replace the right-recursive `tag_section`/`variation_list` rules in
34
46
  `pgn_parser.y` with ordinary left-recursion plus one explicit `.reverse`
35
47
  at the point each list is consumed, so the legacy whittle-order
@@ -1,12 +1,12 @@
1
1
  Workload: immortal game, 45 plies
2
2
 
3
3
  === 1. Replay allocations (45 plies, no parse) ===
4
- total_allocated objects: 1710
5
- total_allocated bytes: 103760
4
+ total_allocated objects: 976
5
+ total_allocated bytes: 62064
6
6
 
7
7
  === 2. Board#dup x45 (target of flat-board COW) ===
8
- total_allocated objects: 136
9
- total_allocated bytes: 10336
8
+ total_allocated objects: 91
9
+ total_allocated bytes: 3856
10
10
 
11
11
  === 3. Board#at(str) x1000 (target of coord-arithmetic at) ===
12
12
  total_allocated objects: 0
@@ -15,8 +15,8 @@ total_allocated bytes: 0
15
15
  === 4. Replay throughput (ips, excluding parse) ===
16
16
  ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
17
17
  Warming up --------------------------------------
18
- replay immortal 112.000 i/100ms
18
+ replay immortal 193.000 i/100ms
19
19
  Calculating -------------------------------------
20
- replay immortal 1.178k4.6%) i/s (848.64 μs/i) - 5.936k in 5.037516s
20
+ replay immortal 1.894k2.9%) i/s (528.02 μs/i) - 9.650k in 5.095389s
21
21
 
22
22
  Done. Compare this file against bench/baseline_moves.txt after optimizations.
@@ -5,21 +5,21 @@ total_allocated objects: 347037
5
5
  total_allocated bytes: 17977414
6
6
 
7
7
  === 2. Parse + replay allocations (500 games) ===
8
- total_allocated objects: 1170586
9
- total_allocated bytes: 67804136
8
+ total_allocated objects: 831530
9
+ total_allocated bytes: 47966112
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.086 (± 0.0%) i/s (244.74 ms/i) - 21.000 in 5.139596s
16
+ parse 500 games 4.053 (± 0.0%) i/s (246.70 ms/i) - 21.000 in 5.180770s
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 1.472 (± 0.0%) i/s (679.56 ms/i) - 8.000 in 5.436470s
23
+ parse+replay 500 games 1.872 (± 0.0%) i/s (534.26 ms/i) - 10.000 in 5.342615s
24
24
 
25
25
  Done. Compare this file against bench/baseline_parse.txt after optimizations.
data/lib/pgn/board.rb CHANGED
@@ -50,7 +50,17 @@ module PGN
50
50
  nil => '_'
51
51
  }.freeze
52
52
 
53
- attr_accessor :squares
53
+ # 0x88 board representation (see chess.js / the classic 0x88 move-generation
54
+ # algorithm). A square is addressed by a single integer index
55
+ # `rank * 16 + file`; the extra files/ranks make off-board detection a
56
+ # single bitmask test -- `(idx & 0x88) != 0` -- which is faster than the
57
+ # four-integer comparison a 0..7 bounds check needs, and lets ray
58
+ # stepping be a single integer add. The public `squares` 8x8 API is built
59
+ # from this array on demand (it is off the replay hot path), and the
60
+ # MoveCalculator hot path works entirely in integer indices.
61
+ #
62
+ # file = idx & 0x0F (0..7)
63
+ # rank = idx >> 4 (0..7)
54
64
 
55
65
  # @return [PGN::Board] a board in the starting position
56
66
  #
@@ -75,7 +85,24 @@ module PGN
75
85
  #
76
86
  def initialize(squares)
77
87
  self.squares = squares
78
- @owned = Array.new(8, false)
88
+ end
89
+
90
+ # @return [Array<Array<String>>] the board as a file-major 8x8 array
91
+ # (squares[file][rank]). Built on demand from the 0x88 array; equality
92
+ # with the START constant and other boards is preserved.
93
+ #
94
+ def squares
95
+ (0..7).map { |f| (0..7).map { |r| @cells[(r * 16) + f] } }
96
+ end
97
+
98
+ def squares=(squares)
99
+ @cells = Array.new(128)
100
+ 8.times do |f|
101
+ 8.times do |r|
102
+ @cells[(r * 16) + f] = squares[f][r]
103
+ end
104
+ end
105
+ @cells
79
106
  end
80
107
 
81
108
  # @overload at(str)
@@ -91,12 +118,10 @@ module PGN
91
118
  # board.at(4,3) #=> "P"
92
119
  # board.at("e4") #=> "P"
93
120
  #
94
- # String squares are parsed with getbyte arithmetic (a=0x61, '1'=0x31)
95
- # so the common string lookup allocates nothing.
96
121
  def at(arg0, arg1 = nil)
97
- return squares[arg0][arg1] unless arg1.nil?
122
+ return @cells[(arg1 * 16) + arg0] unless arg1.nil?
98
123
 
99
- squares[file_of(arg0)][rank_of(arg0)]
124
+ @cells[(rank_of(arg0) * 16) + file_of(arg0)]
100
125
  end
101
126
 
102
127
  # @param changes [Hash<String, <String, nil>>] changes to make to the board
@@ -105,9 +130,7 @@ module PGN
105
130
  # board.change!({"e2" => nil, "e4" => "P"})
106
131
  #
107
132
  def change!(changes)
108
- changes.each do |square, piece|
109
- update(square, piece)
110
- end
133
+ changes.each { |square, piece| update(square, piece) }
111
134
  self
112
135
  end
113
136
 
@@ -117,16 +140,8 @@ module PGN
117
140
  # @example
118
141
  # board.update("e4", "P")
119
142
  #
120
- # Copy-on-write: clone only the column being mutated, and only once per
121
- # instance, so unchanged columns stay shared with any board this one was
122
- # duped from.
123
143
  def update(square, piece)
124
- file = file_of(square)
125
- unless @owned[file]
126
- squares[file] = squares[file].dup
127
- @owned[file] = true
128
- end
129
- squares[file][rank_of(square)] = piece
144
+ @cells[(rank_of(square) * 16) + file_of(square)] = piece
130
145
  self
131
146
  end
132
147
 
@@ -146,9 +161,7 @@ module PGN
146
161
  #
147
162
  def position_for(coordinates)
148
163
  file, rank = coordinates
149
- file_chr = INDEX_TO_FILE[file]
150
- rank_chr = INDEX_TO_RANK[rank]
151
- [file_chr, rank_chr].join('')
164
+ INDEX_TO_FILE[file] + INDEX_TO_RANK[rank]
152
165
  end
153
166
 
154
167
  # @return [String] the board in human readable format with unicode
@@ -160,12 +173,66 @@ module PGN
160
173
  end.join("\n")
161
174
  end
162
175
 
163
- # @return [PGN::Board] a copy of self. The outer array is copied; the
164
- # 8 column arrays are shared and cloned lazily by #update on first
165
- # mutation (copy-on-write).
176
+ # @return [PGN::Board] a copy of self. Copies the 128-cell 0x88 array;
177
+ # mutations to the copy do not affect the original.
166
178
  #
167
179
  def dup
168
- PGN::Board.new(squares.dup)
180
+ copy = PGN::Board.allocate
181
+ copy.instance_variable_set(:@cells, @cells.dup)
182
+ copy
183
+ end
184
+
185
+ # -- 0x88 hot-path API (integer indices) ---------------------------------
186
+
187
+ # The 0x88 index of an algebraic square name.
188
+ #
189
+ # @param square [String] e.g. "e4"
190
+ # @return [Integer] idx = rank * 16 + file
191
+ #
192
+ def index_of(square)
193
+ (rank_of(square) * 16) + file_of(square)
194
+ end
195
+
196
+ # The 0x88 index of zero-indexed file/rank coordinates.
197
+ #
198
+ # @return [Integer] idx = rank * 16 + file
199
+ #
200
+ def index_for(file, rank)
201
+ (rank * 16) + file
202
+ end
203
+
204
+ # Looks up a piece by 0x88 index. The caller is responsible for having
205
+ # already verified the index is on-board (`(idx & 0x88).zero?`); reading
206
+ # an off-board index simply returns nil.
207
+ #
208
+ # @param idx [Integer] a 0x88 square index
209
+ # @return [String, nil] the piece on that square
210
+ #
211
+ def at_index(idx)
212
+ @cells[idx]
213
+ end
214
+
215
+ # Places a piece on a 0x88 index. Returns self.
216
+ #
217
+ # @param idx [Integer] a 0x88 square index
218
+ # @param piece [String, nil]
219
+ # @return [self]
220
+ #
221
+ def update_index(idx, piece)
222
+ @cells[idx] = piece
223
+ self
224
+ end
225
+
226
+ # Applies a batch of integer-indexed changes. The replay hot path uses
227
+ # this so it never allocates square-name strings or `[file, rank]`
228
+ # coordinate arrays.
229
+ #
230
+ # @param changes [Hash<Integer, <String, nil>>]
231
+ # @return [self]
232
+ #
233
+ def apply!(changes)
234
+ changes.each { |idx, piece| @cells[idx] = piece }
235
+ self
169
236
  end
170
237
 
171
238
  private
@@ -6,88 +6,70 @@ module PGN
6
6
  # the board need to be updated, new castling restrictions, the en passant
7
7
  # square and whether to update fullmove and halfmove counters.
8
8
  #
9
+ # Squares are addressed as 0x88 integer indices (see {PGN::Board}); this
10
+ # keeps the replay hot path free of `[file, rank]` coordinate arrays and
11
+ # square-name string allocations. The public {#origin} reader still returns
12
+ # an algebraic square string for API compatibility.
13
+ #
9
14
  # @!attribute board
10
15
  # @return [PGN::Board] the current board
11
16
  #
12
17
  # @!attribute move
13
18
  # @return [PGN::Move] the current move
14
19
  #
15
- # @!attribute origin
16
- # @return [String, nil] the origin square in SAN
17
- #
18
20
  class MoveCalculator
19
- # Specifies the movement of pieces who are allowed to move in a
20
- # given direction until they reach an obstacle or the end of the
21
- # board.
21
+ # 0x88 ray-step offsets for sliding pieces. A step is a single integer
22
+ # add; off-board is `(idx & 0x88) != 0`, which also catches file wraparound.
22
23
  #
23
- DIRECTIONS = {
24
- 'b' => [[1, 1], [-1, 1], [-1, -1], [1, -1]],
25
- 'r' => [[-1, 0], [1, 0], [0, -1], [0, 1]],
26
- 'q' => [[1, 1], [-1, 1], [-1, -1], [1, -1],
27
- [-1, 0], [1, 0], [0, -1], [0, 1]]
24
+ SLIDE = {
25
+ 'b' => [-15, 15, -17, 17],
26
+ 'r' => [-1, 1, -16, 16],
27
+ 'q' => [-1, 1, -16, 16, -15, 15, -17, 17]
28
28
  }.freeze
29
29
 
30
- # Specifies the movement of pieces that have a limited set of moves
31
- # they are allowed to make.
30
+ # 0x88 single-step offsets for knight and king.
32
31
  #
33
- MOVES = {
34
- 'k' => [[-1, -1], [0, -1], [1, -1], [1, 0],
35
- [1, 1], [0, 1], [-1, 1], [-1, 0]],
36
- 'n' => [[-1, -2], [-1, 2], [1, -2], [1, 2],
37
- [-2, -1], [2, -1], [-2, 1], [2, 1]]
32
+ STEP = {
33
+ 'k' => [-1, 1, -16, 16, -15, 15, -17, 17],
34
+ 'n' => [33, 31, -31, -33, 18, 14, -14, -18]
38
35
  }.freeze
39
36
 
40
- # Specifies possible pawn movements. It may seem backwards since it is
41
- # used to compute the origin square and not the destination.
37
+ # Possible pawn origins, expressed as offsets from the destination square
38
+ # (pawn moves are computed backwards from where the pawn landed).
42
39
  #
43
- PAWN_MOVES = {
44
- 'P' => {
45
- capture: [[-1, -1], [1, -1]],
46
- normal: [[0, -1]],
47
- double: [[0, -2]]
48
- },
49
- 'p' => {
50
- capture: [[-1, 1], [1, 1]],
51
- normal: [[0, 1]],
52
- double: [[0, 2]]
53
- }
40
+ PAWN_OFFSETS = {
41
+ 'P' => { capture: [-17, -15], normal: [-16], double: [-32] },
42
+ 'p' => { capture: [15, 17], normal: [16], double: [32] }
54
43
  }.freeze
55
44
 
56
- # The squares to update for each possible castling move.
45
+ # The squares to update for each castling move, keyed by 0x88 index.
57
46
  #
58
47
  CASTLING = {
59
- 'Q' => {
60
- 'a1' => nil,
61
- 'c1' => 'K',
62
- 'd1' => 'R',
63
- 'e1' => nil
64
- },
65
- 'K' => {
66
- 'e1' => nil,
67
- 'f1' => 'R',
68
- 'g1' => 'K',
69
- 'h1' => nil
70
- },
71
- 'q' => {
72
- 'a8' => nil,
73
- 'c8' => 'k',
74
- 'd8' => 'r',
75
- 'e8' => nil
76
- },
77
- 'k' => {
78
- 'e8' => nil,
79
- 'f8' => 'r',
80
- 'g8' => 'k',
81
- 'h8' => nil
82
- }
48
+ 'Q' => { 0 => nil, 2 => 'K', 3 => 'R', 4 => nil },
49
+ 'K' => { 4 => nil, 5 => 'R', 6 => 'K', 7 => nil },
50
+ 'q' => { 112 => nil, 114 => 'k', 115 => 'r', 116 => nil },
51
+ 'k' => { 116 => nil, 117 => 'r', 118 => 'k', 119 => nil }
83
52
  }.freeze
84
53
 
85
- # Frozen rook-origin -> castling-restriction lookup, shared by both
86
- # white ('R') and black ('r') since their rook origins (a1/h1, a8/h8)
87
- # are distinct keys. Replaces a per-call hash literal.
88
- ROOK_RESTRICTIONS = { 'a1' => 'Q', 'h1' => 'K', 'a8' => 'q', 'h8' => 'k' }.freeze
54
+ # Corner-square 0x88 indices, used for castling-restriction bookkeeping
55
+ # (a rook leaving or being captured on a corner drops the matching right).
56
+ #
57
+ A1 = 0
58
+ H1 = 7
59
+ A8 = 112
60
+ H8 = 119
61
+
62
+ # rook-origin (0x88 index) -> castling restriction it drops.
63
+ #
64
+ ROOK_RESTRICTIONS = { A1 => 'Q', H1 => 'K', A8 => 'q', H8 => 'k' }.freeze
65
+
66
+ # Castling-move characters by side, for the "castling occurs" restriction.
67
+ # Frozen so {Array#include?} does not allocate per call.
68
+ #
69
+ WHITE_CASTLE = %w[K Q].freeze
70
+ BLACK_CASTLE = %w[k q].freeze
89
71
 
90
- attr_accessor :board, :move, :origin
72
+ attr_accessor :board, :move
91
73
 
92
74
  # @param board [PGN::Board] the current board
93
75
  # @param move [PGN::Move] the current move
@@ -95,14 +77,24 @@ module PGN
95
77
  def initialize(board, move)
96
78
  self.board = board
97
79
  self.move = move
98
- self.origin = compute_origin
80
+ @origin_idx = compute_origin
81
+ end
82
+
83
+ # @return [String, nil] the origin square in algebraic notation, for API
84
+ # compatibility. Internally the calculator works with the 0x88 index
85
+ # (see {#origin_idx}); this reader materialises the string on demand.
86
+ #
87
+ def origin
88
+ return nil if @origin_idx.nil?
89
+
90
+ board.position_for([@origin_idx & 0x0F, @origin_idx >> 4])
99
91
  end
100
92
 
101
93
  # @return [PGN::Board] the board after the move is made
102
94
  #
103
95
  def result_board
104
96
  new_board = board.dup
105
- new_board.change!(changes)
97
+ new_board.apply!(changes)
106
98
 
107
99
  new_board
108
100
  end
@@ -112,25 +104,28 @@ module PGN
112
104
  def castling_restrictions
113
105
  restrict = []
114
106
 
115
- # when a king or rook is moved
116
107
  case move.piece
117
108
  when 'K'
118
- restrict += %w[K Q]
109
+ restrict << 'K' << 'Q'
119
110
  when 'k'
120
- restrict += %w[k q]
111
+ restrict << 'k' << 'q'
121
112
  when 'R', 'r'
122
- restrict << ROOK_RESTRICTIONS[origin]
113
+ restrict << ROOK_RESTRICTIONS[@origin_idx]
123
114
  end
124
115
 
125
116
  # when castling occurs
126
- restrict += %w[K Q] if %w[K Q].include?(move.castle)
127
- restrict += %w[k q] if %w[k q].include?(move.castle)
117
+ if WHITE_CASTLE.include?(move.castle)
118
+ restrict << 'K' << 'Q'
119
+ elsif BLACK_CASTLE.include?(move.castle)
120
+ restrict << 'k' << 'q'
121
+ end
128
122
 
129
123
  # when a rook is taken
130
- restrict << 'Q' if move.destination == 'a1'
131
- restrict << 'q' if move.destination == 'a8'
132
- restrict << 'K' if move.destination == 'h1'
133
- restrict << 'k' if move.destination == 'h8'
124
+ dest = dest_idx
125
+ restrict << 'Q' if dest == A1
126
+ restrict << 'q' if dest == A8
127
+ restrict << 'K' if dest == H1
128
+ restrict << 'k' if dest == H8
134
129
 
135
130
  restrict.empty? ? restrict : restrict.compact.uniq
136
131
  end
@@ -151,35 +146,31 @@ module PGN
151
146
  #
152
147
  def en_passant_square
153
148
  return nil if move.castle
149
+ return nil unless move.pawn? && ((origin_rank - dest_rank).abs == 2)
154
150
 
155
- return unless move.pawn? && (origin[1].to_i - move.destination[1].to_i).abs == 2
156
-
157
- if move.white?
158
- "#{origin[0]}3"
159
- else
160
- "#{origin[0]}6"
161
- end
151
+ Board::INDEX_TO_FILE[origin_file] + (move.white? ? '3' : '6')
162
152
  end
163
153
 
164
154
  private
165
155
 
156
+ # The integer-indexed changes to apply to the board. Keys are 0x88
157
+ # indices, so no square-name strings are allocated on the hot path.
158
+ #
166
159
  def changes
167
160
  changes = {}
168
161
  changes.merge!(CASTLING[move.castle]) if move.castle
169
- changes.merge!(
170
- origin => nil,
171
- move.destination => move.piece,
172
- en_passant_capture => nil
173
- )
174
- changes[move.destination] = move.promotion if move.promotion
162
+ changes[@origin_idx] = nil
163
+ changes[dest_idx] = move.piece
164
+ changes[en_passant_capture] = nil
165
+ changes[dest_idx] = move.promotion if move.promotion
175
166
 
176
- changes.reject! { |key, _| key.nil? or key.empty? }
167
+ changes.reject! { |idx, _| idx.nil? }
177
168
 
178
169
  changes
179
170
  end
180
171
 
181
172
  # Using the current position and move, figure out where the piece
182
- # came from.
173
+ # came from (as a 0x88 index).
183
174
  #
184
175
  def compute_origin
185
176
  return nil if move.castle
@@ -194,58 +185,55 @@ module PGN
194
185
 
195
186
  possibilities = disambiguate(possibilities) if possibilities.length > 1
196
187
 
197
- board.position_for(possibilities.first)
188
+ possibilities.first
198
189
  end
199
190
 
200
- # From the destination square, move in each direction stopping if we
201
- # reach the end of the board. If we encounter a piece, add it to the
202
- # list of origin possibilities if it is the moving piece, or else
203
- # check the next direction.
191
+ # From the destination square, walk each slider direction until the first
192
+ # occupied square. If that piece is the moving piece, the square it sits
193
+ # on is a possible origin.
204
194
  #
205
195
  def direction_origins
206
- directions = DIRECTIONS[move.piece.downcase]
207
- possibilities = []
196
+ offsets = SLIDE[move.piece.downcase]
197
+ dest = dest_idx
208
198
 
209
- directions.each do |dir|
210
- square = first_piece(destination_coords, dir)
199
+ possibilities = []
200
+ offsets.each do |off|
201
+ square = first_piece(dest, off)
211
202
  possibilities << square if piece_at(square) == move.piece
212
203
  end
213
204
 
214
205
  possibilities
215
206
  end
216
207
 
217
- # From the destination square, make each move. If it is a valid
218
- # square and matches the moving piece, add it to the list of origin
219
- # possibilities.
208
+ # From the destination square, apply each single-step offset. If the
209
+ # target square is on the board and holds the moving piece, it is a
210
+ # possible origin.
220
211
  #
221
- def move_origins(moves = nil)
222
- moves ||= MOVES[move.piece.downcase]
223
- possibilities = []
224
- file, rank = destination_coords
212
+ def move_origins(offsets = STEP[move.piece.downcase])
213
+ dest = dest_idx
225
214
 
226
- moves.each do |i, j|
227
- f = file + i
228
- r = rank + j
215
+ possibilities = []
216
+ offsets.each do |off|
217
+ target = dest + off
218
+ next unless (target & 0x88).zero? # rubocop:disable Style/BitwisePredicate
229
219
 
230
- possibilities << [f, r] if valid_square?(f, r) && board.at(f, r) == move.piece
220
+ possibilities << target if board.at_index(target) == move.piece
231
221
  end
232
222
 
233
223
  possibilities
234
224
  end
235
225
 
236
- # Computes the possbile pawn origins based on the destination square
226
+ # Computes the possible pawn origins based on the destination square
237
227
  # and whether or not the move is a capture.
238
228
  #
239
229
  def pawn_origins
240
- _, rank = destination_coords
241
- double_rank = (rank == 3 && move.white?) || (rank == 4 && move.black?)
242
-
243
- pawn_moves = PAWN_MOVES[move.piece]
230
+ double = (dest_rank == 3 && move.white?) || (dest_rank == 4 && move.black?)
244
231
 
245
- moves = move.capture ? pawn_moves[:capture] : pawn_moves[:normal]
246
- moves += pawn_moves[:double] if double_rank
232
+ pawn_moves = PAWN_OFFSETS[move.piece]
233
+ offsets = move.capture ? pawn_moves[:capture] : pawn_moves[:normal]
234
+ offsets += pawn_moves[:double] if double
247
235
 
248
- move_origins(moves)
236
+ move_origins(offsets)
249
237
  end
250
238
 
251
239
  def disambiguate(possibilities)
@@ -259,36 +247,36 @@ module PGN
259
247
  # Try to disambiguate based on the standard algebraic notation.
260
248
  #
261
249
  def disambiguate_san(possibilities)
262
- if move.disambiguation
263
- possibilities.select { |p| board.position_for(p).match(move.disambiguation) }
264
- else
265
- possibilities
250
+ return possibilities unless move.disambiguation
251
+
252
+ possibilities.select do |idx|
253
+ board.position_for([idx & 0x0F, idx >> 4]).match(move.disambiguation)
266
254
  end
267
255
  end
268
256
 
269
- # A pawn can't move two spaces if there is a pawn in front of it.
257
+ # A pawn can't move two spaces if there is a pawn in front of it. A
258
+ # double-push origin sits on rank 2 (white) or 7 (black); reject those
259
+ # candidates when more than one pawn could have reached the destination.
270
260
  #
271
261
  def disambiguate_pawns(possibilities)
272
- if move.piece.match(/p/i) && !move.capture
273
- possibilities.reject { |p| board.position_for(p).match(/2|7/) }
274
- else
275
- possibilities
276
- end
262
+ return possibilities unless move.piece.match?(/p/i) && !move.capture
263
+
264
+ possibilities.reject { |idx| (idx >> 4) == 1 || (idx >> 4) == 6 }
277
265
  end
278
266
 
279
267
  # A piece can't move if it would result in a discovered check.
280
268
  #
281
269
  def disambiguate_discovered_check(possibilities)
282
- king_pos = king_position
270
+ king_idx = king_position
283
271
 
284
- DIRECTIONS.each do |attacking_piece, directions|
272
+ SLIDE.each do |attacking_piece, offsets|
285
273
  attacking_piece = attacking_piece.upcase if move.black?
286
274
 
287
- directions.each do |dir|
288
- square = first_piece(king_pos, dir)
275
+ offsets.each do |off|
276
+ square = first_piece(king_idx, off)
289
277
  next unless piece_at(square) == move.piece && possibilities.include?(square)
290
278
 
291
- next_square = first_piece(square, dir)
279
+ next_square = first_piece(square, off)
292
280
  possibilities.reject! { |p| p == square } if piece_at(next_square) == attacking_piece
293
281
  end
294
282
  end
@@ -296,62 +284,69 @@ module PGN
296
284
  possibilities
297
285
  end
298
286
 
299
- # Walks from `from` in `direction` until it reaches the edge of the board
300
- # or the first occupied square. Returns that square's `[file, rank]`
301
- # coordinates, or `nil` if no piece was encountered before the edge. The
302
- # caller reads the piece off the board itself, so this avoids allocating
303
- # the `[piece, square]` wrapper tuple per direction scan.
287
+ # Walks from `idx` in the 0x88 direction `off` until it reaches the edge
288
+ # of the board or the first occupied square. Returns that square's 0x88
289
+ # index, or nil if no piece was encountered before the edge.
304
290
  #
305
- def first_piece(from, direction)
306
- file, rank = from
307
- i, j = direction
291
+ def first_piece(idx, off)
292
+ idx += off
293
+ while (idx & 0x88).zero? # rubocop:disable Style/BitwisePredicate
294
+ square = board.at_index(idx)
295
+ return idx if square
308
296
 
309
- loop do
310
- file += i
311
- rank += j
312
- return nil if file.negative? || file > 7 || rank.negative? || rank > 7
313
-
314
- square = [file, rank]
315
- return square if board.at(file, rank)
297
+ idx += off
316
298
  end
299
+ nil
317
300
  end
318
301
 
319
- # Reads the piece on a square, tolerating a nil square (returned by
320
- # {#first_piece} when the scan ran off the edge). Kept as a helper so the
321
- # callers read the piece once instead of unpacking a `[piece, square]`
322
- # tuple per direction scan.
302
+ # Reads the piece at a 0x88 index, returning nil for an off-board (nil)
303
+ # index. Keeps {#disambiguate_discovered_check} within the configured
304
+ # complexity limits.
323
305
  #
324
- def piece_at(square)
325
- square && board.at(square[0], square[1])
306
+ def piece_at(idx)
307
+ idx && board.at_index(idx)
326
308
  end
327
309
 
328
- # If the move is a capture and there is no piece on the
329
- # destination square, it must be an en passant capture.
310
+ # If the move is a capture and there is no piece on the destination
311
+ # square, it must be an en passant capture. The captured pawn sits on the
312
+ # destination file and the moving pawn's origin rank.
330
313
  #
331
314
  def en_passant_capture
332
315
  return nil if move.castle
316
+ return nil unless move.capture && board.at_index(dest_idx).nil?
333
317
 
334
- move.destination[0] + origin[1] if !board.at(move.destination) && move.capture
318
+ (origin_rank * 16) + (dest_idx & 0x0F)
335
319
  end
336
320
 
337
321
  def king_position
338
322
  king = move.white? ? 'K' : 'k'
339
323
 
340
- 0.upto(7) do |file|
341
- 0.upto(7) do |rank|
342
- return [file, rank] if board.at(file, rank) == king
324
+ 0.upto(7) do |rank|
325
+ 0.upto(7) do |file|
326
+ idx = (rank * 16) + file
327
+ return idx if board.at_index(idx) == king
343
328
  end
344
329
  end
345
330
 
346
331
  nil
347
332
  end
348
333
 
349
- def valid_square?(file, rank)
350
- file >= 0 && file < 8 && rank >= 0 && rank < 8
334
+ # -- 0x88 index helpers --------------------------------------------------
335
+
336
+ def dest_idx
337
+ @dest_idx ||= move.destination && board.index_of(move.destination)
338
+ end
339
+
340
+ def origin_file
341
+ @origin_idx & 0x0F
342
+ end
343
+
344
+ def origin_rank
345
+ @origin_idx >> 4
351
346
  end
352
347
 
353
- def destination_coords
354
- @destination_coords ||= board.coordinates_for(move.destination)
348
+ def dest_rank
349
+ dest_idx >> 4
355
350
  end
356
351
  end
357
352
  end
data/lib/pgn/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module PGN
2
- VERSION = '1.2.1'.freeze
2
+ VERSION = '1.3.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgn2
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stacey Touset