pgn2 2.0.1 → 2.0.3
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 +73 -0
- data/ext/pgn2_native/pgn2-bitboard/src/board.rs +2 -10
- data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +0 -2
- data/ext/pgn2_native/pgn2_native/src/lib.rs +5 -5
- data/lib/pgn/attack.rb +18 -8
- data/lib/pgn/epd.rb +3 -30
- data/lib/pgn/fen.rb +48 -45
- data/lib/pgn/game.rb +26 -15
- data/lib/pgn/node.rb +66 -91
- data/lib/pgn/pgn_parser.rb +77 -78
- data/lib/pgn/pgn_parser.y +37 -26
- data/lib/pgn/position.rb +3 -1
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +4 -0
- data/spec/game_spec.rb +0 -14
- data/spec/node_spec.rb +0 -18
- data/spec/parser_explicit_spec.rb +39 -0
- data/spec/spec_helper.rb +17 -0
- metadata +2 -3
- data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +0 -121
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 26cc37d3385d1f5516a2cff916f3219198914295b2ea83b587f1a8a6ee267480
|
|
4
|
+
data.tar.gz: 7efabc9471ace989a11a48879fb496f3999d35dbb42b3c8c138bdc392beeb597
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3622fadf7c7918121f2c3e6700d1bb4284d9a258d76f56a2b47d20813f7f837ad357ff45892e252da96b6c52694909241809f203b97872b5bdb3dbe5a2b33023
|
|
7
|
+
data.tar.gz: e3205c5033d63601f1a64e175e7e1799ab9c698e736d216e15408c4eb358851ba8bf415139bf66780dbc5bd559fde6468ca06cf81a29df056d50c6ba52f9b6b9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.3 (2026-08-20)
|
|
4
|
+
|
|
5
|
+
### Summary
|
|
6
|
+
|
|
7
|
+
Parser fix: a move may now be followed by any number of comments, NAGs,
|
|
8
|
+
and variations, in any order (PGN spec §3.2.5). Previously the grammar
|
|
9
|
+
accepted only a single trailing comment before a variation, so common
|
|
10
|
+
real-world annotations like `1... Kxh7 {a} {[%cal ...]} (20... Kf8)` raised
|
|
11
|
+
`parse error on value "("` and whole files were skipped. No public API
|
|
12
|
+
changes; `MoveText#comment` and `#variations` keep their existing shapes.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **`PGN::PgnParser` (grammar)** — replace the single-comment
|
|
17
|
+
`move_trailer` + separate `variation_list` with a `move_suffix` list of
|
|
18
|
+
`[:comment, c] | [:nags, a] | [:variation, v]` items folded onto the
|
|
19
|
+
move by the new `build_move` helper. Multiple comments on one move are
|
|
20
|
+
concatenated with a space (each cleaned individually); variations keep
|
|
21
|
+
the legacy reversed storage order so round-trip and serializer behavior
|
|
22
|
+
are unchanged.
|
|
23
|
+
- **`PGN::MoveText.clean_text`** — extracted as a class method so the
|
|
24
|
+
parser can clean each trailing comment before merging; the instance
|
|
25
|
+
method delegates to it (behavior unchanged).
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Two (or more) comments before a variation no longer abort parsing.
|
|
30
|
+
- A comment appearing after a variation now attaches to the move it
|
|
31
|
+
follows instead of becoming a stray game comment.
|
|
32
|
+
- `PGN.parse` no longer mutates the caller's input string (it dups before
|
|
33
|
+
`force_encoding`), so frozen-string-literal callers no longer raise
|
|
34
|
+
FrozenError on the target Ruby.
|
|
35
|
+
|
|
36
|
+
### Known issues
|
|
37
|
+
|
|
38
|
+
- A lone backslash inside a comment that is not part of `\\`, `\{`, or
|
|
39
|
+
`\}` (e.g. `{/\Ba3#}`) still raises an "Unmatched input" lexer error;
|
|
40
|
+
the comment escape grammar remains stricter than the PGN spec.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 2.0.2 (2026-08-18)
|
|
45
|
+
|
|
46
|
+
### Summary
|
|
47
|
+
|
|
48
|
+
Internal refactor and performance pass — no public API changes. The
|
|
49
|
+
`pgn2-bitboard` adapter drops its hand-rolled `Move`/`MoveList`/`uci_parse`
|
|
50
|
+
layer in favor of `chessie`'s own `MoveList` and `Move: PartialEq<str>`
|
|
51
|
+
(UCI comparison), and the Ruby side DRYs shared FEN/EPD parsing, memoizes
|
|
52
|
+
`Position#in_check?`, and maintains `Game`'s position cache incrementally.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **`ext/pgn2_native`** — remove `pgn2-bitboard/src/moves.rs`;
|
|
57
|
+
`Board#legal_moves` returns `chessie::MoveList` directly and
|
|
58
|
+
`Engine#legal?` compares via `chessie::Move`'s `PartialEq<str>` (by
|
|
59
|
+
`to_uci()`), eliminating the separate UCI parser.
|
|
60
|
+
- **`PGN::PositionFields`** — shared FEN/EPD module for board-string and
|
|
61
|
+
castling/en-passant field parsing; `FEN` and `EPD` now include it instead
|
|
62
|
+
of duplicating the logic.
|
|
63
|
+
- **`PGN::Attack.attacked?`** — short-circuits pawn/knight/king checks before
|
|
64
|
+
the slider ray-walk (cheapest first), matching the previous result.
|
|
65
|
+
- **`PGN::Position#in_check?`** — memoizes its result on first call.
|
|
66
|
+
- **`PGN::Game`** — `push`/`pop` now grow/shrink the memoized `@positions`
|
|
67
|
+
list in step rather than invalidating it; `threefold?` reuses `#positions`.
|
|
68
|
+
- **`PGN::MoveText.normalize_castling`** — castling `0`→`O` normalization
|
|
69
|
+
extracted as a class method (was a private `Node` helper).
|
|
70
|
+
- **`PGN::Node`** — `promote`/`demote`/`promote_to_main`/`demote_to_last`/
|
|
71
|
+
`delete` share a `mutate_sibling` prologue/epilogue and a `splice_line!`
|
|
72
|
+
helper; behavior unchanged.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
3
76
|
## 2.0.1 (2026-08-15)
|
|
4
77
|
|
|
5
78
|
### Summary
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
use crate::moves::{Move, MoveList};
|
|
2
|
-
|
|
3
1
|
/// A chess position backed by `chessie::Game`. Holds no chess logic of
|
|
4
2
|
/// its own; every operation delegates. `Copy` because `chessie::Game`
|
|
5
3
|
/// is `Copy`; `Default` because the magnus `Engine` wraps it in a
|
|
@@ -20,13 +18,7 @@ impl Board {
|
|
|
20
18
|
self.game.perft(depth as usize)
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
pub fn legal_moves(&self) -> MoveList {
|
|
24
|
-
|
|
25
|
-
.game
|
|
26
|
-
.get_legal_moves()
|
|
27
|
-
.into_iter()
|
|
28
|
-
.map(Move::from_chessie)
|
|
29
|
-
.collect();
|
|
30
|
-
MoveList(moves)
|
|
21
|
+
pub fn legal_moves(&self) -> chessie::MoveList {
|
|
22
|
+
self.game.get_legal_moves()
|
|
31
23
|
}
|
|
32
24
|
}
|
|
@@ -32,10 +32,10 @@ impl Engine {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
fn legal_p(&self, uci: String) -> bool {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
// `chessie::Move`'s `PartialEq<str>` compares by `to_uci()`, so this
|
|
36
|
+
// matches on castle/promotion notation the same way `legal_moves_ruby`
|
|
37
|
+
// renders it — no separate UCI parser needed.
|
|
38
|
+
self.0.borrow().legal_moves().iter().any(|m| m == &uci)
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -49,6 +49,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
49
49
|
engine.define_method("initialize", method!(Engine::initialize, 1))?;
|
|
50
50
|
engine.define_method("perft", method!(Engine::perft, 1))?;
|
|
51
51
|
engine.define_method("legal_moves", method!(Engine::legal_moves_ruby, 0))?;
|
|
52
|
-
engine.define_method("legal?", method!(Engine::legal_p, 1))
|
|
52
|
+
engine.define_method("legal?", method!(Engine::legal_p, 1))?;
|
|
53
53
|
Ok(())
|
|
54
54
|
}
|
data/lib/pgn/attack.rb
CHANGED
|
@@ -14,7 +14,7 @@ module PGN
|
|
|
14
14
|
|
|
15
15
|
# The 0x88 index of the `color` king on +board+, or nil if absent.
|
|
16
16
|
def self.king_idx(board, color)
|
|
17
|
-
king =
|
|
17
|
+
king = piece_letter('K', color)
|
|
18
18
|
(0...128).each do |idx|
|
|
19
19
|
next if idx.anybits?(0x88)
|
|
20
20
|
|
|
@@ -24,8 +24,13 @@ module PGN
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Whether +target+ (a 0x88 index) is attacked by any `color` piece.
|
|
27
|
+
# Checked (and short-circuited) piece type by piece type, cheapest first,
|
|
28
|
+
# so a hit skips the pricier ray-walk sliders scan entirely.
|
|
27
29
|
def self.attacked?(board, target, color)
|
|
28
|
-
|
|
30
|
+
pawn_attackers(board, target, color).any? ||
|
|
31
|
+
knight_attackers(board, target, color).any? ||
|
|
32
|
+
king_attackers(board, target, color).any? ||
|
|
33
|
+
slider_attackers(board, target, color).any?
|
|
29
34
|
end
|
|
30
35
|
|
|
31
36
|
# The algebraic squares of every `color` piece on +board+ that attacks
|
|
@@ -40,9 +45,14 @@ module PGN
|
|
|
40
45
|
class << self
|
|
41
46
|
private
|
|
42
47
|
|
|
48
|
+
# 'w' -> the uppercase letter, 'b' -> the lowercase letter.
|
|
49
|
+
def piece_letter(letter, color)
|
|
50
|
+
color == 'w' ? letter : letter.downcase
|
|
51
|
+
end
|
|
52
|
+
|
|
43
53
|
def pawn_attackers(board, target, color)
|
|
44
54
|
offs = color == 'w' ? [-15, -17] : [15, 17]
|
|
45
|
-
pawn =
|
|
55
|
+
pawn = piece_letter('P', color)
|
|
46
56
|
offs.each_with_object([]) do |off, a|
|
|
47
57
|
i = target + off
|
|
48
58
|
a << board.square_name(i) if i.nobits?(0x88) && board.at_index(i) == pawn
|
|
@@ -50,23 +60,23 @@ module PGN
|
|
|
50
60
|
end
|
|
51
61
|
|
|
52
62
|
def knight_attackers(board, target, color)
|
|
53
|
-
knight =
|
|
63
|
+
knight = piece_letter('N', color)
|
|
54
64
|
Board::KNIGHT_ATTACKS[target].each_with_object([]) do |i, a|
|
|
55
65
|
a << board.square_name(i) if board.at_index(i) == knight
|
|
56
66
|
end
|
|
57
67
|
end
|
|
58
68
|
|
|
59
69
|
def king_attackers(board, target, color)
|
|
60
|
-
king =
|
|
70
|
+
king = piece_letter('K', color)
|
|
61
71
|
Board::KING_ATTACKS[target].each_with_object([]) do |i, a|
|
|
62
72
|
a << board.square_name(i) if board.at_index(i) == king
|
|
63
73
|
end
|
|
64
74
|
end
|
|
65
75
|
|
|
66
76
|
def slider_attackers(board, target, color)
|
|
67
|
-
bishop =
|
|
68
|
-
rook =
|
|
69
|
-
queen =
|
|
77
|
+
bishop = piece_letter('B', color)
|
|
78
|
+
rook = piece_letter('R', color)
|
|
79
|
+
queen = piece_letter('Q', color)
|
|
70
80
|
squares = []
|
|
71
81
|
ray_attackers(board, target, BISHOP_DIRS) do |piece, sq|
|
|
72
82
|
squares << sq if piece == bishop || piece == queen
|
data/lib/pgn/epd.rb
CHANGED
|
@@ -14,6 +14,8 @@ module PGN
|
|
|
14
14
|
# PGN::FEN.start.to_epd #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -"
|
|
15
15
|
#
|
|
16
16
|
class EPD
|
|
17
|
+
include PositionFields
|
|
18
|
+
|
|
17
19
|
attr_accessor :board, :active, :ops
|
|
18
20
|
attr_reader :castling, :en_passant
|
|
19
21
|
|
|
@@ -31,40 +33,11 @@ module PGN
|
|
|
31
33
|
self.ops = fields[4]
|
|
32
34
|
end
|
|
33
35
|
|
|
34
|
-
def castling=(val)
|
|
35
|
-
@castling = val.nil? || val.empty? ? '-' : val
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def en_passant=(val)
|
|
39
|
-
@en_passant = val.nil? ? '-' : val
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# @param board_fen [String] the FEN/EPD representation of the board
|
|
43
|
-
#
|
|
44
|
-
def board_string=(board_fen)
|
|
45
|
-
squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
|
|
46
|
-
.split('/')
|
|
47
|
-
.map(&:chars)
|
|
48
|
-
.map { |row| row.map { |e| e == '_' ? nil : e } }
|
|
49
|
-
.reverse
|
|
50
|
-
.transpose
|
|
51
|
-
self.board = PGN::Board.new(squares)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# @return [String] the EPD board-string portion
|
|
55
|
-
#
|
|
56
|
-
def board_string
|
|
57
|
-
board.fen_board_string
|
|
58
|
-
end
|
|
59
|
-
|
|
60
36
|
# @return [PGN::Position] a {PGN::Position} for this EPD. Halfmove and
|
|
61
37
|
# fullmove default to 0 and 1 (EPD does not carry them).
|
|
62
38
|
#
|
|
63
39
|
def to_position
|
|
64
|
-
player
|
|
65
|
-
castling_rights = castling.chars - ['-']
|
|
66
|
-
ep = en_passant == '-' ? nil : en_passant
|
|
67
|
-
|
|
40
|
+
player, castling_rights, ep = position_fields
|
|
68
41
|
PGN::Position.new(board, player, castling_rights, ep, 0, 1)
|
|
69
42
|
end
|
|
70
43
|
|
data/lib/pgn/fen.rb
CHANGED
|
@@ -1,4 +1,44 @@
|
|
|
1
1
|
module PGN
|
|
2
|
+
# Shared FEN/EPD board-field parsing and serialization: the piece-placement
|
|
3
|
+
# field, and the '-'-normalized castling/en-passant fields. {PGN::FEN} and
|
|
4
|
+
# {PGN::EPD} both include this rather than each carrying their own copy.
|
|
5
|
+
module PositionFields
|
|
6
|
+
def castling=(val)
|
|
7
|
+
@castling = val.nil? || val.empty? ? '-' : val
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def en_passant=(val)
|
|
11
|
+
@en_passant = val.nil? ? '-' : val
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param board_fen [String] the FEN/EPD representation of the board
|
|
15
|
+
def board_string=(board_fen)
|
|
16
|
+
squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
|
|
17
|
+
.split('/')
|
|
18
|
+
.map(&:chars)
|
|
19
|
+
.map { |row| row.map { |e| e == '_' ? nil : e } }
|
|
20
|
+
.reverse
|
|
21
|
+
.transpose
|
|
22
|
+
self.board = PGN::Board.new(squares)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [String] the FEN/EPD board-string portion
|
|
26
|
+
def board_string
|
|
27
|
+
board.fen_board_string
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# The player/castling-rights/en-passant args shared by FEN#to_position
|
|
33
|
+
# and EPD#to_position (which differ only in halfmove/fullmove).
|
|
34
|
+
def position_fields
|
|
35
|
+
player = active == 'w' ? :white : :black
|
|
36
|
+
castling_rights = castling.chars - ['-']
|
|
37
|
+
ep = en_passant == '-' ? nil : en_passant
|
|
38
|
+
[player, castling_rights, ep]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
2
42
|
# {PGN::FEN} is responsible for translating between strings in FEN
|
|
3
43
|
# notation and an internal representation of the board.
|
|
4
44
|
#
|
|
@@ -35,14 +75,17 @@ module PGN
|
|
|
35
75
|
# plays.
|
|
36
76
|
#
|
|
37
77
|
class FEN
|
|
78
|
+
include PositionFields
|
|
79
|
+
|
|
38
80
|
# The FEN string representing the starting position in chess
|
|
39
81
|
#
|
|
40
82
|
INITIAL = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'.freeze
|
|
41
83
|
|
|
42
84
|
attr_accessor :board, :active, :halfmove, :fullmove
|
|
43
|
-
# `castling` and `en_passant` have custom writers
|
|
44
|
-
# to '-'), so expose readers here and
|
|
45
|
-
# redefining the accessors generated by
|
|
85
|
+
# `castling` and `en_passant` have custom writers in {PositionFields}
|
|
86
|
+
# (normalize nil/empty to '-'), so expose readers here and rely on the
|
|
87
|
+
# module for the writers, to avoid redefining the accessors generated by
|
|
88
|
+
# attr_accessor (Lint/DuplicateMethods).
|
|
46
89
|
attr_reader :castling, :en_passant
|
|
47
90
|
|
|
48
91
|
# @return [PGN::FEN] a {PGN::FEN} object representing the starting
|
|
@@ -75,52 +118,12 @@ module PGN
|
|
|
75
118
|
self.fullmove = fen_string.split
|
|
76
119
|
end
|
|
77
120
|
|
|
78
|
-
def en_passant=(val)
|
|
79
|
-
@en_passant = val.nil? ? '-' : val
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def castling=(val)
|
|
83
|
-
@castling = val.nil? || val.empty? ? '-' : val
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# @param board_fen [String] the fen representation of the board
|
|
87
|
-
# @example
|
|
88
|
-
# fen.board_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
89
|
-
#
|
|
90
|
-
def board_string=(board_fen)
|
|
91
|
-
squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
|
|
92
|
-
.split('/')
|
|
93
|
-
.map(&:chars)
|
|
94
|
-
.map { |row| row.map { |e| e == '_' ? nil : e } }
|
|
95
|
-
.reverse
|
|
96
|
-
.transpose
|
|
97
|
-
self.board = PGN::Board.new(squares)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# @return [String] the fen representation of the board
|
|
101
|
-
# @example
|
|
102
|
-
# PGN::FEN.start.board_string #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
103
|
-
#
|
|
104
|
-
def board_string
|
|
105
|
-
board.fen_board_string
|
|
106
|
-
end
|
|
107
|
-
|
|
108
121
|
# @return [PGN::Position] a {PGN::Position} representing the current
|
|
109
122
|
# position
|
|
110
123
|
#
|
|
111
124
|
def to_position
|
|
112
|
-
player
|
|
113
|
-
|
|
114
|
-
en_passant = self.en_passant == '-' ? nil : self.en_passant
|
|
115
|
-
|
|
116
|
-
PGN::Position.new(
|
|
117
|
-
board,
|
|
118
|
-
player,
|
|
119
|
-
castling,
|
|
120
|
-
en_passant,
|
|
121
|
-
halfmove.to_i,
|
|
122
|
-
fullmove.to_i
|
|
123
|
-
)
|
|
125
|
+
player, castling_rights, ep = position_fields
|
|
126
|
+
PGN::Position.new(board, player, castling_rights, ep, halfmove.to_i, fullmove.to_i)
|
|
124
127
|
end
|
|
125
128
|
|
|
126
129
|
# @return [String] the EPD string for this position (the first four FEN
|
data/lib/pgn/game.rb
CHANGED
|
@@ -9,7 +9,7 @@ module PGN
|
|
|
9
9
|
def initialize(notation, annotation = nil, comment = nil, variations = [])
|
|
10
10
|
@notation = notation
|
|
11
11
|
@annotation = annotation
|
|
12
|
-
@comment = clean_text(comment)
|
|
12
|
+
@comment = MoveText.clean_text(comment)
|
|
13
13
|
@variations = variations
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -29,16 +29,27 @@ module PGN
|
|
|
29
29
|
@notation
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
# Normalize UCI-style castling ('0-0') to canonical SAN ('O-O').
|
|
33
|
+
def self.normalize_castling(san)
|
|
34
|
+
san.include?('0') ? san.gsub('0', 'O') : san
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Strip the single outermost brace pair, then unescape \{ \} \\ so
|
|
38
|
+
# the internal representation is the raw comment body. {Serializer}
|
|
39
|
+
# re-escapes on output, so parse -> serialize -> parse is byte-stable.
|
|
40
|
+
# Exposed as a class method so the parser can clean each trailing comment
|
|
41
|
+
# individually before concatenating multiple comments on one move.
|
|
42
|
+
def self.clean_text(text)
|
|
33
43
|
return unless text
|
|
34
44
|
|
|
35
|
-
# Strip the single outermost brace pair, then unescape \{ \} \\ so
|
|
36
|
-
# the internal representation is the raw comment body. {Serializer}
|
|
37
|
-
# re-escapes on output, so parse -> serialize -> parse is byte-stable.
|
|
38
45
|
text = text[1..-2] if text.start_with?('{') && text.end_with?('}')
|
|
39
46
|
text = text.gsub(/\\([\\{}])/, '\1')
|
|
40
47
|
text.gsub(/\s+/, ' ').strip
|
|
41
48
|
end
|
|
49
|
+
|
|
50
|
+
def clean_text(text)
|
|
51
|
+
MoveText.clean_text(text)
|
|
52
|
+
end
|
|
42
53
|
end
|
|
43
54
|
|
|
44
55
|
# {PGN::Game} holds all of the information about a game. It is either
|
|
@@ -150,7 +161,7 @@ module PGN
|
|
|
150
161
|
|
|
151
162
|
# Append a move in SAN, validating legality when the native engine is
|
|
152
163
|
# available. Raises ArgumentError for an illegal move (when the engine is
|
|
153
|
-
# loaded)
|
|
164
|
+
# loaded). Grows the memoized position list in step, if it is populated.
|
|
154
165
|
#
|
|
155
166
|
# @param san [String, PGN::MoveText] the move to append
|
|
156
167
|
# @return [self]
|
|
@@ -161,31 +172,31 @@ module PGN
|
|
|
161
172
|
end
|
|
162
173
|
|
|
163
174
|
@moves << move
|
|
164
|
-
@positions
|
|
175
|
+
@positions << @positions.last.move(move.notation) if @positions
|
|
165
176
|
self
|
|
166
177
|
end
|
|
167
178
|
|
|
168
|
-
# Remove and return the last move, or nil if there are none.
|
|
169
|
-
#
|
|
179
|
+
# Remove and return the last move, or nil if there are none. Shrinks the
|
|
180
|
+
# memoized position list in step, if it is populated.
|
|
170
181
|
#
|
|
171
182
|
# @return [PGN::MoveText, nil]
|
|
172
183
|
def pop
|
|
173
184
|
return nil if @moves.empty?
|
|
174
185
|
|
|
175
186
|
move = @moves.pop
|
|
176
|
-
@positions
|
|
187
|
+
@positions&.pop
|
|
177
188
|
move
|
|
178
189
|
end
|
|
179
190
|
|
|
180
191
|
# Whether any position has occurred three times in this game (the
|
|
181
192
|
# threefold-repetition draw). Uses {PGN::Position#hash} (the Zobrist
|
|
182
|
-
# hash of the FEN-relevant state)
|
|
183
|
-
#
|
|
193
|
+
# hash of the FEN-relevant state) over {#positions}, so a prior or
|
|
194
|
+
# subsequent call that also needs the position list shares the replay.
|
|
184
195
|
#
|
|
185
196
|
# @return [Boolean]
|
|
186
197
|
def threefold?
|
|
187
198
|
counts = Hash.new(0)
|
|
188
|
-
|
|
199
|
+
positions.each { |position| counts[position.hash] += 1 }
|
|
189
200
|
counts.any? { |_, count| count >= 3 }
|
|
190
201
|
end
|
|
191
202
|
|
|
@@ -245,9 +256,9 @@ module PGN
|
|
|
245
256
|
# (it only strips a *single* outermost brace pair), so reusing or rebuilding
|
|
246
257
|
# a MoveText never corrupts a comment that still contains inner braces.
|
|
247
258
|
def standardize_castling(entry)
|
|
248
|
-
return MoveText.new(
|
|
259
|
+
return MoveText.new(MoveText.normalize_castling(entry)) if entry.is_a?(String)
|
|
249
260
|
|
|
250
|
-
notation =
|
|
261
|
+
notation = MoveText.normalize_castling(entry.notation)
|
|
251
262
|
return entry if notation.equal?(entry.notation)
|
|
252
263
|
|
|
253
264
|
MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
|