pgn2 2.0.1 → 2.0.2
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 +32 -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 +15 -10
- data/lib/pgn/node.rb +66 -91
- data/lib/pgn/position.rb +3 -1
- data/lib/pgn/version.rb +1 -1
- data/spec/game_spec.rb +0 -14
- data/spec/node_spec.rb +0 -18
- 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: 8fdc6dcb9543c25e2e1102902d031f9d733463780b6f5d16a1a4713c6cfe16b9
|
|
4
|
+
data.tar.gz: 051ba77cdb0e657909ef5a22c1470f229c883e2bcb70cbfd3b9dbfbb83e92b8e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3999930da7369748415bfb222108d436a64c67bf8e91c5b0d12d9dc5ab38f64c78f1e1e56abca9454ed8e1a215df0918d152e37b1271808d8e1962088029a0e7
|
|
7
|
+
data.tar.gz: 2dec3d79ee03350d252bd846a789b3b96091f6ec34cd788d60e267506c2837436659395472fef192f3aeec8290ef32e3f7bd235308062a1de12f1add0e110ab7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.2 (2026-08-18)
|
|
4
|
+
|
|
5
|
+
### Summary
|
|
6
|
+
|
|
7
|
+
Internal refactor and performance pass — no public API changes. The
|
|
8
|
+
`pgn2-bitboard` adapter drops its hand-rolled `Move`/`MoveList`/`uci_parse`
|
|
9
|
+
layer in favor of `chessie`'s own `MoveList` and `Move: PartialEq<str>`
|
|
10
|
+
(UCI comparison), and the Ruby side DRYs shared FEN/EPD parsing, memoizes
|
|
11
|
+
`Position#in_check?`, and maintains `Game`'s position cache incrementally.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **`ext/pgn2_native`** — remove `pgn2-bitboard/src/moves.rs`;
|
|
16
|
+
`Board#legal_moves` returns `chessie::MoveList` directly and
|
|
17
|
+
`Engine#legal?` compares via `chessie::Move`'s `PartialEq<str>` (by
|
|
18
|
+
`to_uci()`), eliminating the separate UCI parser.
|
|
19
|
+
- **`PGN::PositionFields`** — shared FEN/EPD module for board-string and
|
|
20
|
+
castling/en-passant field parsing; `FEN` and `EPD` now include it instead
|
|
21
|
+
of duplicating the logic.
|
|
22
|
+
- **`PGN::Attack.attacked?`** — short-circuits pawn/knight/king checks before
|
|
23
|
+
the slider ray-walk (cheapest first), matching the previous result.
|
|
24
|
+
- **`PGN::Position#in_check?`** — memoizes its result on first call.
|
|
25
|
+
- **`PGN::Game`** — `push`/`pop` now grow/shrink the memoized `@positions`
|
|
26
|
+
list in step rather than invalidating it; `threefold?` reuses `#positions`.
|
|
27
|
+
- **`PGN::MoveText.normalize_castling`** — castling `0`→`O` normalization
|
|
28
|
+
extracted as a class method (was a private `Node` helper).
|
|
29
|
+
- **`PGN::Node`** — `promote`/`demote`/`promote_to_main`/`demote_to_last`/
|
|
30
|
+
`delete` share a `mutate_sibling` prologue/epilogue and a `splice_line!`
|
|
31
|
+
helper; behavior unchanged.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
3
35
|
## 2.0.1 (2026-08-15)
|
|
4
36
|
|
|
5
37
|
### 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
|
@@ -29,6 +29,11 @@ module PGN
|
|
|
29
29
|
@notation
|
|
30
30
|
end
|
|
31
31
|
|
|
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
|
+
|
|
32
37
|
def clean_text(text)
|
|
33
38
|
return unless text
|
|
34
39
|
|
|
@@ -150,7 +155,7 @@ module PGN
|
|
|
150
155
|
|
|
151
156
|
# Append a move in SAN, validating legality when the native engine is
|
|
152
157
|
# available. Raises ArgumentError for an illegal move (when the engine is
|
|
153
|
-
# loaded)
|
|
158
|
+
# loaded). Grows the memoized position list in step, if it is populated.
|
|
154
159
|
#
|
|
155
160
|
# @param san [String, PGN::MoveText] the move to append
|
|
156
161
|
# @return [self]
|
|
@@ -161,31 +166,31 @@ module PGN
|
|
|
161
166
|
end
|
|
162
167
|
|
|
163
168
|
@moves << move
|
|
164
|
-
@positions
|
|
169
|
+
@positions << @positions.last.move(move.notation) if @positions
|
|
165
170
|
self
|
|
166
171
|
end
|
|
167
172
|
|
|
168
|
-
# Remove and return the last move, or nil if there are none.
|
|
169
|
-
#
|
|
173
|
+
# Remove and return the last move, or nil if there are none. Shrinks the
|
|
174
|
+
# memoized position list in step, if it is populated.
|
|
170
175
|
#
|
|
171
176
|
# @return [PGN::MoveText, nil]
|
|
172
177
|
def pop
|
|
173
178
|
return nil if @moves.empty?
|
|
174
179
|
|
|
175
180
|
move = @moves.pop
|
|
176
|
-
@positions
|
|
181
|
+
@positions&.pop
|
|
177
182
|
move
|
|
178
183
|
end
|
|
179
184
|
|
|
180
185
|
# Whether any position has occurred three times in this game (the
|
|
181
186
|
# threefold-repetition draw). Uses {PGN::Position#hash} (the Zobrist
|
|
182
|
-
# hash of the FEN-relevant state)
|
|
183
|
-
#
|
|
187
|
+
# hash of the FEN-relevant state) over {#positions}, so a prior or
|
|
188
|
+
# subsequent call that also needs the position list shares the replay.
|
|
184
189
|
#
|
|
185
190
|
# @return [Boolean]
|
|
186
191
|
def threefold?
|
|
187
192
|
counts = Hash.new(0)
|
|
188
|
-
|
|
193
|
+
positions.each { |position| counts[position.hash] += 1 }
|
|
189
194
|
counts.any? { |_, count| count >= 3 }
|
|
190
195
|
end
|
|
191
196
|
|
|
@@ -245,9 +250,9 @@ module PGN
|
|
|
245
250
|
# (it only strips a *single* outermost brace pair), so reusing or rebuilding
|
|
246
251
|
# a MoveText never corrupts a comment that still contains inner braces.
|
|
247
252
|
def standardize_castling(entry)
|
|
248
|
-
return MoveText.new(
|
|
253
|
+
return MoveText.new(MoveText.normalize_castling(entry)) if entry.is_a?(String)
|
|
249
254
|
|
|
250
|
-
notation =
|
|
255
|
+
notation = MoveText.normalize_castling(entry.notation)
|
|
251
256
|
return entry if notation.equal?(entry.notation)
|
|
252
257
|
|
|
253
258
|
MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
|
data/lib/pgn/node.rb
CHANGED
|
@@ -141,14 +141,7 @@ module PGN
|
|
|
141
141
|
@line.push(*new_line)
|
|
142
142
|
else
|
|
143
143
|
normalize_branch_point(cont)
|
|
144
|
-
|
|
145
|
-
pos = @index + 1
|
|
146
|
-
old_tail = @line[(pos + 1)..] || []
|
|
147
|
-
n0 = new_line[0]
|
|
148
|
-
@line[pos] = n0
|
|
149
|
-
@line[(pos + 1)..] = (new_line[1..] || [])
|
|
150
|
-
cont.variations = []
|
|
151
|
-
n0.variations = [[cont, *old_tail], *vars]
|
|
144
|
+
make_mainline(@line, @index + 1, cont, new_line, cont.variations, old_main_position: :first)
|
|
152
145
|
end
|
|
153
146
|
@game.root
|
|
154
147
|
end
|
|
@@ -157,16 +150,9 @@ module PGN
|
|
|
157
150
|
# No-op for the mainline (index 0) or the first variation (index 1).
|
|
158
151
|
# Returns a fresh +game.root+.
|
|
159
152
|
def promote
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return @game.root if i.nil? || i <= 1
|
|
164
|
-
|
|
165
|
-
cont = parent_continuation
|
|
166
|
-
normalize_branch_point(cont)
|
|
167
|
-
vars = cont.variations
|
|
168
|
-
vars[i - 1], vars[i - 2] = vars[i - 2], vars[i - 1]
|
|
169
|
-
@game.root
|
|
153
|
+
mutate_sibling(noop: ->(i) { i <= 1 }) do |i, _cont, vars|
|
|
154
|
+
vars[i - 1], vars[i - 2] = vars[i - 2], vars[i - 1]
|
|
155
|
+
end
|
|
170
156
|
end
|
|
171
157
|
|
|
172
158
|
# Move this node one slot toward the end among its siblings. At index 0
|
|
@@ -174,83 +160,54 @@ module PGN
|
|
|
174
160
|
# mainline and the old mainline becomes variation #1. No-op at the last
|
|
175
161
|
# index. Returns a fresh +game.root+.
|
|
176
162
|
def demote
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
normalize_branch_point(cont)
|
|
185
|
-
vars = cont.variations
|
|
186
|
-
if i.zero?
|
|
187
|
-
v1 = vars.shift
|
|
188
|
-
make_mainline(cont, v1, vars, old_main_position: :first)
|
|
189
|
-
else
|
|
190
|
-
vars[i - 1], vars[i] = vars[i], vars[i - 1]
|
|
163
|
+
mutate_sibling(noop: ->(i) { i == @parent.children.size - 1 }) do |i, cont, vars|
|
|
164
|
+
if i.zero?
|
|
165
|
+
v1 = vars.shift
|
|
166
|
+
make_mainline(@parent.line, @parent.index + 1, cont, v1, vars, old_main_position: :first)
|
|
167
|
+
else
|
|
168
|
+
vars[i - 1], vars[i] = vars[i], vars[i - 1]
|
|
169
|
+
end
|
|
191
170
|
end
|
|
192
|
-
@game.root
|
|
193
171
|
end
|
|
194
172
|
|
|
195
173
|
# Make this node the mainline at its branching point (the old mainline
|
|
196
174
|
# becomes variation #1). No-op if already the mainline. Returns a
|
|
197
175
|
# fresh +game.root+.
|
|
198
176
|
def promote_to_main
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
cont = parent_continuation
|
|
205
|
-
normalize_branch_point(cont)
|
|
206
|
-
vars = cont.variations
|
|
207
|
-
vk = vars.delete_at(i - 1)
|
|
208
|
-
make_mainline(cont, vk, vars, old_main_position: :first)
|
|
209
|
-
@game.root
|
|
177
|
+
mutate_sibling(noop: lambda(&:zero?)) do |i, cont, vars|
|
|
178
|
+
vk = vars.delete_at(i - 1)
|
|
179
|
+
make_mainline(@parent.line, @parent.index + 1, cont, vk, vars, old_main_position: :first)
|
|
180
|
+
end
|
|
210
181
|
end
|
|
211
182
|
|
|
212
183
|
# Move this node to the last position among its siblings. At index 0
|
|
213
184
|
# the last variation becomes the new mainline and the old mainline
|
|
214
185
|
# becomes the last variation. Returns a fresh +game.root+.
|
|
215
186
|
def demote_to_last
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
last = vars.pop
|
|
228
|
-
make_mainline(cont, last, vars, old_main_position: :last)
|
|
229
|
-
else
|
|
230
|
-
el = vars.delete_at(i - 1)
|
|
231
|
-
vars.push(el)
|
|
187
|
+
mutate_sibling do |i, cont, vars|
|
|
188
|
+
if i.zero?
|
|
189
|
+
unless vars.empty?
|
|
190
|
+
last = vars.pop
|
|
191
|
+
make_mainline(@parent.line, @parent.index + 1, cont, last, vars, old_main_position: :last)
|
|
192
|
+
end
|
|
193
|
+
else
|
|
194
|
+
el = vars.delete_at(i - 1)
|
|
195
|
+
vars.push(el)
|
|
196
|
+
end
|
|
232
197
|
end
|
|
233
|
-
@game.root
|
|
234
198
|
end
|
|
235
199
|
|
|
236
200
|
# Remove this node and its subtree. If it is the mainline continuation,
|
|
237
201
|
# the first remaining variation (if any) takes its place; otherwise the
|
|
238
202
|
# line is truncated at this point. Returns a fresh +game.root+.
|
|
239
203
|
def delete
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
normalize_branch_point(cont)
|
|
247
|
-
vars = cont.variations
|
|
248
|
-
if i.zero?
|
|
249
|
-
delete_mainline_continuation(cont, vars)
|
|
250
|
-
else
|
|
251
|
-
vars.delete_at(i - 1)
|
|
204
|
+
mutate_sibling do |i, cont, vars|
|
|
205
|
+
if i.zero?
|
|
206
|
+
delete_mainline_continuation(cont, vars)
|
|
207
|
+
else
|
|
208
|
+
vars.delete_at(i - 1)
|
|
209
|
+
end
|
|
252
210
|
end
|
|
253
|
-
@game.root
|
|
254
211
|
end
|
|
255
212
|
|
|
256
213
|
private
|
|
@@ -295,11 +252,25 @@ module PGN
|
|
|
295
252
|
# SANs, applying the same castling 0->O normalization as Game#moves=.
|
|
296
253
|
def build_movetexts(move_or_moves)
|
|
297
254
|
sans = move_or_moves.is_a?(String) ? [move_or_moves] : move_or_moves
|
|
298
|
-
sans.map { |s| PGN::MoveText.new(normalize_castling(s)) }
|
|
255
|
+
sans.map { |s| PGN::MoveText.new(PGN::MoveText.normalize_castling(s)) }
|
|
299
256
|
end
|
|
300
257
|
|
|
301
|
-
|
|
302
|
-
|
|
258
|
+
# Shared prologue/epilogue for the sibling-reordering mutators (promote,
|
|
259
|
+
# demote, promote_to_main, demote_to_last, delete): no-op at the root or
|
|
260
|
+
# per +noop+ (checked against the sibling index before any mutation),
|
|
261
|
+
# otherwise normalize the branching point and yield the sibling index,
|
|
262
|
+
# the parent's mainline continuation, and its (now flat) variations to
|
|
263
|
+
# +block+ for the mutation proper. Always returns a fresh +game.root+.
|
|
264
|
+
def mutate_sibling(noop: nil)
|
|
265
|
+
return @game.root if root?
|
|
266
|
+
|
|
267
|
+
i = sibling_index
|
|
268
|
+
return @game.root if i.nil? || noop&.call(i)
|
|
269
|
+
|
|
270
|
+
cont = parent_continuation
|
|
271
|
+
normalize_branch_point(cont)
|
|
272
|
+
yield i, cont, cont.variations
|
|
273
|
+
@game.root
|
|
303
274
|
end
|
|
304
275
|
|
|
305
276
|
# Hoist every variation first-move branching at the position before
|
|
@@ -331,18 +302,24 @@ module PGN
|
|
|
331
302
|
end
|
|
332
303
|
end
|
|
333
304
|
|
|
334
|
-
#
|
|
335
|
-
#
|
|
336
|
-
#
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
305
|
+
# Splice +movetexts+ (an Array<MoveText>) into +line+ starting at +pos+:
|
|
306
|
+
# its first entry replaces +line[pos]+, and the rest replace the tail
|
|
307
|
+
# after it (so a shorter +movetexts+ truncates +line+).
|
|
308
|
+
def splice_line!(line, pos, movetexts)
|
|
309
|
+
line[pos] = movetexts[0]
|
|
310
|
+
line[(pos + 1)..] = (movetexts[1..] || [])
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Make +vk+ (= [vk0, *tail]) the new mainline continuation at +line+/+pos+
|
|
314
|
+
# (the position before +cont+), replacing +cont+. The old mainline
|
|
315
|
+
# (+cont+ and its tail) becomes a variation appended to +others+ either
|
|
316
|
+
# before (+old_main_position == :first+) or after (+:last+).
|
|
317
|
+
# +cont.variations+ is cleared (its at-point variations are now +vk0+'s
|
|
318
|
+
# siblings).
|
|
319
|
+
def make_mainline(line, pos, cont, variation, others, old_main_position:)
|
|
342
320
|
old_tail = line[(pos + 1)..] || []
|
|
343
321
|
variation0 = variation[0]
|
|
344
|
-
line
|
|
345
|
-
line[(pos + 1)..] = (variation[1..] || [])
|
|
322
|
+
splice_line!(line, pos, variation)
|
|
346
323
|
cont.variations = []
|
|
347
324
|
old_var = [cont, *old_tail]
|
|
348
325
|
variation0.variations =
|
|
@@ -361,11 +338,9 @@ module PGN
|
|
|
361
338
|
line[pos..] = []
|
|
362
339
|
else
|
|
363
340
|
first_variation = vars.shift
|
|
364
|
-
|
|
365
|
-
line[pos] = first_variation_move
|
|
366
|
-
line[(pos + 1)..] = (first_variation[1..] || [])
|
|
341
|
+
splice_line!(line, pos, first_variation)
|
|
367
342
|
cont.variations = []
|
|
368
|
-
|
|
343
|
+
first_variation[0].variations = vars
|
|
369
344
|
end
|
|
370
345
|
end
|
|
371
346
|
end
|
data/lib/pgn/position.rb
CHANGED
|
@@ -179,8 +179,10 @@ module PGN
|
|
|
179
179
|
#
|
|
180
180
|
# @return [Boolean]
|
|
181
181
|
def in_check?
|
|
182
|
+
return @in_check if defined?(@in_check)
|
|
183
|
+
|
|
182
184
|
king = PGN::Attack.king_idx(board, mover_color)
|
|
183
|
-
!king.nil? && PGN::Attack.attacked?(board, king, opponent_color)
|
|
185
|
+
@in_check = !king.nil? && PGN::Attack.attacked?(board, king, opponent_color)
|
|
184
186
|
end
|
|
185
187
|
|
|
186
188
|
# The algebraic squares of every piece of the given color that attacks
|
data/lib/pgn/version.rb
CHANGED
data/spec/game_spec.rb
CHANGED
|
@@ -133,20 +133,6 @@ describe PGN::Game do
|
|
|
133
133
|
describe 'node mutation round-trip' do
|
|
134
134
|
non_round_trip = %w[doublequotes.pgn specialcharacters.pgn].freeze
|
|
135
135
|
|
|
136
|
-
# A node-sig tree with variations sorted, so the comparison is
|
|
137
|
-
# order-independent (the parser reverses variation order on each parse).
|
|
138
|
-
def node_sig(move)
|
|
139
|
-
[move.notation, (move.variations || []).map { line_sig(_1) }.sort]
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def line_sig(line)
|
|
143
|
-
line.map { node_sig(_1) }
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def tree_sig(game)
|
|
147
|
-
game.moves.map { node_sig(_1) }
|
|
148
|
-
end
|
|
149
|
-
|
|
150
136
|
# First node (DFS over children) that has at least one variation. Such a
|
|
151
137
|
# node always has a non-nil continuation, so add_variation can branch.
|
|
152
138
|
def first_branch_with_variations(node)
|
data/spec/node_spec.rb
CHANGED
|
@@ -1,23 +1,5 @@
|
|
|
1
1
|
require 'spec_helper'
|
|
2
2
|
|
|
3
|
-
# Helpers for order-independent variation comparison. The parser's
|
|
4
|
-
# `variation_list` rule is right-recursive, so a move's variations are stored
|
|
5
|
-
# in reverse of their PGN-text order; round-tripping flips that order each
|
|
6
|
-
# parse. We therefore compare variation trees as sorted signatures (the same
|
|
7
|
-
# approach the existing game_spec round-trip gate uses), and reference
|
|
8
|
-
# variations by notation rather than by index.
|
|
9
|
-
def node_sig(move)
|
|
10
|
-
[move.notation, (move.variations || []).map { line_sig(_1) }.sort]
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def line_sig(line)
|
|
14
|
-
line.map { node_sig(_1) }
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def tree_sig(game)
|
|
18
|
-
game.moves.map { node_sig(_1) }
|
|
19
|
-
end
|
|
20
|
-
|
|
21
3
|
describe PGN::Node do
|
|
22
4
|
let(:game) { PGN.parse(File.read(File.expand_path('pgn_files/variations.pgn', __dir__), encoding: Encoding::ISO_8859_1)).first }
|
|
23
5
|
let(:root) { game.root }
|
data/spec/spec_helper.rb
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
require 'pgn'
|
|
2
2
|
|
|
3
|
+
# Helpers for order-independent variation comparison. The parser's
|
|
4
|
+
# `variation_list` rule is right-recursive, so a move's variations are stored
|
|
5
|
+
# in reverse of their PGN-text order; round-tripping flips that order each
|
|
6
|
+
# parse. We therefore compare variation trees as sorted signatures, and
|
|
7
|
+
# reference variations by notation rather than by index.
|
|
8
|
+
def node_sig(move)
|
|
9
|
+
[move.notation, (move.variations || []).map { line_sig(_1) }.sort]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def line_sig(line)
|
|
13
|
+
line.map { node_sig(_1) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def tree_sig(game)
|
|
17
|
+
game.moves.map { node_sig(_1) }
|
|
18
|
+
end
|
|
19
|
+
|
|
3
20
|
# This file was generated by the `rspec --init` command. Conventionally, all
|
|
4
21
|
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
|
|
5
22
|
# Require this file using `require "spec_helper"` to ensure that it is only
|
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: 2.0.
|
|
4
|
+
version: 2.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stacey Touset
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-08-
|
|
12
|
+
date: 2026-08-18 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: rb_sys
|
|
@@ -242,7 +242,6 @@ files:
|
|
|
242
242
|
- ext/pgn2_native/pgn2-bitboard/Cargo.toml
|
|
243
243
|
- ext/pgn2_native/pgn2-bitboard/src/board.rs
|
|
244
244
|
- ext/pgn2_native/pgn2-bitboard/src/lib.rs
|
|
245
|
-
- ext/pgn2_native/pgn2-bitboard/src/moves.rs
|
|
246
245
|
- ext/pgn2_native/pgn2-bitboard/src/perft.rs
|
|
247
246
|
- ext/pgn2_native/pgn2_native/Cargo.toml
|
|
248
247
|
- ext/pgn2_native/pgn2_native/src/lib.rs
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/// Adapter move carrying exactly what `to_uci` and `same_target` need:
|
|
2
|
-
/// from-square, to-square, and optional promotion kind. No flag bits,
|
|
3
|
-
/// so a position-free `uci_parse` can produce a comparable token — this
|
|
4
|
-
/// preserves the existing `same_target` semantics (match on
|
|
5
|
-
/// from + to + promo only), so `legal?("e1g1")` finds the castle and
|
|
6
|
-
/// `legal?("e7e8q")` finds that exact promotion.
|
|
7
|
-
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
8
|
-
pub struct Move {
|
|
9
|
-
from: u8, // 0..=63, index = rank * 8 + file
|
|
10
|
-
to: u8,
|
|
11
|
-
promo: Option<Promo>,
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
15
|
-
enum Promo {
|
|
16
|
-
Knight,
|
|
17
|
-
Bishop,
|
|
18
|
-
Rook,
|
|
19
|
-
Queen,
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
impl Move {
|
|
23
|
-
/// Build an adapter `Move` from a `chessie::Move` (a legal move).
|
|
24
|
-
///
|
|
25
|
-
/// chessie encodes castling internally as "King takes Rook"
|
|
26
|
-
/// (`to` = the rook's square, Chess960 style). `into_standard_castle`
|
|
27
|
-
/// rewrites the `to` square to the king's destination (`e1g1`/`e1c1`),
|
|
28
|
-
/// matching the standard-UCI output the previous engine produced and
|
|
29
|
-
/// that `uci_parse` / `bitboard_spec.rb` expect. No-op for non-castle
|
|
30
|
-
/// moves.
|
|
31
|
-
pub(crate) fn from_chessie(m: chessie::Move) -> Self {
|
|
32
|
-
let m = m.into_standard_castle();
|
|
33
|
-
Move {
|
|
34
|
-
from: m.from().index() as u8,
|
|
35
|
-
to: m.to().index() as u8,
|
|
36
|
-
promo: m.promotion().map(|kind| match kind {
|
|
37
|
-
chessie::PieceKind::Knight => Promo::Knight,
|
|
38
|
-
chessie::PieceKind::Bishop => Promo::Bishop,
|
|
39
|
-
chessie::PieceKind::Rook => Promo::Rook,
|
|
40
|
-
chessie::PieceKind::Queen => Promo::Queen,
|
|
41
|
-
// Pawns/Kings never appear as a promotion kind.
|
|
42
|
-
_ => unreachable!("non-promotion PieceKind in Move::promotion"),
|
|
43
|
-
}),
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/// UCI string: `"e2e4"`, `"e1g1"` (castle, king from→to),
|
|
48
|
-
/// `"e7e8q"` (promotion). Equal to `chessie::Move::to_uci` for
|
|
49
|
-
/// every legal move (castle and en-passant both reduce to from+to
|
|
50
|
-
/// in UCI), so the sorted-UCI output is unchanged.
|
|
51
|
-
pub fn to_uci(self) -> String {
|
|
52
|
-
let mut s = String::with_capacity(5);
|
|
53
|
-
s.push_str(&sq_name(self.from));
|
|
54
|
-
s.push_str(&sq_name(self.to));
|
|
55
|
-
if let Some(p) = self.promo {
|
|
56
|
-
s.push(match p {
|
|
57
|
-
Promo::Knight => 'n',
|
|
58
|
-
Promo::Bishop => 'b',
|
|
59
|
-
Promo::Rook => 'r',
|
|
60
|
-
Promo::Queen => 'q',
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
s
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/// Match on from + to + promo (the pre-existing semantics).
|
|
67
|
-
pub fn same_target(self, other: Move) -> bool {
|
|
68
|
-
self.from == other.from && self.to == other.to && self.promo == other.promo
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/// Thin wrapper around `Vec<Move>`; the binding calls `.iter()` on it.
|
|
73
|
-
pub struct MoveList(pub Vec<Move>);
|
|
74
|
-
|
|
75
|
-
impl MoveList {
|
|
76
|
-
pub fn iter(&self) -> std::slice::Iter<'_, Move> {
|
|
77
|
-
self.0.iter()
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/// Parse a UCI string into a `Move` token **without** a position.
|
|
82
|
-
/// Only `to_uci`/`same_target` consume the result, so from + to + promo
|
|
83
|
-
/// is all that is needed. Returns `None` on any malformed input.
|
|
84
|
-
pub fn uci_parse(s: &str) -> Option<Move> {
|
|
85
|
-
let b = s.as_bytes();
|
|
86
|
-
if b.len() < 4 {
|
|
87
|
-
return None;
|
|
88
|
-
}
|
|
89
|
-
let from = parse_sq(&b[0..2])?;
|
|
90
|
-
let to = parse_sq(&b[2..4])?;
|
|
91
|
-
let promo = if b.len() >= 5 {
|
|
92
|
-
Some(match b[4] {
|
|
93
|
-
b'n' => Promo::Knight,
|
|
94
|
-
b'b' => Promo::Bishop,
|
|
95
|
-
b'r' => Promo::Rook,
|
|
96
|
-
b'q' => Promo::Queen,
|
|
97
|
-
_ => return None,
|
|
98
|
-
})
|
|
99
|
-
} else {
|
|
100
|
-
None
|
|
101
|
-
};
|
|
102
|
-
Some(Move { from, to, promo })
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
fn parse_sq(t: &[u8]) -> Option<u8> {
|
|
106
|
-
let f = t[0].checked_sub(b'a')?;
|
|
107
|
-
let r = t[1].checked_sub(b'1')?;
|
|
108
|
-
if f > 7 || r > 7 {
|
|
109
|
-
return None;
|
|
110
|
-
}
|
|
111
|
-
Some(r * 8 + f)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
fn sq_name(idx: u8) -> String {
|
|
115
|
-
let file = (b'a' + (idx & 7)) as char;
|
|
116
|
-
let rank = (b'1' + (idx >> 3)) as char;
|
|
117
|
-
let mut s = String::with_capacity(2);
|
|
118
|
-
s.push(file);
|
|
119
|
-
s.push(rank);
|
|
120
|
-
s
|
|
121
|
-
}
|