pgn2 1.1.0 → 1.2.1
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 +52 -0
- data/README.md +11 -6
- data/TODO.md +33 -4
- data/bench/IMPROVEMENTS.md +24 -0
- data/bench/baseline_parse.txt +6 -6
- data/lib/pgn/game.rb +30 -30
- data/lib/pgn/lexer.rb +22 -25
- data/lib/pgn/move_calculator.rb +34 -20
- data/lib/pgn/pgn_parser.rb +12 -5
- data/lib/pgn/pgn_parser.y +12 -5
- data/lib/pgn/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 963f48df8fbfe5d69097030a9434a05b82e211c235772273f03bd11178afb8a5
|
|
4
|
+
data.tar.gz: 6bc38e0387e7061e3649fe52d22e5f01dabdc7029f8c27294d56b62a3779a76c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e81b88377f5d1ab6102a5b15d4d006feb4201647125b5b3d434b826dfa55cac60c82048fc8d4d2a4c8aa7d1600d6ba4edd472f27d063d258703d8e225c0832cc
|
|
7
|
+
data.tar.gz: 06cb00152b81f2124749fbc743356a25a7ee44e049c7779fb397660a84ebfefee8afcf4679f86ac633ff12b771db007123308cd5c65520ae8329e7334b340ab9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.1 (2026-08-13)
|
|
4
|
+
|
|
5
|
+
### Summary
|
|
6
|
+
|
|
7
|
+
Replay (move-application) throughput quick win on the `MoveCalculator`
|
|
8
|
+
hot path. No public API changes; serialized PGN/FEN output stays
|
|
9
|
+
byte-identical. See `bench/IMPROVEMENTS.md` for the per-step deltas.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **`MoveCalculator#valid_square?`**: integer compares (`file >= 0 &&
|
|
13
|
+
file < 8`) instead of `(0..7).include?(file)`, which both allocates a
|
|
14
|
+
`Range` per call and is ~3.4x slower. This predicate sits inside the
|
|
15
|
+
`first_piece` / `move_origins` / `king_position` inner board-scanning
|
|
16
|
+
loops — the dominant replay compute site (~46% of replay CPU via
|
|
17
|
+
per-method timing, not the SAN regex parse as had been assumed).
|
|
18
|
+
- **`MoveCalculator#compute_origin`**: string `case` (`when 'B','R','Q',...`)
|
|
19
|
+
instead of `/[brq]/i` regex `===` matches, removing 4 `MatchData`
|
|
20
|
+
allocations per move.
|
|
21
|
+
- **`MoveCalculator#first_piece`**: returns only the `[file, rank]` square
|
|
22
|
+
(`nil` when the scan runs off the edge) instead of a `[piece, square]`
|
|
23
|
+
wrapper tuple; callers read the piece via a new `piece_at` helper. Removes
|
|
24
|
+
one array allocation per direction scan.
|
|
25
|
+
- Performance vs 1.2.0 (immortal game, 45 plies, fresh process each):
|
|
26
|
+
replay throughput 814.65 us/i -> 752.54 us/i (+8.2%); replay allocations
|
|
27
|
+
1709 -> 1571 objects (-138, -8.1%) and 103720 -> 92440 bytes (-10.9%).
|
|
28
|
+
182 specs pass; byte-identical FEN/PGN output; zero new rubocop offenses
|
|
29
|
+
vs 1.2.0. The piece-location-index rewrite (the real ~46% ceiling,
|
|
30
|
+
deferred "Approach B") is unchanged.
|
|
31
|
+
|
|
32
|
+
## 1.2.0 (2026-08-13)
|
|
33
|
+
|
|
34
|
+
### Summary
|
|
35
|
+
|
|
36
|
+
Parse allocation quick win: collapse `PGN::Lexer#scan_one`'s per-token
|
|
37
|
+
3-element tuple. No public API changes; serialized PGN/FEN output stays
|
|
38
|
+
byte-identical. See `bench/IMPROVEMENTS.md` for the per-step deltas.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- **Lexer hot path**: `PGN::Lexer#scan_one` now returns the matched string
|
|
42
|
+
directly (stashing its type/discarded flag in ivars) instead of a
|
|
43
|
+
3-element `[type, m, discarded]` tuple, so the parser path (`next_token_pair`)
|
|
44
|
+
allocates only the single `[type, value]` array Racc requires per token.
|
|
45
|
+
Selected by per-line allocation profiling (`scan_one`'s tuple was the #1
|
|
46
|
+
parse allocation site).
|
|
47
|
+
- **Deferred**: the coordinate-only-board + piece-location-index work was
|
|
48
|
+
profiled and deferred to a single coherent board-representation rewrite
|
|
49
|
+
(see TODO) — coordinate-board alone measured only ~1.25× replay at medium
|
|
50
|
+
risk and would likely be superseded by the piece-index rewrite.
|
|
51
|
+
- Performance vs 1.1.0: parse-only allocations −42% objects (603537 → 347037 /
|
|
52
|
+
500 games); parse+replay −18% objects (1427586 → 1170586). Replay path
|
|
53
|
+
unchanged. All 182 specs pass; racc-sync OK.
|
|
54
|
+
|
|
3
55
|
## 1.1.0 (2026-08-13)
|
|
4
56
|
|
|
5
57
|
### Summary
|
data/README.md
CHANGED
|
@@ -181,12 +181,12 @@ Parser — 500 immortal games (`bench/profile_parse.rb`):
|
|
|
181
181
|
|
|
182
182
|
| Metric | original `pgn` | pgn2 | Δ |
|
|
183
183
|
|---|---:|---:|---:|
|
|
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 |
|
|
184
|
+
| Parse-only allocations (objects) | 1248065 | 347037 | -901028 (-72.2%) |
|
|
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%) |
|
|
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 |
|
|
190
190
|
|
|
191
191
|
What changed to get there:
|
|
192
192
|
|
|
@@ -211,6 +211,11 @@ What changed to get there:
|
|
|
211
211
|
11. `PGN::MoveCalculator` — memoized `destination_coords`, frozen
|
|
212
212
|
`ROOK_RESTRICTIONS`, empty short-circuit in `castling_restrictions`;
|
|
213
213
|
`Move#pawn?` non-allocating.
|
|
214
|
+
12. `PGN::Lexer#scan_one` — returns the matched string directly (stashing
|
|
215
|
+
type/discarded in ivars) instead of a 3-element `[type, m, discarded]`
|
|
216
|
+
tuple, so the parser hot path now allocates only the single `[type, value]`
|
|
217
|
+
array Racc requires per token. Cuts parse allocations ~42% (603537 → 347037
|
|
218
|
+
objects for 500 games).
|
|
214
219
|
|
|
215
220
|
Public output (FEN, PGN) is byte-identical to the original gem; the full
|
|
216
221
|
suite (182 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
|
data/TODO.md
CHANGED
|
@@ -10,12 +10,41 @@
|
|
|
10
10
|
|
|
11
11
|
- Support converting a game to pgn format
|
|
12
12
|
- Speed up parsing
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
- ✓ (done in 1.2.0) Removed `PGN::Lexer`'s per-token `Token` Struct
|
|
14
|
+
allocation on the parser hot path (`next_token_pair`), and collapsed
|
|
15
|
+
`scan_one`'s `[type, m, discarded]` tuple to a single returned string
|
|
16
|
+
(type/discarded stashed in ivars). The full `Token` is kept only for the
|
|
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).
|
|
17
33
|
- Replace the right-recursive `tag_section`/`variation_list` rules in
|
|
18
34
|
`pgn_parser.y` with ordinary left-recursion plus one explicit `.reverse`
|
|
19
35
|
at the point each list is consumed, so the legacy whittle-order
|
|
20
36
|
compatibility quirk is a single greppable line instead of implicit in
|
|
21
37
|
recursion direction.
|
|
38
|
+
- Make `MoveText#clean_text` idempotent (or run it exactly once, at
|
|
39
|
+
construction) so `Game#moves=`/`#standardize_castling` doesn't need to
|
|
40
|
+
sniff a comment for leftover `{`/`}` to decide whether a MoveText is safe
|
|
41
|
+
to reuse as-is. The brace check is a bandaid for `clean_text` not fully
|
|
42
|
+
normalizing multi-line/nested comments in one pass; fixing that at the
|
|
43
|
+
source would let `moves=` reuse unconditionally.
|
|
44
|
+
- `MoveCalculator#destination_coords` memoizes into `@dest_coords` based on
|
|
45
|
+
`board`/`move`/`origin` never changing after `#initialize` — true today,
|
|
46
|
+
but only by convention, since `board`, `move`, `origin` are all public
|
|
47
|
+
`attr_accessor`s with no cache invalidation tied to their setters. If a
|
|
48
|
+
future caller ever mutates and reuses a `MoveCalculator` instance, this
|
|
49
|
+
memo goes stale silently. Either drop the public setters or invalidate
|
|
50
|
+
`@dest_coords` when they're used.
|
data/bench/IMPROVEMENTS.md
CHANGED
|
@@ -141,3 +141,27 @@ byte-identical (covered by `spec/lexer_spec.rb`, `spec/parser_explicit_spec.rb`,
|
|
|
141
141
|
and the fixture round-trip in `spec/game_spec.rb`). Racc parser is in sync with
|
|
142
142
|
`pgn_parser.y` (CI racc-sync check passes). No public API or serialized-output
|
|
143
143
|
changes.
|
|
144
|
+
|
|
145
|
+
## Token-array collapse (2026-08-13, v1.2.0)
|
|
146
|
+
|
|
147
|
+
Collapse `PGN::Lexer#scan_one`'s per-token 3-element `[type, m, discarded]` tuple.
|
|
148
|
+
`scan_one` now returns the matched string directly and stashes its type /
|
|
149
|
+
discarded flag in `@scan_type` / `@scan_discarded` ivars, so the parser hot path
|
|
150
|
+
(`next_token_pair`) allocates only the single `[type, value]` array Racc requires.
|
|
151
|
+
Selected by per-line allocation profiling: `scan_one`'s tuple was the #1 parse
|
|
152
|
+
allocation site. The coordinate-only-board + piece-location-index work was
|
|
153
|
+
profiled and **deferred** — the board rewrite is a larger design (see TODO), and
|
|
154
|
+
coordinate-board alone measured only ~1.25× replay at medium risk and would
|
|
155
|
+
likely be superseded by the piece-index rewrite.
|
|
156
|
+
|
|
157
|
+
### bench/profile_parse.rb (500 immortal games)
|
|
158
|
+
|
|
159
|
+
| Metric | BEFORE | AFTER | Δ |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| Parse-only allocations (objects) | 603537 | 347037 | -256500 (-42.5%) |
|
|
162
|
+
| Parse-only allocations (bytes) | 28257414 | 17977414 | -10280000 (-36.4%) |
|
|
163
|
+
| Parse + replay allocations (objects) | 1427586 | 1170586 | -257000 (-18.0%) |
|
|
164
|
+
| Parse + replay allocations (bytes) | 78104136 | 67804136 | -10300000 (-13.2%) |
|
|
165
|
+
|
|
166
|
+
Replay (`profile_moves.rb`) is unchanged (1709 objects / 103720 bytes) — the
|
|
167
|
+
lexer edit does not touch the replay path. All 182 specs pass; racc-sync OK.
|
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: 347037
|
|
5
|
+
total_allocated bytes: 17977414
|
|
6
6
|
|
|
7
7
|
=== 2. Parse + replay allocations (500 games) ===
|
|
8
|
-
total_allocated objects:
|
|
9
|
-
total_allocated bytes:
|
|
8
|
+
total_allocated objects: 1170586
|
|
9
|
+
total_allocated bytes: 67804136
|
|
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
|
|
16
|
+
parse 500 games 4.086 (± 0.0%) i/s (244.74 ms/i) - 21.000 in 5.139596s
|
|
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.
|
|
23
|
+
parse+replay 500 games 1.472 (± 0.0%) i/s (679.56 ms/i) - 8.000 in 5.436470s
|
|
24
24
|
|
|
25
25
|
Done. Compare this file against bench/baseline_parse.txt after optimizations.
|
data/lib/pgn/game.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'io/console'
|
|
2
4
|
|
|
3
5
|
module PGN
|
|
@@ -11,12 +13,12 @@ module PGN
|
|
|
11
13
|
@variations = variations
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
def ==(
|
|
15
|
-
to_s ==
|
|
16
|
+
def ==(other)
|
|
17
|
+
to_s == other.to_s
|
|
16
18
|
end
|
|
17
19
|
|
|
18
|
-
def eql?(
|
|
19
|
-
self ==
|
|
20
|
+
def eql?(other)
|
|
21
|
+
self == other
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def hash
|
|
@@ -31,6 +33,7 @@ module PGN
|
|
|
31
33
|
text&.gsub(/{(.*)}/, '\1')&.gsub(/\s+/, ' ')&.strip
|
|
32
34
|
end
|
|
33
35
|
end
|
|
36
|
+
|
|
34
37
|
# {PGN::Game} holds all of the information about a game. It is either
|
|
35
38
|
# the result of parsing a PGN file, or created by hand.
|
|
36
39
|
#
|
|
@@ -57,9 +60,9 @@ module PGN
|
|
|
57
60
|
attr_accessor :tags, :result, :pgn, :comment
|
|
58
61
|
attr_reader :moves
|
|
59
62
|
|
|
60
|
-
LEFT = /(a|\x1B\[D)\z
|
|
61
|
-
RIGHT = /(d|\x1B\[C)\z
|
|
62
|
-
EXIT = /(q|\x03)\z
|
|
63
|
+
LEFT = /(a|\x1B\[D)\z/
|
|
64
|
+
RIGHT = /(d|\x1B\[C)\z/
|
|
65
|
+
EXIT = /(q|\x03)\z/
|
|
63
66
|
|
|
64
67
|
# @param moves [Array<String>] a list of moves in SAN
|
|
65
68
|
# @param tags [Hash<String, String>] metadata about the game
|
|
@@ -77,28 +80,8 @@ module PGN
|
|
|
77
80
|
#
|
|
78
81
|
# Standardize castling moves to use O's instead of 0's
|
|
79
82
|
#
|
|
80
|
-
# Reuse-safety invariant: when an element is already a MoveText we reuse
|
|
81
|
-
# it directly (sharing one object between the parser's move tree and
|
|
82
|
-
# Game#moves) whenever its comment is already fully cleaned. This is safe
|
|
83
|
-
# because nothing mutates a MoveText after this assignment returns. We
|
|
84
|
-
# still re-wrap when the comment carries braces: the parser's single-pass
|
|
85
|
-
# clean_text leaves braces on multi-line/nested comments, and the second
|
|
86
|
-
# clean_text that MoveText.new applies here is load-bearing for those
|
|
87
|
-
# (matches the legacy whittle byte-output). Skipping it would change
|
|
88
|
-
# serialized comments.
|
|
89
83
|
def moves=(moves)
|
|
90
|
-
@moves =
|
|
91
|
-
moves.map do |m|
|
|
92
|
-
if m.is_a?(String)
|
|
93
|
-
MoveText.new(m.include?('0') ? m.gsub('0', 'O') : m)
|
|
94
|
-
elsif m.notation.include?('0')
|
|
95
|
-
MoveText.new(m.notation.gsub('0', 'O'), m.annotation, m.comment, m.variations)
|
|
96
|
-
elsif m.comment.nil? || !m.comment.include?('{')
|
|
97
|
-
m
|
|
98
|
-
else
|
|
99
|
-
MoveText.new(m.notation, m.annotation, m.comment, m.variations)
|
|
100
|
-
end
|
|
101
|
-
end
|
|
84
|
+
@moves = moves.map { |m| standardize_castling(m) }
|
|
102
85
|
end
|
|
103
86
|
|
|
104
87
|
# @return [String] a canonical PGN string for this game, ending with a
|
|
@@ -152,11 +135,11 @@ module PGN
|
|
|
152
135
|
loop do
|
|
153
136
|
puts "\e[H\e[2J"
|
|
154
137
|
puts positions[index].inspect
|
|
155
|
-
hist[0..2] = (hist[1..2] <<
|
|
138
|
+
hist[0..2] = (hist[1..2] << $stdin.getch)
|
|
156
139
|
|
|
157
140
|
case hist.join
|
|
158
141
|
when LEFT
|
|
159
|
-
index -= 1 if index
|
|
142
|
+
index -= 1 if index.positive?
|
|
160
143
|
when RIGHT
|
|
161
144
|
index += 1 if index < moves.length
|
|
162
145
|
when EXIT
|
|
@@ -164,5 +147,22 @@ module PGN
|
|
|
164
147
|
end
|
|
165
148
|
end
|
|
166
149
|
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# A MoveText is reused as-is (no new object) when its notation needs no
|
|
154
|
+
# '0'->'O' fix and its comment has no braces left to clean; a braced
|
|
155
|
+
# comment still needs MoveText.new's second clean_text pass to match the
|
|
156
|
+
# legacy whittle byte-output.
|
|
157
|
+
def standardize_castling(entry)
|
|
158
|
+
return MoveText.new(entry.include?('0') ? entry.gsub('0', 'O') : entry) if entry.is_a?(String)
|
|
159
|
+
|
|
160
|
+
notation = entry.notation.include?('0') ? entry.notation.gsub('0', 'O') : entry.notation
|
|
161
|
+
if notation.equal?(entry.notation) && (entry.comment.nil? || !entry.comment.include?('{'))
|
|
162
|
+
return entry
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
|
|
166
|
+
end
|
|
167
167
|
end
|
|
168
168
|
end
|
data/lib/pgn/lexer.rb
CHANGED
|
@@ -146,29 +146,18 @@ module PGN
|
|
|
146
146
|
|
|
147
147
|
# Returns the next {Token}, or +nil+ at end of input.
|
|
148
148
|
def next_token
|
|
149
|
-
type, value
|
|
149
|
+
type, value = next_token_pair
|
|
150
150
|
return nil unless type
|
|
151
|
-
|
|
151
|
+
|
|
152
|
+
Token.new(type: type, value: value, offset: @last_offset, line: @line)
|
|
152
153
|
end
|
|
153
154
|
|
|
154
155
|
# Fast path for the parser: returns [type, value] for the next
|
|
155
156
|
# non-discarded token, or +nil+ at end of input. Does not allocate a
|
|
156
|
-
# {Token} Struct
|
|
157
|
-
#
|
|
158
|
-
#
|
|
157
|
+
# {Token} Struct, and +scan_one+ returns the matched string directly
|
|
158
|
+
# (stashing its type/discarded flag in ivars) so the only array
|
|
159
|
+
# allocated per token is the [type, value] pair Racc requires.
|
|
159
160
|
def next_token_pair
|
|
160
|
-
type, value, = scan_next
|
|
161
|
-
return nil unless type
|
|
162
|
-
[type, value]
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
private
|
|
166
|
-
|
|
167
|
-
# Scan to the next non-discarded token and return [type, value, offset],
|
|
168
|
-
# or nil at end of input. Performs the exact +advance_line+ and
|
|
169
|
-
# +note_token+ side effects that +next_token+ historically did, so
|
|
170
|
-
# +game_starts+ (used for verbatim +Game#pgn+ slicing) stays correct.
|
|
171
|
-
def scan_next
|
|
172
161
|
until @ss.eos?
|
|
173
162
|
off = @ss.pos
|
|
174
163
|
|
|
@@ -176,25 +165,33 @@ module PGN
|
|
|
176
165
|
type, value = lit
|
|
177
166
|
@ss.pos = off + 1
|
|
178
167
|
note_token(type, off)
|
|
179
|
-
|
|
168
|
+
@last_offset = off
|
|
169
|
+
return [type, value]
|
|
180
170
|
end
|
|
181
171
|
|
|
182
|
-
|
|
172
|
+
value = scan_one
|
|
183
173
|
advance_line(value)
|
|
184
|
-
next if
|
|
174
|
+
next if @scan_discarded
|
|
185
175
|
|
|
186
|
-
note_token(
|
|
187
|
-
|
|
176
|
+
note_token(@scan_type, off)
|
|
177
|
+
@last_offset = off
|
|
178
|
+
return [@scan_type, value]
|
|
188
179
|
end
|
|
189
180
|
nil
|
|
190
181
|
end
|
|
191
182
|
|
|
192
|
-
|
|
193
|
-
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
# Try each terminal rule in order; return the matched string for the
|
|
186
|
+
# first match (stashing its type and discarded flag in +@scan_type+ /
|
|
187
|
+
# +@scan_discarded+ so the caller avoids allocating a 3-element tuple),
|
|
188
|
+
# or raise if nothing matches at the current position.
|
|
194
189
|
def scan_one
|
|
195
190
|
RULES.each do |(type, re, discarded)|
|
|
196
191
|
if (m = @ss.scan(re))
|
|
197
|
-
|
|
192
|
+
@scan_type = type
|
|
193
|
+
@scan_discarded = discarded
|
|
194
|
+
return m
|
|
198
195
|
end
|
|
199
196
|
end
|
|
200
197
|
raise UnconsumedInputError,
|
data/lib/pgn/move_calculator.rb
CHANGED
|
@@ -118,15 +118,13 @@ module PGN
|
|
|
118
118
|
restrict += %w[K Q]
|
|
119
119
|
when 'k'
|
|
120
120
|
restrict += %w[k q]
|
|
121
|
-
when 'R'
|
|
122
|
-
restrict << ROOK_RESTRICTIONS[origin]
|
|
123
|
-
when 'r'
|
|
121
|
+
when 'R', 'r'
|
|
124
122
|
restrict << ROOK_RESTRICTIONS[origin]
|
|
125
123
|
end
|
|
126
124
|
|
|
127
125
|
# when castling occurs
|
|
128
|
-
restrict += %w[K Q] if %w[K Q].include?
|
|
129
|
-
restrict += %w[k q] if %w[k q].include?
|
|
126
|
+
restrict += %w[K Q] if %w[K Q].include?(move.castle)
|
|
127
|
+
restrict += %w[k q] if %w[k q].include?(move.castle)
|
|
130
128
|
|
|
131
129
|
# when a rook is taken
|
|
132
130
|
restrict << 'Q' if move.destination == 'a1'
|
|
@@ -187,9 +185,9 @@ module PGN
|
|
|
187
185
|
return nil if move.castle
|
|
188
186
|
|
|
189
187
|
possibilities = case move.piece
|
|
190
|
-
when
|
|
191
|
-
when
|
|
192
|
-
when
|
|
188
|
+
when 'B', 'R', 'Q', 'b', 'r', 'q' then direction_origins
|
|
189
|
+
when 'K', 'N', 'k', 'n' then move_origins
|
|
190
|
+
when 'P', 'p' then pawn_origins
|
|
193
191
|
else # don't care move, used in variations
|
|
194
192
|
return nil
|
|
195
193
|
end
|
|
@@ -209,8 +207,8 @@ module PGN
|
|
|
209
207
|
possibilities = []
|
|
210
208
|
|
|
211
209
|
directions.each do |dir|
|
|
212
|
-
|
|
213
|
-
possibilities << square if
|
|
210
|
+
square = first_piece(destination_coords, dir)
|
|
211
|
+
possibilities << square if piece_at(square) == move.piece
|
|
214
212
|
end
|
|
215
213
|
|
|
216
214
|
possibilities
|
|
@@ -287,28 +285,44 @@ module PGN
|
|
|
287
285
|
attacking_piece = attacking_piece.upcase if move.black?
|
|
288
286
|
|
|
289
287
|
directions.each do |dir|
|
|
290
|
-
|
|
291
|
-
next unless
|
|
288
|
+
square = first_piece(king_pos, dir)
|
|
289
|
+
next unless piece_at(square) == move.piece && possibilities.include?(square)
|
|
292
290
|
|
|
293
|
-
|
|
294
|
-
possibilities.reject! { |p| p == square } if
|
|
291
|
+
next_square = first_piece(square, dir)
|
|
292
|
+
possibilities.reject! { |p| p == square } if piece_at(next_square) == attacking_piece
|
|
295
293
|
end
|
|
296
294
|
end
|
|
297
295
|
|
|
298
296
|
possibilities
|
|
299
297
|
end
|
|
300
298
|
|
|
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.
|
|
304
|
+
#
|
|
301
305
|
def first_piece(from, direction)
|
|
302
306
|
file, rank = from
|
|
303
307
|
i, j = direction
|
|
304
308
|
|
|
305
|
-
|
|
309
|
+
loop do
|
|
310
|
+
file += i
|
|
311
|
+
rank += j
|
|
312
|
+
return nil if file.negative? || file > 7 || rank.negative? || rank > 7
|
|
306
313
|
|
|
307
|
-
|
|
308
|
-
|
|
314
|
+
square = [file, rank]
|
|
315
|
+
return square if board.at(file, rank)
|
|
309
316
|
end
|
|
317
|
+
end
|
|
310
318
|
|
|
311
|
-
|
|
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.
|
|
323
|
+
#
|
|
324
|
+
def piece_at(square)
|
|
325
|
+
square && board.at(square[0], square[1])
|
|
312
326
|
end
|
|
313
327
|
|
|
314
328
|
# If the move is a capture and there is no piece on the
|
|
@@ -333,11 +347,11 @@ module PGN
|
|
|
333
347
|
end
|
|
334
348
|
|
|
335
349
|
def valid_square?(file, rank)
|
|
336
|
-
|
|
350
|
+
file >= 0 && file < 8 && rank >= 0 && rank < 8
|
|
337
351
|
end
|
|
338
352
|
|
|
339
353
|
def destination_coords
|
|
340
|
-
@
|
|
354
|
+
@destination_coords ||= board.coordinates_for(move.destination)
|
|
341
355
|
end
|
|
342
356
|
end
|
|
343
357
|
end
|
data/lib/pgn/pgn_parser.rb
CHANGED
|
@@ -27,13 +27,20 @@ module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 97)
|
|
|
27
27
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
|
-
# Punctuation tokens are racc'd by literal
|
|
31
|
-
# the
|
|
32
|
-
#
|
|
33
|
-
|
|
30
|
+
# Punctuation tokens are racc'd by their literal character (taken from
|
|
31
|
+
# PGN::Lexer::LITERAL_BYTES, the single source of truth for those
|
|
32
|
+
# characters); everything else is racc'd by the upcased lexer symbol.
|
|
33
|
+
# A complete frozen lookup keeps this a plain hash read for every token
|
|
34
|
+
# instead of allocating two Strings (`to_s` + `upcase`) per token.
|
|
35
|
+
TOKEN_TRANSLATIONS = PGN::Lexer::LITERAL_BYTES.values.to_h.merge(
|
|
36
|
+
string: :STRING, comment: :COMMENT, game_termination: :GAME_TERMINATION,
|
|
37
|
+
san_move: :SAN_MOVE, nag: :NAG, move_number: :MOVE_NUMBER, tag_name: :TAG_NAME
|
|
38
|
+
).freeze
|
|
34
39
|
|
|
35
40
|
def translate_type(sym)
|
|
36
|
-
|
|
41
|
+
TOKEN_TRANSLATIONS.fetch(sym) do
|
|
42
|
+
raise "PGN::Lexer produced an unknown token type: #{sym.inspect}"
|
|
43
|
+
end
|
|
37
44
|
end
|
|
38
45
|
|
|
39
46
|
# Slice the verbatim raw PGN text per game out of the original input using
|
data/lib/pgn/pgn_parser.y
CHANGED
|
@@ -112,13 +112,20 @@ rule
|
|
|
112
112
|
|
|
113
113
|
private
|
|
114
114
|
|
|
115
|
-
# Punctuation tokens are racc'd by literal
|
|
116
|
-
# the
|
|
117
|
-
#
|
|
118
|
-
|
|
115
|
+
# Punctuation tokens are racc'd by their literal character (taken from
|
|
116
|
+
# PGN::Lexer::LITERAL_BYTES, the single source of truth for those
|
|
117
|
+
# characters); everything else is racc'd by the upcased lexer symbol.
|
|
118
|
+
# A complete frozen lookup keeps this a plain hash read for every token
|
|
119
|
+
# instead of allocating two Strings (`to_s` + `upcase`) per token.
|
|
120
|
+
TOKEN_TRANSLATIONS = PGN::Lexer::LITERAL_BYTES.values.to_h.merge(
|
|
121
|
+
string: :STRING, comment: :COMMENT, game_termination: :GAME_TERMINATION,
|
|
122
|
+
san_move: :SAN_MOVE, nag: :NAG, move_number: :MOVE_NUMBER, tag_name: :TAG_NAME
|
|
123
|
+
).freeze
|
|
119
124
|
|
|
120
125
|
def translate_type(sym)
|
|
121
|
-
|
|
126
|
+
TOKEN_TRANSLATIONS.fetch(sym) do
|
|
127
|
+
raise "PGN::Lexer produced an unknown token type: #{sym.inspect}"
|
|
128
|
+
end
|
|
122
129
|
end
|
|
123
130
|
|
|
124
131
|
# Slice the verbatim raw PGN text per game out of the original input using
|
data/lib/pgn/version.rb
CHANGED