pgn2 1.5.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +34 -3
- data/.github/workflows/native.yml +32 -0
- data/.github/workflows/publish.yml +44 -2
- data/.github/workflows/release-gems.yml +40 -0
- data/.github/workflows/release.yml +52 -5
- data/.gitignore +9 -1
- data/.rubocop.yml +46 -6
- data/CHANGELOG.md +148 -0
- data/Gemfile +3 -0
- data/NOTICE.md +21 -0
- data/README.md +107 -5
- data/Rakefile +35 -10
- data/TODO.md +64 -0
- data/bench/baseline_moves.txt +38 -4
- data/bench/baseline_parse.txt +4 -4
- data/bench/cross_check.rb +61 -0
- data/bench/legal_moves.rb +38 -0
- data/bench/perft.rb +32 -0
- data/bench/profile_moves.rb +93 -0
- data/docs/superpowers/plans/2026-08-13-attack-masks-plan.md +51 -0
- data/docs/superpowers/plans/2026-08-13-perf-internals-plan.md +883 -0
- data/docs/superpowers/plans/2026-08-13-rust-bitboard-perft-plan.md +2442 -0
- data/docs/superpowers/plans/2026-08-14-chessie-migration.md +722 -0
- data/docs/superpowers/plans/2026-08-14-rust-integration-plan.md +444 -0
- data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
- data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
- data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
- data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
- data/ext/pgn2_native/Cargo.lock +321 -0
- data/ext/pgn2_native/Cargo.toml +19 -0
- data/ext/pgn2_native/extconf.rb +8 -0
- data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
- data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
- data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
- data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
- data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
- data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
- data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
- data/lib/pgn/bitboard.rb +13 -0
- data/lib/pgn/board.rb +64 -0
- data/lib/pgn/fen.rb +35 -46
- data/lib/pgn/game.rb +22 -13
- data/lib/pgn/move.rb +19 -15
- data/lib/pgn/move_calculator.rb +13 -1
- data/lib/pgn/notation.rb +24 -29
- data/lib/pgn/position.rb +60 -19
- data/lib/pgn/serializer.rb +13 -18
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn/zobrist.rb +53 -0
- data/lib/pgn.rb +2 -0
- data/pgn2.gemspec +17 -10
- data/spec/bitboard_spec.rb +54 -0
- data/spec/board_spec.rb +53 -0
- data/spec/fen_spec.rb +65 -65
- data/spec/game_spec.rb +52 -15
- data/spec/lexer_spec.rb +5 -5
- data/spec/notation_spec.rb +5 -0
- data/spec/parser_spec.rb +8 -1
- data/spec/position_spec.rb +128 -27
- data/spec/serializer_spec.rb +4 -4
- data/spec/zobrist_spec.rb +46 -0
- metadata +97 -35
data/lib/pgn/board.rb
CHANGED
|
@@ -32,6 +32,31 @@ module PGN
|
|
|
32
32
|
RANK_TO_INDEX = ('1'..'8').each_with_index.to_h
|
|
33
33
|
INDEX_TO_RANK = RANK_TO_INDEX.invert
|
|
34
34
|
|
|
35
|
+
# 0x88 knight offsets (a1 + 33 = b3, etc.).
|
|
36
|
+
KNIGHT_OFFS = [33, 31, -31, -33, 18, 14, -14, -18].freeze
|
|
37
|
+
KING_OFFS = [-1, 1, -16, 16, -15, 15, -17, 17].freeze
|
|
38
|
+
|
|
39
|
+
# Precomputed on-board attack masks: entry `idx` is the frozen Array of
|
|
40
|
+
# on-board 0x88 target indices reachable from `idx` by that piece. Built
|
|
41
|
+
# once at load time so the per-call offset + off-board test is replaced by
|
|
42
|
+
# a direct array iteration.
|
|
43
|
+
KNIGHT_ATTACKS = Array.new(128) do |idx|
|
|
44
|
+
next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate
|
|
45
|
+
|
|
46
|
+
KNIGHT_OFFS.each_with_object([]) do |off, a|
|
|
47
|
+
t = idx + off
|
|
48
|
+
a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate
|
|
49
|
+
end.freeze
|
|
50
|
+
end.freeze
|
|
51
|
+
KING_ATTACKS = Array.new(128) do |idx|
|
|
52
|
+
next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate
|
|
53
|
+
|
|
54
|
+
KING_OFFS.each_with_object([]) do |off, a|
|
|
55
|
+
t = idx + off
|
|
56
|
+
a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate
|
|
57
|
+
end.freeze
|
|
58
|
+
end.freeze
|
|
59
|
+
|
|
35
60
|
# algebraic to unicode piece lookup
|
|
36
61
|
#
|
|
37
62
|
UNICODE_PIECES = {
|
|
@@ -264,6 +289,45 @@ module PGN
|
|
|
264
289
|
INDEX_TO_FILE[idx & 0x0F] + INDEX_TO_RANK[idx >> 4]
|
|
265
290
|
end
|
|
266
291
|
|
|
292
|
+
# Serializes the board to the FEN board-string portion (ranks 8→1,
|
|
293
|
+
# files a→h, runs of empty squares collapsed to a digit) by walking
|
|
294
|
+
# the 0x88 `@cells` array directly. This avoids rebuilding the 8x8
|
|
295
|
+
# `squares` array on every FEN generation.
|
|
296
|
+
#
|
|
297
|
+
# @return [String] e.g. "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
298
|
+
def fen_board_string
|
|
299
|
+
rows = []
|
|
300
|
+
7.downto(0) do |rank|
|
|
301
|
+
s = String.new
|
|
302
|
+
run = 0
|
|
303
|
+
0.upto(7) do |file|
|
|
304
|
+
piece = @cells[(rank * 16) + file]
|
|
305
|
+
if piece.nil?
|
|
306
|
+
run += 1
|
|
307
|
+
else
|
|
308
|
+
s << run.to_s if run.positive?
|
|
309
|
+
run = 0
|
|
310
|
+
s << piece
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
s << run.to_s if run.positive?
|
|
314
|
+
rows << s
|
|
315
|
+
end
|
|
316
|
+
rows.join('/')
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Boards are equal when every cell holds the same piece (or is
|
|
320
|
+
# empty), including off-board padding, which is always nil on both.
|
|
321
|
+
def eql?(other)
|
|
322
|
+
other.is_a?(Board) && cells == other.cells
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
alias == eql?
|
|
326
|
+
|
|
327
|
+
protected
|
|
328
|
+
|
|
329
|
+
attr_reader :cells
|
|
330
|
+
|
|
267
331
|
private
|
|
268
332
|
|
|
269
333
|
def file_of(square)
|
data/lib/pgn/fen.rb
CHANGED
|
@@ -37,9 +37,13 @@ module PGN
|
|
|
37
37
|
class FEN
|
|
38
38
|
# The FEN string representing the starting position in chess
|
|
39
39
|
#
|
|
40
|
-
INITIAL =
|
|
40
|
+
INITIAL = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'.freeze
|
|
41
41
|
|
|
42
|
-
attr_accessor :board, :active, :
|
|
42
|
+
attr_accessor :board, :active, :halfmove, :fullmove
|
|
43
|
+
# `castling` and `en_passant` have custom writers below (normalize nil/empty
|
|
44
|
+
# to '-'), so expose readers here and define the writers explicitly to avoid
|
|
45
|
+
# redefining the accessors generated by attr_accessor (Lint/DuplicateMethods).
|
|
46
|
+
attr_reader :castling, :en_passant
|
|
43
47
|
|
|
44
48
|
# @return [PGN::FEN] a {PGN::FEN} object representing the starting
|
|
45
49
|
# position
|
|
@@ -61,22 +65,22 @@ module PGN
|
|
|
61
65
|
# @param fen_string [String] a string in Forsyth-Edwards Notation
|
|
62
66
|
#
|
|
63
67
|
def initialize(fen_string = nil)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
return unless fen_string
|
|
69
|
+
|
|
70
|
+
self.board_string,
|
|
71
|
+
self.active,
|
|
72
|
+
self.castling,
|
|
73
|
+
self.en_passant,
|
|
74
|
+
self.halfmove,
|
|
75
|
+
self.fullmove = fen_string.split
|
|
72
76
|
end
|
|
73
77
|
|
|
74
78
|
def en_passant=(val)
|
|
75
|
-
@en_passant = val.nil? ?
|
|
79
|
+
@en_passant = val.nil? ? '-' : val
|
|
76
80
|
end
|
|
77
81
|
|
|
78
82
|
def castling=(val)
|
|
79
|
-
@castling =
|
|
83
|
+
@castling = val.nil? || val.empty? ? '-' : val
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
# @param board_fen [String] the fen representation of the board
|
|
@@ -84,10 +88,10 @@ module PGN
|
|
|
84
88
|
# fen.board_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
85
89
|
#
|
|
86
90
|
def board_string=(board_fen)
|
|
87
|
-
squares = board_fen.gsub(/\d/) {|match|
|
|
88
|
-
.split(
|
|
89
|
-
.map
|
|
90
|
-
.map {|row| row.map {|e| e ==
|
|
91
|
+
squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
|
|
92
|
+
.split('/')
|
|
93
|
+
.map(&:chars)
|
|
94
|
+
.map { |row| row.map { |e| e == '_' ? nil : e } }
|
|
91
95
|
.reverse
|
|
92
96
|
.transpose
|
|
93
97
|
self.board = PGN::Board.new(squares)
|
|
@@ -98,39 +102,24 @@ module PGN
|
|
|
98
102
|
# PGN::FEN.start.board_string #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
99
103
|
#
|
|
100
104
|
def board_string
|
|
101
|
-
|
|
102
|
-
rows.map do |row|
|
|
103
|
-
s = +""
|
|
104
|
-
run = 0
|
|
105
|
-
row.each do |e|
|
|
106
|
-
if e.nil?
|
|
107
|
-
run += 1
|
|
108
|
-
else
|
|
109
|
-
s << run.to_s if run > 0
|
|
110
|
-
run = 0
|
|
111
|
-
s << e
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
s << run.to_s if run > 0
|
|
115
|
-
s
|
|
116
|
-
end.join("/")
|
|
105
|
+
board.fen_board_string
|
|
117
106
|
end
|
|
118
107
|
|
|
119
108
|
# @return [PGN::Position] a {PGN::Position} representing the current
|
|
120
109
|
# position
|
|
121
110
|
#
|
|
122
111
|
def to_position
|
|
123
|
-
player =
|
|
124
|
-
castling = self.castling.
|
|
125
|
-
en_passant = self.en_passant == '-'
|
|
112
|
+
player = active == 'w' ? :white : :black
|
|
113
|
+
castling = self.castling.chars - ['-']
|
|
114
|
+
en_passant = nil if self.en_passant == '-'
|
|
126
115
|
|
|
127
116
|
PGN::Position.new(
|
|
128
|
-
|
|
117
|
+
board,
|
|
129
118
|
player,
|
|
130
119
|
castling,
|
|
131
120
|
en_passant,
|
|
132
|
-
|
|
133
|
-
|
|
121
|
+
halfmove.to_i,
|
|
122
|
+
fullmove.to_i
|
|
134
123
|
)
|
|
135
124
|
end
|
|
136
125
|
|
|
@@ -140,17 +129,17 @@ module PGN
|
|
|
140
129
|
#
|
|
141
130
|
def to_s
|
|
142
131
|
[
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
].join(
|
|
132
|
+
board_string,
|
|
133
|
+
active,
|
|
134
|
+
castling,
|
|
135
|
+
en_passant,
|
|
136
|
+
halfmove,
|
|
137
|
+
fullmove
|
|
138
|
+
].join(' ')
|
|
150
139
|
end
|
|
151
140
|
|
|
152
141
|
def inspect
|
|
153
|
-
|
|
142
|
+
to_s
|
|
154
143
|
end
|
|
155
144
|
end
|
|
156
145
|
end
|
data/lib/pgn/game.rb
CHANGED
|
@@ -30,7 +30,9 @@ module PGN
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def clean_text(text)
|
|
33
|
-
|
|
33
|
+
return unless text
|
|
34
|
+
|
|
35
|
+
text.gsub(/{(.*)}/, '\1').gsub(/\s+/, ' ').strip
|
|
34
36
|
end
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -106,16 +108,25 @@ module PGN
|
|
|
106
108
|
# @return [Array<PGN::Position>] list of the {PGN::Position}s in the game
|
|
107
109
|
#
|
|
108
110
|
def positions
|
|
109
|
-
@positions ||=
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
111
|
+
@positions ||= each_position.to_a
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Enumerator, self] with a block: yields each {PGN::Position}
|
|
115
|
+
# in order (starting position, then one per move) and returns self.
|
|
116
|
+
# Without a block: returns an Enumerator that yields the same.
|
|
117
|
+
#
|
|
118
|
+
# The replay loop is shared with {#positions} so eager and lazy paths
|
|
119
|
+
# produce identical position objects in identical order.
|
|
120
|
+
def each_position
|
|
121
|
+
return enum_for(:each_position) unless block_given?
|
|
122
|
+
|
|
123
|
+
position = starting_position
|
|
124
|
+
yield position
|
|
125
|
+
moves.each do |move|
|
|
126
|
+
position = position.move(move.notation)
|
|
127
|
+
yield position
|
|
118
128
|
end
|
|
129
|
+
self
|
|
119
130
|
end
|
|
120
131
|
|
|
121
132
|
# @return [Array<String>] list of the fen representations of the positions
|
|
@@ -158,9 +169,7 @@ module PGN
|
|
|
158
169
|
return MoveText.new(entry.include?('0') ? entry.gsub('0', 'O') : entry) if entry.is_a?(String)
|
|
159
170
|
|
|
160
171
|
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
|
|
172
|
+
return entry if notation.equal?(entry.notation) && (entry.comment.nil? || !entry.comment.include?('{'))
|
|
164
173
|
|
|
165
174
|
MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
|
|
166
175
|
end
|
data/lib/pgn/move.rb
CHANGED
|
@@ -57,8 +57,12 @@ module PGN
|
|
|
57
57
|
#
|
|
58
58
|
|
|
59
59
|
class Move
|
|
60
|
-
attr_accessor :san, :player
|
|
61
|
-
|
|
60
|
+
attr_accessor :san, :player, :destination, :check
|
|
61
|
+
# piece/promotion/capture/disambiguation/castle have custom writers below
|
|
62
|
+
# (color normalization, nil-coalescing, etc.), so expose only readers here
|
|
63
|
+
# to avoid redefining attr_accessor setters (Lint/DuplicateMethods).
|
|
64
|
+
attr_reader :piece, :promotion, :capture, :disambiguation, :castle
|
|
65
|
+
|
|
62
66
|
# A regular expression for matching moves in standard algebraic
|
|
63
67
|
# notation
|
|
64
68
|
#
|
|
@@ -70,7 +74,7 @@ module PGN
|
|
|
70
74
|
(?<capture> x ){0}
|
|
71
75
|
(?<disambiguation> [a-h]?[1-8]? ){0}
|
|
72
76
|
|
|
73
|
-
(?<castle> O-O(
|
|
77
|
+
(?<castle> O-O(?:-O)? ){0}
|
|
74
78
|
|
|
75
79
|
(?<normal>
|
|
76
80
|
\g<piece>?
|
|
@@ -80,8 +84,8 @@ module PGN
|
|
|
80
84
|
\g<promotion>?
|
|
81
85
|
){0}
|
|
82
86
|
|
|
83
|
-
\A (
|
|
84
|
-
/x
|
|
87
|
+
\A (?:\g<castle> | \g<normal>) \g<check>? \z
|
|
88
|
+
/x
|
|
85
89
|
|
|
86
90
|
# @param move [String] the move in SAN
|
|
87
91
|
# @param player [Symbol] the player making the move
|
|
@@ -115,10 +119,10 @@ module PGN
|
|
|
115
119
|
end
|
|
116
120
|
|
|
117
121
|
def promotion=(val)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
return unless val
|
|
123
|
+
|
|
124
|
+
val.downcase! if black?
|
|
125
|
+
@promotion = val.delete('=')
|
|
122
126
|
end
|
|
123
127
|
|
|
124
128
|
def capture=(val)
|
|
@@ -130,11 +134,11 @@ module PGN
|
|
|
130
134
|
end
|
|
131
135
|
|
|
132
136
|
def castle=(val)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
return unless val
|
|
138
|
+
|
|
139
|
+
@castle = 'K' if val == 'O-O'
|
|
140
|
+
@castle = 'Q' if val == 'O-O-O'
|
|
141
|
+
@castle.downcase! if black?
|
|
138
142
|
end
|
|
139
143
|
|
|
140
144
|
# @return [Boolean] whether the move results in check
|
|
@@ -164,7 +168,7 @@ module PGN
|
|
|
164
168
|
# @return [Boolean] whether the piece being moved is a pawn
|
|
165
169
|
#
|
|
166
170
|
def pawn?
|
|
167
|
-
|
|
171
|
+
%w[P p].include?(piece)
|
|
168
172
|
end
|
|
169
173
|
end
|
|
170
174
|
end
|
data/lib/pgn/move_calculator.rb
CHANGED
|
@@ -191,7 +191,7 @@ module PGN
|
|
|
191
191
|
|
|
192
192
|
possibilities = case move.piece
|
|
193
193
|
when 'B', 'R', 'Q', 'b', 'r', 'q' then direction_origins
|
|
194
|
-
when 'K', 'N', 'k', 'n' then
|
|
194
|
+
when 'K', 'N', 'k', 'n' then leaper_origins
|
|
195
195
|
when 'P', 'p' then pawn_origins
|
|
196
196
|
else # don't care move, used in variations
|
|
197
197
|
return nil
|
|
@@ -237,6 +237,18 @@ module PGN
|
|
|
237
237
|
possibilities
|
|
238
238
|
end
|
|
239
239
|
|
|
240
|
+
# Knight/king origins use the precomputed on-board attack masks on
|
|
241
|
+
# {PGN::Board}, skipping the per-call off-board test.
|
|
242
|
+
def leaper_origins
|
|
243
|
+
dest = dest_idx
|
|
244
|
+
mask = move.piece.upcase == 'N' ? Board::KNIGHT_ATTACKS[dest] : Board::KING_ATTACKS[dest]
|
|
245
|
+
piece = move.piece
|
|
246
|
+
|
|
247
|
+
possibilities = []
|
|
248
|
+
mask.each { |t| possibilities << t if board.at_index(t) == piece }
|
|
249
|
+
possibilities
|
|
250
|
+
end
|
|
251
|
+
|
|
240
252
|
# Computes the possible pawn origins based on the destination square
|
|
241
253
|
# and whether or not the move is a capture.
|
|
242
254
|
#
|
data/lib/pgn/notation.rb
CHANGED
|
@@ -133,7 +133,7 @@ module PGN
|
|
|
133
133
|
# Other squares holding `piece` whose move to `to_idx` is legal.
|
|
134
134
|
def same_type_legals(piece, from_idx, to_idx)
|
|
135
135
|
(0...128).each_with_object([]) do |idx, acc|
|
|
136
|
-
next if (
|
|
136
|
+
next if idx.anybits?(0x88)
|
|
137
137
|
next if idx == from_idx
|
|
138
138
|
next unless @board.at_index(idx) == piece
|
|
139
139
|
|
|
@@ -230,9 +230,9 @@ module PGN
|
|
|
230
230
|
|
|
231
231
|
case piece.upcase
|
|
232
232
|
when 'N'
|
|
233
|
-
|
|
233
|
+
Board::KNIGHT_ATTACKS[from_idx].include?(to_idx)
|
|
234
234
|
when 'K'
|
|
235
|
-
|
|
235
|
+
Board::KING_ATTACKS[from_idx].include?(to_idx)
|
|
236
236
|
when 'B', 'R', 'Q'
|
|
237
237
|
slider_reaches?(piece.upcase, from_idx, to_idx)
|
|
238
238
|
else
|
|
@@ -243,7 +243,7 @@ module PGN
|
|
|
243
243
|
def slider_reaches?(piece_up, from_idx, to_idx)
|
|
244
244
|
slider_dirs(piece_up).any? do |off|
|
|
245
245
|
i = from_idx + off
|
|
246
|
-
while (
|
|
246
|
+
while i.nobits?(0x88)
|
|
247
247
|
return true if i == to_idx
|
|
248
248
|
break if @board.at_index(i)
|
|
249
249
|
|
|
@@ -264,7 +264,7 @@ module PGN
|
|
|
264
264
|
end
|
|
265
265
|
|
|
266
266
|
def own?(piece, color)
|
|
267
|
-
color == 'w' ? piece
|
|
267
|
+
piece == (color == 'w' ? piece.upcase : piece.downcase)
|
|
268
268
|
end
|
|
269
269
|
|
|
270
270
|
# Slider ray directions for a piece letter ('B'/'R'/'Q').
|
|
@@ -281,7 +281,7 @@ module PGN
|
|
|
281
281
|
def king_idx(board, color)
|
|
282
282
|
king = color == 'w' ? 'K' : 'k'
|
|
283
283
|
(0...128).each do |idx|
|
|
284
|
-
next if (
|
|
284
|
+
next if idx.anybits?(0x88)
|
|
285
285
|
|
|
286
286
|
return idx if board.at_index(idx) == king
|
|
287
287
|
end
|
|
@@ -301,24 +301,18 @@ module PGN
|
|
|
301
301
|
pawn = color == 'w' ? 'P' : 'p'
|
|
302
302
|
offs.any? do |off|
|
|
303
303
|
i = target + off
|
|
304
|
-
(
|
|
304
|
+
i.nobits?(0x88) && board.at_index(i) == pawn
|
|
305
305
|
end
|
|
306
306
|
end
|
|
307
307
|
|
|
308
308
|
def knight_attacked?(board, target, color)
|
|
309
309
|
knight = color == 'w' ? 'N' : 'n'
|
|
310
|
-
|
|
311
|
-
i = target + off
|
|
312
|
-
(i & 0x88).zero? && board.at_index(i) == knight
|
|
313
|
-
end
|
|
310
|
+
Board::KNIGHT_ATTACKS[target].any? { |i| board.at_index(i) == knight }
|
|
314
311
|
end
|
|
315
312
|
|
|
316
313
|
def king_attacked?(board, target, color)
|
|
317
314
|
king = color == 'w' ? 'K' : 'k'
|
|
318
|
-
|
|
319
|
-
i = target + off
|
|
320
|
-
(i & 0x88).zero? && board.at_index(i) == king
|
|
321
|
-
end
|
|
315
|
+
Board::KING_ATTACKS[target].any? { |i| board.at_index(i) == king }
|
|
322
316
|
end
|
|
323
317
|
|
|
324
318
|
def slider_attacked?(board, target, color)
|
|
@@ -332,10 +326,11 @@ module PGN
|
|
|
332
326
|
def ray_hit?(board, target, dirs)
|
|
333
327
|
dirs.each do |off|
|
|
334
328
|
i = target + off
|
|
335
|
-
while (
|
|
329
|
+
while i.nobits?(0x88)
|
|
336
330
|
piece = board.at_index(i)
|
|
337
331
|
if piece
|
|
338
332
|
return true if yield(piece)
|
|
333
|
+
|
|
339
334
|
break
|
|
340
335
|
end
|
|
341
336
|
i += off
|
|
@@ -352,7 +347,7 @@ module PGN
|
|
|
352
347
|
ep_idx = ep_target_idx(position, board)
|
|
353
348
|
|
|
354
349
|
(0...128).any? do |idx|
|
|
355
|
-
next if (
|
|
350
|
+
next if idx.anybits?(0x88)
|
|
356
351
|
|
|
357
352
|
piece = board.at_index(idx)
|
|
358
353
|
piece && own?(piece, color) && move_from?(board, idx, piece, color, ep_idx)
|
|
@@ -370,8 +365,8 @@ module PGN
|
|
|
370
365
|
def move_from?(board, idx, piece, color, ep_idx)
|
|
371
366
|
up = piece.upcase
|
|
372
367
|
case up
|
|
373
|
-
when 'N' then leaper_moves?(board, idx, piece, color,
|
|
374
|
-
when 'K' then leaper_moves?(board, idx, piece, color,
|
|
368
|
+
when 'N' then leaper_moves?(board, idx, piece, color, Board::KNIGHT_ATTACKS[idx])
|
|
369
|
+
when 'K' then leaper_moves?(board, idx, piece, color, Board::KING_ATTACKS[idx])
|
|
375
370
|
when 'B', 'R', 'Q'
|
|
376
371
|
slider_moves?(board, idx, piece, color, slider_dirs(up))
|
|
377
372
|
when 'P'
|
|
@@ -381,22 +376,22 @@ module PGN
|
|
|
381
376
|
end
|
|
382
377
|
end
|
|
383
378
|
|
|
384
|
-
def leaper_moves?(board, idx, piece, color,
|
|
385
|
-
|
|
386
|
-
t
|
|
387
|
-
(t & 0x88).zero? && landable?(board, t, color) && safe?(board, idx, t, piece, nil)
|
|
379
|
+
def leaper_moves?(board, idx, piece, color, targets)
|
|
380
|
+
targets.any? do |t|
|
|
381
|
+
landable?(board, t, color) && safe?(board, idx, t, piece, nil)
|
|
388
382
|
end
|
|
389
383
|
end
|
|
390
384
|
|
|
391
385
|
def slider_moves?(board, idx, piece, color, dirs)
|
|
392
386
|
dirs.any? do |off|
|
|
393
387
|
t = idx + off
|
|
394
|
-
while (
|
|
388
|
+
while t.nobits?(0x88)
|
|
395
389
|
tp = board.at_index(t)
|
|
396
390
|
if tp.nil?
|
|
397
391
|
return true if safe?(board, idx, t, piece, nil)
|
|
398
392
|
else
|
|
399
393
|
return true if !own?(tp, color) && safe?(board, idx, t, piece, nil)
|
|
394
|
+
|
|
400
395
|
break
|
|
401
396
|
end
|
|
402
397
|
t += off
|
|
@@ -416,24 +411,24 @@ module PGN
|
|
|
416
411
|
promo_rank = color == 'w' ? 7 : 0
|
|
417
412
|
|
|
418
413
|
t = idx + dir
|
|
419
|
-
return false unless (
|
|
414
|
+
return false unless t.nobits?(0x88) && board.at_index(t).nil?
|
|
420
415
|
|
|
421
|
-
promo =
|
|
416
|
+
promo = t >> 4 == promo_rank ? 'Q' : nil
|
|
422
417
|
return true if safe?(board, idx, t, piece, promo)
|
|
423
418
|
|
|
424
419
|
t2 = idx + (dir * 2)
|
|
425
420
|
(idx >> 4) == start_rank && board.at_index(t2).nil? && safe?(board, idx, t2, piece, nil)
|
|
426
421
|
end
|
|
427
422
|
|
|
428
|
-
def pawn_captures?(board, idx, piece, color, ep_idx)
|
|
423
|
+
def pawn_captures?(board, idx, piece, color, ep_idx)
|
|
429
424
|
promo_rank = color == 'w' ? 7 : 0
|
|
430
425
|
caps = color == 'w' ? [15, 17] : [-15, -17]
|
|
431
426
|
caps.any? do |off|
|
|
432
427
|
t = idx + off
|
|
433
|
-
next false unless (
|
|
428
|
+
next false unless t.nobits?(0x88)
|
|
434
429
|
|
|
435
430
|
tp = board.at_index(t)
|
|
436
|
-
promo =
|
|
431
|
+
promo = t >> 4 == promo_rank ? 'Q' : nil
|
|
437
432
|
if tp && !own?(tp, color)
|
|
438
433
|
safe?(board, idx, t, piece, promo)
|
|
439
434
|
elsif tp.nil? && t == ep_idx
|
data/lib/pgn/position.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module PGN
|
|
2
4
|
# {PGN::Position} encapsulates all of the information necessary to
|
|
3
5
|
# completely understand a chess position. It can be turned into a FEN string
|
|
@@ -31,12 +33,7 @@ module PGN
|
|
|
31
33
|
PLAYERS = %i[white black].freeze
|
|
32
34
|
CASTLING = %w[K Q k q].freeze
|
|
33
35
|
|
|
34
|
-
attr_accessor :board
|
|
35
|
-
attr_accessor :player
|
|
36
|
-
attr_accessor :castling
|
|
37
|
-
attr_accessor :en_passant
|
|
38
|
-
attr_accessor :halfmove
|
|
39
|
-
attr_accessor :fullmove
|
|
36
|
+
attr_accessor :board, :player, :castling, :en_passant, :halfmove, :fullmove
|
|
40
37
|
|
|
41
38
|
# @return [PGN::Position] the starting position of a chess game
|
|
42
39
|
#
|
|
@@ -83,17 +80,10 @@ module PGN
|
|
|
83
80
|
|
|
84
81
|
restrictions = calculator.castling_restrictions
|
|
85
82
|
new_castling = restrictions.empty? ? castling : castling - restrictions
|
|
86
|
-
new_halfmove =
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
end
|
|
91
|
-
new_fullmove = if calculator.increment_fullmove?
|
|
92
|
-
fullmove + 1
|
|
93
|
-
else
|
|
94
|
-
fullmove
|
|
95
|
-
end
|
|
96
|
-
no_move = str == '--'
|
|
83
|
+
new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0
|
|
84
|
+
new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove
|
|
85
|
+
no_move = str == '--'
|
|
86
|
+
|
|
97
87
|
PGN::Position.new(
|
|
98
88
|
no_move ? board : calculator.result_board,
|
|
99
89
|
next_player,
|
|
@@ -110,8 +100,32 @@ module PGN
|
|
|
110
100
|
player == :white ? :black : :white
|
|
111
101
|
end
|
|
112
102
|
|
|
103
|
+
# The perft node count at +depth+ from this position, computed by the
|
|
104
|
+
# native bitboard engine via a FEN round-trip. Requires the compiled
|
|
105
|
+
# native extension (the shipped gem); raises NameError if it is absent.
|
|
106
|
+
#
|
|
107
|
+
# @param depth [Integer] search depth, >= 0
|
|
108
|
+
# @return [Integer]
|
|
109
|
+
#
|
|
110
|
+
def perft(depth)
|
|
111
|
+
raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0
|
|
112
|
+
|
|
113
|
+
PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# All legal moves from this position as sorted UCI strings
|
|
117
|
+
# (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed
|
|
118
|
+
# by the native bitboard engine via a FEN round-trip. Requires the
|
|
119
|
+
# compiled native extension; raises NameError if it is absent.
|
|
120
|
+
#
|
|
121
|
+
# @return [Array<String>] sorted lexicographically
|
|
122
|
+
#
|
|
123
|
+
def legal_moves
|
|
124
|
+
PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves
|
|
125
|
+
end
|
|
126
|
+
|
|
113
127
|
def inspect
|
|
114
|
-
"\n
|
|
128
|
+
"\n#{board.inspect}"
|
|
115
129
|
end
|
|
116
130
|
|
|
117
131
|
# @return [PGN::FEN] a {PGN::FEN} object representing the current position
|
|
@@ -120,11 +134,38 @@ module PGN
|
|
|
120
134
|
PGN::FEN.from_attributes(
|
|
121
135
|
board: board,
|
|
122
136
|
active: player == :white ? 'w' : 'b',
|
|
123
|
-
castling: castling.join
|
|
137
|
+
castling: castling.join,
|
|
124
138
|
en_passant: en_passant,
|
|
125
139
|
halfmove: halfmove.to_s,
|
|
126
140
|
fullmove: fullmove.to_s
|
|
127
141
|
)
|
|
128
142
|
end
|
|
143
|
+
|
|
144
|
+
# Positions are equal when their board, side to move, castling rights,
|
|
145
|
+
# and en-passant square match. Halfmove/fullmove counters are ignored
|
|
146
|
+
# (matching threefold-repetition semantics).
|
|
147
|
+
def eql?(other)
|
|
148
|
+
other.is_a?(PGN::Position) &&
|
|
149
|
+
player == other.player &&
|
|
150
|
+
castling == other.castling &&
|
|
151
|
+
en_passant == other.en_passant &&
|
|
152
|
+
zobrist == other.zobrist &&
|
|
153
|
+
board == other.board
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
alias == eql?
|
|
157
|
+
|
|
158
|
+
def hash
|
|
159
|
+
zobrist
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The Zobrist hash of the position. Computed lazily on first access and
|
|
163
|
+
# cached, so the replay hot path (which never asks for the hash) pays
|
|
164
|
+
# nothing; consumers like threefold-repetition checks pay one full seed.
|
|
165
|
+
#
|
|
166
|
+
# @return [Integer]
|
|
167
|
+
def zobrist
|
|
168
|
+
@zobrist ||= Zobrist.seed(board, player, castling, en_passant)
|
|
169
|
+
end
|
|
129
170
|
end
|
|
130
171
|
end
|