pgn2 1.4.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 +183 -1
- data/Gemfile +3 -0
- data/NOTICE.md +21 -0
- data/README.md +107 -5
- data/Rakefile +35 -10
- data/TODO.md +102 -31
- 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 +103 -10
- data/lib/pgn/fen.rb +35 -46
- data/lib/pgn/game.rb +22 -13
- data/lib/pgn/lexer.rb +9 -6
- data/lib/pgn/move.rb +19 -15
- data/lib/pgn/move_calculator.rb +46 -20
- 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 +101 -36
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
|
data/lib/pgn/serializer.rb
CHANGED
|
@@ -17,7 +17,7 @@ module PGN
|
|
|
17
17
|
|
|
18
18
|
# @return [String] a canonical PGN string ending with a trailing newline
|
|
19
19
|
def to_s
|
|
20
|
-
tag_section
|
|
20
|
+
"#{tag_section}\n\n#{movetext_section}\n"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
private
|
|
@@ -39,18 +39,16 @@ module PGN
|
|
|
39
39
|
# result, all joined by spaces.
|
|
40
40
|
def movetext_section
|
|
41
41
|
tokens = []
|
|
42
|
-
if @game.comment && !@game.comment.empty?
|
|
43
|
-
tokens << "{ #{escape_comment(@game.comment)} }"
|
|
44
|
-
end
|
|
42
|
+
tokens << "{ #{escape_comment(@game.comment)} }" if @game.comment && !@game.comment.empty?
|
|
45
43
|
line = emit_line(@game.moves, starting_fullmove, starting_player)
|
|
46
44
|
tokens << line unless line.empty?
|
|
47
45
|
tokens << result_token
|
|
48
|
-
tokens.join(
|
|
46
|
+
tokens.join(' ')
|
|
49
47
|
end
|
|
50
48
|
|
|
51
49
|
# The result token: the game's result if present and non-empty, else "*".
|
|
52
50
|
def result_token
|
|
53
|
-
|
|
51
|
+
@game.result.nil? || @game.result.empty? ? '*' : @game.result
|
|
54
52
|
end
|
|
55
53
|
|
|
56
54
|
# Emit a single line (mainline or variation) of movetext, tracking the
|
|
@@ -63,12 +61,11 @@ module PGN
|
|
|
63
61
|
moves.each do |move|
|
|
64
62
|
if player == :white
|
|
65
63
|
tokens << "#{fullmove}."
|
|
66
|
-
tokens << move_token(move, fullmove, player)
|
|
67
64
|
else # black
|
|
68
65
|
need_number = prev_player.nil? || prev_had_extras || prev_player != :white
|
|
69
66
|
tokens << "#{fullmove}..." if need_number
|
|
70
|
-
tokens << move_token(move, fullmove, player)
|
|
71
67
|
end
|
|
68
|
+
tokens << move_token(move, fullmove, player)
|
|
72
69
|
|
|
73
70
|
prev_player = player
|
|
74
71
|
prev_had_extras = has_extras?(move)
|
|
@@ -76,7 +73,7 @@ module PGN
|
|
|
76
73
|
player = opposite(player)
|
|
77
74
|
end
|
|
78
75
|
|
|
79
|
-
tokens.join(
|
|
76
|
+
tokens.join(' ')
|
|
80
77
|
end
|
|
81
78
|
|
|
82
79
|
# A move token: notation plus trailing extras (annotation, comment,
|
|
@@ -85,13 +82,11 @@ module PGN
|
|
|
85
82
|
def move_token(move, fullmove, player)
|
|
86
83
|
parts = [move.notation]
|
|
87
84
|
(move.annotation || []).each { |a| parts << a }
|
|
88
|
-
if move.comment && !move.comment.empty?
|
|
89
|
-
parts << "{ #{escape_comment(move.comment)} }"
|
|
90
|
-
end
|
|
85
|
+
parts << "{ #{escape_comment(move.comment)} }" if move.comment && !move.comment.empty?
|
|
91
86
|
(move.variations || []).each do |variation|
|
|
92
87
|
parts << "(#{emit_line(variation, fullmove, player)})"
|
|
93
88
|
end
|
|
94
|
-
parts.join(
|
|
89
|
+
parts.join(' ')
|
|
95
90
|
end
|
|
96
91
|
|
|
97
92
|
# Whether a move carries any annotation, comment, or variation.
|
|
@@ -118,8 +113,8 @@ module PGN
|
|
|
118
113
|
# string replacement would otherwise re-interpret backslashes).
|
|
119
114
|
def escape_tag(value)
|
|
120
115
|
value.to_s
|
|
121
|
-
.gsub(
|
|
122
|
-
.gsub('"') { "
|
|
116
|
+
.gsub('\\') { '\\\\' }
|
|
117
|
+
.gsub('"') { '\"' }
|
|
123
118
|
end
|
|
124
119
|
|
|
125
120
|
# Escape backslashes and braces for a comment body (block form, so the
|
|
@@ -133,9 +128,9 @@ module PGN
|
|
|
133
128
|
# acknowledged v1 limitation.
|
|
134
129
|
def escape_comment(text)
|
|
135
130
|
text.to_s
|
|
136
|
-
.gsub(
|
|
137
|
-
.gsub(
|
|
138
|
-
.gsub(
|
|
131
|
+
.gsub('\\') { '\\\\' }
|
|
132
|
+
.gsub('{') { '\\{' }
|
|
133
|
+
.gsub('}') { '\\}' }
|
|
139
134
|
end
|
|
140
135
|
end
|
|
141
136
|
end
|
data/lib/pgn/version.rb
CHANGED
data/lib/pgn/zobrist.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGN
|
|
4
|
+
# Zobrist hashing keys for incremental position hashing. All keys are
|
|
5
|
+
# fixed 64-bit pseudo-random Integers generated once at load time from a
|
|
6
|
+
# frozen seed so hashes are stable for the life of the process.
|
|
7
|
+
#
|
|
8
|
+
# Indexing is by 0x88 square index (0..127); off-board indices are
|
|
9
|
+
# allocated but never read.
|
|
10
|
+
module Zobrist
|
|
11
|
+
SEED = 0x1234_5678_9abc_def1
|
|
12
|
+
|
|
13
|
+
# Deterministic pseudo-random generator so hashes are stable per
|
|
14
|
+
# process and across machines (no Kernel#rand).
|
|
15
|
+
def self.gen
|
|
16
|
+
@gen ||= Random.new(SEED)
|
|
17
|
+
end
|
|
18
|
+
private_class_method :gen
|
|
19
|
+
|
|
20
|
+
def self.rand64
|
|
21
|
+
gen.rand(1 << 64)
|
|
22
|
+
end
|
|
23
|
+
private_class_method :rand64
|
|
24
|
+
|
|
25
|
+
PIECES = %w[P N B R Q K p n b r q k].freeze
|
|
26
|
+
|
|
27
|
+
TABLE = PIECES.to_h { |piece| [piece, Array.new(128) { rand64 }] }.freeze
|
|
28
|
+
|
|
29
|
+
SIDE = rand64
|
|
30
|
+
CASTLING = { 'K' => rand64, 'Q' => rand64, 'k' => rand64, 'q' => rand64 }.freeze
|
|
31
|
+
EP_FILE = Array.new(8) { rand64 }.freeze
|
|
32
|
+
|
|
33
|
+
# @param board [PGN::Board]
|
|
34
|
+
# @param player [Symbol] :white or :black
|
|
35
|
+
# @param castling [Array<String>] e.g. %w[K Q k q]
|
|
36
|
+
# @param en_passant [String, nil] e.g. "e3" or nil
|
|
37
|
+
# @return [Integer] the Zobrist hash of the position
|
|
38
|
+
def self.seed(board, player, castling, en_passant)
|
|
39
|
+
h = 0
|
|
40
|
+
0.upto(7) do |rank|
|
|
41
|
+
0.upto(7) do |file|
|
|
42
|
+
idx = board.index_for(file, rank)
|
|
43
|
+
piece = board.at_index(idx)
|
|
44
|
+
h ^= TABLE[piece][idx] if piece
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
h ^= SIDE if player == :black
|
|
48
|
+
castling.to_a.each { |right| h ^= CASTLING[right] if CASTLING.key?(right) }
|
|
49
|
+
h ^= EP_FILE[Board::FILE_TO_INDEX[en_passant[0]]] if en_passant && !en_passant.empty?
|
|
50
|
+
h
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/pgn.rb
CHANGED
|
@@ -5,11 +5,13 @@ require 'pgn/move'
|
|
|
5
5
|
require 'pgn/move_calculator'
|
|
6
6
|
require 'pgn/lexer'
|
|
7
7
|
require 'pgn/notation'
|
|
8
|
+
require 'pgn/zobrist'
|
|
8
9
|
require 'pgn/pgn_parser'
|
|
9
10
|
require 'pgn/parser'
|
|
10
11
|
require 'pgn/position'
|
|
11
12
|
require 'pgn/serializer'
|
|
12
13
|
require 'pgn/version'
|
|
14
|
+
require 'pgn/bitboard'
|
|
13
15
|
|
|
14
16
|
module PGN
|
|
15
17
|
# @param pgn [String] a pgn representation of one or more chess games
|
data/pgn2.gemspec
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require 'English'
|
|
1
2
|
lib = File.expand_path('lib', __dir__)
|
|
2
3
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
3
4
|
require 'pgn/version'
|
|
@@ -10,27 +11,33 @@ Gem::Specification.new do |spec|
|
|
|
10
11
|
spec.description = 'A PGN parser and FEN generator for Ruby'
|
|
11
12
|
spec.summary = 'A PGN parser for Ruby'
|
|
12
13
|
spec.homepage = 'https://github.com/muriloime/pgn'
|
|
13
|
-
spec.
|
|
14
|
+
spec.licenses = ['MIT', 'MPL-2.0']
|
|
14
15
|
spec.required_ruby_version = '>= 3.0'
|
|
15
16
|
|
|
16
17
|
spec.metadata = {
|
|
17
|
-
'homepage_uri'
|
|
18
|
-
'source_code_uri'
|
|
19
|
-
'bug_tracker_uri'
|
|
20
|
-
'changelog_uri'
|
|
18
|
+
'homepage_uri' => 'https://github.com/muriloime/pgn',
|
|
19
|
+
'source_code_uri' => 'https://github.com/muriloime/pgn',
|
|
20
|
+
'bug_tracker_uri' => 'https://github.com/muriloime/pgn/issues',
|
|
21
|
+
'changelog_uri' => 'https://github.com/muriloime/pgn/blob/main/CHANGELOG.md',
|
|
22
|
+
'rubygems_mfa_required' => 'true'
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
spec.files = `git ls-files`.split(
|
|
25
|
+
spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
|
|
24
26
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
|
25
|
-
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
|
26
27
|
spec.require_paths = ['lib']
|
|
27
28
|
|
|
29
|
+
spec.extensions = ['ext/pgn2_native/extconf.rb']
|
|
30
|
+
spec.add_dependency 'rb_sys', '~> 0.9.39'
|
|
31
|
+
spec.add_development_dependency 'rake-compiler', '~> 1.2'
|
|
32
|
+
spec.add_development_dependency 'rake-compiler-dock', '~> 1.6'
|
|
33
|
+
|
|
34
|
+
spec.add_development_dependency 'benchmark-ips'
|
|
28
35
|
spec.add_development_dependency 'bundler', '~> 2.3'
|
|
36
|
+
spec.add_development_dependency 'bundler-audit'
|
|
37
|
+
spec.add_development_dependency 'memory_profiler'
|
|
29
38
|
spec.add_development_dependency 'pry'
|
|
39
|
+
spec.add_development_dependency 'racc'
|
|
30
40
|
spec.add_development_dependency 'rake'
|
|
31
41
|
spec.add_development_dependency 'rspec'
|
|
32
|
-
spec.add_development_dependency 'racc'
|
|
33
|
-
spec.add_development_dependency 'benchmark-ips'
|
|
34
|
-
spec.add_development_dependency 'memory_profiler'
|
|
35
42
|
spec.add_development_dependency 'rubocop', '~> 1.60'
|
|
36
43
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe PGN::Bitboard::Engine do
|
|
4
|
+
let(:startpos) { 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' }
|
|
5
|
+
|
|
6
|
+
# The native extension is optional from the gem's perspective; skip the
|
|
7
|
+
# whole suite if it isn't compiled in this environment.
|
|
8
|
+
skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
|
|
9
|
+
|
|
10
|
+
it 'perfts the start position' do
|
|
11
|
+
e = described_class.new(startpos)
|
|
12
|
+
expect(e.perft(1)).to eq(20)
|
|
13
|
+
expect(e.perft(2)).to eq(400)
|
|
14
|
+
expect(e.perft(3)).to eq(8902)
|
|
15
|
+
expect(e.perft(4)).to eq(197_281)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'perfts Kiwipete' do
|
|
19
|
+
e = described_class.new('r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1')
|
|
20
|
+
expect(e.perft(1)).to eq(48)
|
|
21
|
+
expect(e.perft(3)).to eq(97_862)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'raises on a bad FEN' do
|
|
25
|
+
expect { described_class.new('not a fen') }.to raise_error(ArgumentError)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'lists legal moves in sorted UCI' do
|
|
29
|
+
e = described_class.new(startpos)
|
|
30
|
+
moves = e.legal_moves
|
|
31
|
+
expect(moves.length).to eq(20)
|
|
32
|
+
expect(moves).to eq(moves.sort)
|
|
33
|
+
expect(moves).to include('e2e4', 'g1f3', 'd2d4')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'answers legal? with UCI' do
|
|
37
|
+
e = described_class.new(startpos)
|
|
38
|
+
expect(e.legal?('e2e4')).to be(true)
|
|
39
|
+
expect(e.legal?('e2e5')).to be(false)
|
|
40
|
+
expect(e.legal?('g1f3')).to be(true)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'encodes promotions in legal_moves' do
|
|
44
|
+
e = described_class.new('8/P7/8/8/8/8/8/4k2K w - - 0 1')
|
|
45
|
+
moves = e.legal_moves
|
|
46
|
+
expect(moves).to include('a7a8q', 'a7a8r', 'a7a8b', 'a7a8n')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'recognizes castling as legal' do
|
|
50
|
+
e = described_class.new('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1')
|
|
51
|
+
expect(e.legal?('e1g1')).to be(true)
|
|
52
|
+
expect(e.legal?('e1c1')).to be(true)
|
|
53
|
+
end
|
|
54
|
+
end
|
data/spec/board_spec.rb
CHANGED
|
@@ -7,6 +7,36 @@ describe PGN::Board do
|
|
|
7
7
|
end
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
describe 'attack masks' do
|
|
11
|
+
it 'KNIGHT_ATTACKS lists the on-board knight targets for each 0x88 index' do
|
|
12
|
+
expect(PGN::Board::KNIGHT_ATTACKS[0]).to contain_exactly(18, 33) # a1: c3(0x12), b3(0x21)
|
|
13
|
+
expect(PGN::Board::KNIGHT_ATTACKS[0x54].length).to eq(8) # e4 = 0x54, centre
|
|
14
|
+
expect(PGN::Board::KNIGHT_ATTACKS[0x77]).to contain_exactly(86, 101) # h8: f7(0x56), g6(0x65)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'KING_ATTACKS lists the on-board king targets for each 0x88 index' do
|
|
18
|
+
expect(PGN::Board::KING_ATTACKS[0]).to contain_exactly(1, 16, 17) # a1: b1, a2, b2
|
|
19
|
+
expect(PGN::Board::KING_ATTACKS[0x54].length).to eq(8)
|
|
20
|
+
expect(PGN::Board::KING_ATTACKS[0x77]).to contain_exactly(102, 103, 118) # h8: g7, h7, g8
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'only contains on-board squares and is nil for off-board source indices' do
|
|
24
|
+
on_board = (0...128).select { |i| i.nobits?(0x88) }
|
|
25
|
+
expect(PGN::Board::KNIGHT_ATTACKS.length).to eq(128)
|
|
26
|
+
expect(PGN::Board::KING_ATTACKS.length).to eq(128)
|
|
27
|
+
on_board.each do |i|
|
|
28
|
+
expect(PGN::Board::KNIGHT_ATTACKS[i]).to be_an(Array)
|
|
29
|
+
expect(PGN::Board::KING_ATTACKS[i]).to be_an(Array)
|
|
30
|
+
expect(PGN::Board::KNIGHT_ATTACKS[i]).to all(satisfy { |sq| sq.nobits?(0x88) })
|
|
31
|
+
expect(PGN::Board::KING_ATTACKS[i]).to all(satisfy { |sq| sq.nobits?(0x88) })
|
|
32
|
+
end
|
|
33
|
+
(0...128).reject { |i| i.nobits?(0x88) }.each do |i|
|
|
34
|
+
expect(PGN::Board::KNIGHT_ATTACKS[i]).to be_nil
|
|
35
|
+
expect(PGN::Board::KING_ATTACKS[i]).to be_nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
10
40
|
describe '#at' do
|
|
11
41
|
# Use .dup because PGN::Board.start returns the same shared board
|
|
12
42
|
# backed by the frozen START constant.
|
|
@@ -100,6 +130,29 @@ describe PGN::Board do
|
|
|
100
130
|
end
|
|
101
131
|
end
|
|
102
132
|
|
|
133
|
+
describe '#fen_board_string' do
|
|
134
|
+
it 'serializes the start position to the FEN board string' do
|
|
135
|
+
expect(PGN::Board.start.fen_board_string)
|
|
136
|
+
.to eq('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR')
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'collapses runs of empty squares into digits' do
|
|
140
|
+
board = PGN::FEN.new('8/8/8/8/8/8/8/8 w - - 0 1').board
|
|
141
|
+
expect(board.fen_board_string).to eq('8/8/8/8/8/8/8/8')
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'serializes a mid-game board byte-for-byte like FEN#board_string' do
|
|
145
|
+
fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
|
|
146
|
+
board = PGN::FEN.new(fen).board
|
|
147
|
+
expect(board.fen_board_string).to eq(fen.split.first)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'serializes rank 8 first and rank 1 last (FEN order)' do
|
|
151
|
+
board = PGN::FEN.new('4k3/8/8/8/8/8/8/4K3 w - - 0 1').board
|
|
152
|
+
expect(board.fen_board_string).to eq('4k3/8/8/8/8/8/8/4K3')
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
103
156
|
describe '#inspect' do
|
|
104
157
|
it 'returns a string with unicode pieces and newlines' do
|
|
105
158
|
inspected = PGN::Board.start.inspect
|