pgn2 1.5.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.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 +43 -0
- data/.github/workflows/release.yml +52 -5
- data/.gitignore +9 -1
- data/.rubocop.yml +46 -6
- data/CHANGELOG.md +215 -0
- data/Gemfile +3 -0
- data/NOTICE.md +21 -0
- data/README.md +134 -5
- data/Rakefile +53 -10
- data/TODO.md +44 -62
- 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/plans/2026-08-15-game-tree-api-plan.md +1014 -0
- data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -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/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -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/attack.rb +97 -0
- data/lib/pgn/bitboard.rb +13 -0
- data/lib/pgn/board.rb +64 -0
- data/lib/pgn/epd.rb +81 -0
- data/lib/pgn/fen.rb +42 -46
- data/lib/pgn/game.rb +104 -16
- data/lib/pgn/move.rb +20 -16
- data/lib/pgn/move_calculator.rb +13 -1
- data/lib/pgn/node.rb +372 -0
- data/lib/pgn/notation.rb +26 -89
- data/lib/pgn/pgn_parser.rb +30 -31
- data/lib/pgn/pgn_parser.y +14 -15
- data/lib/pgn/position.rb +251 -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 +5 -0
- data/pgn2.gemspec +17 -10
- data/spec/bitboard_spec.rb +54 -0
- data/spec/board_spec.rb +53 -0
- data/spec/castling_normalization_spec.rb +39 -0
- data/spec/comment_round_trip_spec.rb +35 -0
- data/spec/epd_spec.rb +44 -0
- data/spec/fen_spec.rb +71 -65
- data/spec/game_history_spec.rb +46 -0
- data/spec/game_spec.rb +112 -15
- data/spec/lexer_spec.rb +5 -5
- data/spec/movetext_clean_spec.rb +44 -0
- data/spec/node_spec.rb +240 -0
- data/spec/notation_spec.rb +5 -0
- data/spec/outcome_spec.rb +93 -0
- data/spec/parser_left_recursion_spec.rb +37 -0
- data/spec/parser_spec.rb +8 -1
- data/spec/position_attack_spec.rb +56 -0
- data/spec/position_legal_spec.rb +123 -0
- data/spec/position_spec.rb +128 -27
- data/spec/serializer_spec.rb +4 -4
- data/spec/zobrist_spec.rb +46 -0
- metadata +113 -35
data/lib/pgn/attack.rb
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGN
|
|
4
|
+
# {PGN::Attack} is the single source of truth for square-attack queries on
|
|
5
|
+
# a {PGN::Board}. It factors the private attack logic that {PGN::Notation}
|
|
6
|
+
# previously duplicated, so {PGN::Position} (and other consumers) can ask
|
|
7
|
+
# "is this square attacked?" and "which squares attack this square?".
|
|
8
|
+
#
|
|
9
|
+
# Squares are 0x88 indices (see {PGN::Board}); on-board when
|
|
10
|
+
# `(idx & 0x88).zero?`. `color` is 'w' or 'b'.
|
|
11
|
+
module Attack
|
|
12
|
+
BISHOP_DIRS = [-15, 15, -17, 17].freeze
|
|
13
|
+
ROOK_DIRS = [-1, 1, -16, 16].freeze
|
|
14
|
+
|
|
15
|
+
# The 0x88 index of the `color` king on +board+, or nil if absent.
|
|
16
|
+
def self.king_idx(board, color)
|
|
17
|
+
king = color == 'w' ? 'K' : 'k'
|
|
18
|
+
(0...128).each do |idx|
|
|
19
|
+
next if idx.anybits?(0x88)
|
|
20
|
+
|
|
21
|
+
return idx if board.at_index(idx) == king
|
|
22
|
+
end
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Whether +target+ (a 0x88 index) is attacked by any `color` piece.
|
|
27
|
+
def self.attacked?(board, target, color)
|
|
28
|
+
attackers(board, target, color).any?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The algebraic squares of every `color` piece on +board+ that attacks
|
|
32
|
+
# +target+ (a 0x88 index), in no particular order.
|
|
33
|
+
def self.attackers(board, target, color)
|
|
34
|
+
pawn_attackers(board, target, color) +
|
|
35
|
+
knight_attackers(board, target, color) +
|
|
36
|
+
king_attackers(board, target, color) +
|
|
37
|
+
slider_attackers(board, target, color)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def pawn_attackers(board, target, color)
|
|
44
|
+
offs = color == 'w' ? [-15, -17] : [15, 17]
|
|
45
|
+
pawn = color == 'w' ? 'P' : 'p'
|
|
46
|
+
offs.each_with_object([]) do |off, a|
|
|
47
|
+
i = target + off
|
|
48
|
+
a << board.square_name(i) if i.nobits?(0x88) && board.at_index(i) == pawn
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def knight_attackers(board, target, color)
|
|
53
|
+
knight = color == 'w' ? 'N' : 'n'
|
|
54
|
+
Board::KNIGHT_ATTACKS[target].each_with_object([]) do |i, a|
|
|
55
|
+
a << board.square_name(i) if board.at_index(i) == knight
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def king_attackers(board, target, color)
|
|
60
|
+
king = color == 'w' ? 'K' : 'k'
|
|
61
|
+
Board::KING_ATTACKS[target].each_with_object([]) do |i, a|
|
|
62
|
+
a << board.square_name(i) if board.at_index(i) == king
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def slider_attackers(board, target, color)
|
|
67
|
+
bishop = color == 'w' ? 'B' : 'b'
|
|
68
|
+
rook = color == 'w' ? 'R' : 'r'
|
|
69
|
+
queen = color == 'w' ? 'Q' : 'q'
|
|
70
|
+
squares = []
|
|
71
|
+
ray_attackers(board, target, BISHOP_DIRS) do |piece, sq|
|
|
72
|
+
squares << sq if piece == bishop || piece == queen
|
|
73
|
+
end
|
|
74
|
+
ray_attackers(board, target, ROOK_DIRS) do |piece, sq|
|
|
75
|
+
squares << sq if piece == rook || piece == queen
|
|
76
|
+
end
|
|
77
|
+
squares
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Walk each ray from +target+; yield the first piece hit on that ray
|
|
81
|
+
# along with its square.
|
|
82
|
+
def ray_attackers(board, target, dirs)
|
|
83
|
+
dirs.each do |off|
|
|
84
|
+
i = target + off
|
|
85
|
+
while i.nobits?(0x88)
|
|
86
|
+
piece = board.at_index(i)
|
|
87
|
+
if piece
|
|
88
|
+
yield(piece, board.square_name(i))
|
|
89
|
+
break
|
|
90
|
+
end
|
|
91
|
+
i += off
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/pgn/bitboard.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Load the chessie-backed native bitboard engine if the compiled
|
|
4
|
+
# extension is available. End users get the precompiled native gem (no
|
|
5
|
+
# Rust toolchain needed); if the extension is absent (e.g. running from a
|
|
6
|
+
# source checkout without `rake compile`), the load fails silently so
|
|
7
|
+
# the rest of the gem still works. Code that calls PGN::Bitboard::Engine
|
|
8
|
+
# will raise NameError naturally.
|
|
9
|
+
begin
|
|
10
|
+
require 'pgn2_native/pgn2_native'
|
|
11
|
+
rescue StandardError
|
|
12
|
+
LoadError
|
|
13
|
+
end
|
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/epd.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGN
|
|
4
|
+
# {PGN::EPD} translates between strings in Extended Position Description
|
|
5
|
+
# and a {PGN::Position}. EPD is the FEN-like position format used by
|
|
6
|
+
# {http://www.chessprogramming.org/Extended_Position_Description EPD tools}:
|
|
7
|
+
# it shares FEN's first four fields (piece placement, side to move,
|
|
8
|
+
# castling availability, en passant target square) and then carries a
|
|
9
|
+
# trailing list of operations (`ops`) instead of the halfmove/fullmove
|
|
10
|
+
# counters.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# PGN::EPD.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -')
|
|
14
|
+
# PGN::FEN.start.to_epd #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -"
|
|
15
|
+
#
|
|
16
|
+
class EPD
|
|
17
|
+
attr_accessor :board, :active, :ops
|
|
18
|
+
attr_reader :castling, :en_passant
|
|
19
|
+
|
|
20
|
+
# @param epd_string [String] an EPD string: four FEN fields followed by
|
|
21
|
+
# zero or more operation fields (kept verbatim as {#ops}).
|
|
22
|
+
#
|
|
23
|
+
def initialize(epd_string = nil)
|
|
24
|
+
return unless epd_string
|
|
25
|
+
|
|
26
|
+
fields = epd_string.split(' ', 5)
|
|
27
|
+
self.board_string = fields[0]
|
|
28
|
+
self.active = fields[1]
|
|
29
|
+
self.castling = fields[2]
|
|
30
|
+
self.en_passant = fields[3]
|
|
31
|
+
self.ops = fields[4]
|
|
32
|
+
end
|
|
33
|
+
|
|
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
|
+
# @return [PGN::Position] a {PGN::Position} for this EPD. Halfmove and
|
|
61
|
+
# fullmove default to 0 and 1 (EPD does not carry them).
|
|
62
|
+
#
|
|
63
|
+
def to_position
|
|
64
|
+
player = active == 'w' ? :white : :black
|
|
65
|
+
castling_rights = castling.chars - ['-']
|
|
66
|
+
ep = en_passant == '-' ? nil : en_passant
|
|
67
|
+
|
|
68
|
+
PGN::Position.new(board, player, castling_rights, ep, 0, 1)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [String] the EPD string (four fields, then +ops+ if present)
|
|
72
|
+
#
|
|
73
|
+
def to_s
|
|
74
|
+
[board_string, active, castling, en_passant, ops].compact.reject(&:empty?).join(' ')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inspect
|
|
78
|
+
to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
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,59 +102,51 @@ 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 == '-' ? nil : en_passant
|
|
112
|
+
player = active == 'w' ? :white : :black
|
|
113
|
+
castling = self.castling.chars - ['-']
|
|
114
|
+
en_passant = self.en_passant == '-' ? nil : 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
|
|
|
126
|
+
# @return [String] the EPD string for this position (the first four FEN
|
|
127
|
+
# fields, dropping the halfmove/fullmove counters)
|
|
128
|
+
#
|
|
129
|
+
def to_epd
|
|
130
|
+
PGN::EPD.new("#{board_string} #{active} #{castling} #{en_passant}").to_s
|
|
131
|
+
end
|
|
132
|
+
|
|
137
133
|
# @return [String] the FEN string
|
|
138
134
|
# @example
|
|
139
135
|
# PGN::FEN.start.to_s #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
|
140
136
|
#
|
|
141
137
|
def to_s
|
|
142
138
|
[
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
].join(
|
|
139
|
+
board_string,
|
|
140
|
+
active,
|
|
141
|
+
castling,
|
|
142
|
+
en_passant,
|
|
143
|
+
halfmove,
|
|
144
|
+
fullmove
|
|
145
|
+
].join(' ')
|
|
150
146
|
end
|
|
151
147
|
|
|
152
148
|
def inspect
|
|
153
|
-
|
|
149
|
+
to_s
|
|
154
150
|
end
|
|
155
151
|
end
|
|
156
152
|
end
|
data/lib/pgn/game.rb
CHANGED
|
@@ -30,7 +30,14 @@ module PGN
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def clean_text(text)
|
|
33
|
-
|
|
33
|
+
return unless text
|
|
34
|
+
|
|
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
|
+
text = text[1..-2] if text.start_with?('{') && text.end_with?('}')
|
|
39
|
+
text = text.gsub(/\\([\\{}])/, '\1')
|
|
40
|
+
text.gsub(/\s+/, ' ').strip
|
|
34
41
|
end
|
|
35
42
|
end
|
|
36
43
|
|
|
@@ -106,16 +113,25 @@ module PGN
|
|
|
106
113
|
# @return [Array<PGN::Position>] list of the {PGN::Position}s in the game
|
|
107
114
|
#
|
|
108
115
|
def positions
|
|
109
|
-
@positions ||=
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
@positions ||= each_position.to_a
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Enumerator, self] with a block: yields each {PGN::Position}
|
|
120
|
+
# in order (starting position, then one per move) and returns self.
|
|
121
|
+
# Without a block: returns an Enumerator that yields the same.
|
|
122
|
+
#
|
|
123
|
+
# The replay loop is shared with {#positions} so eager and lazy paths
|
|
124
|
+
# produce identical position objects in identical order.
|
|
125
|
+
def each_position
|
|
126
|
+
return enum_for(:each_position) unless block_given?
|
|
127
|
+
|
|
128
|
+
position = starting_position
|
|
129
|
+
yield position
|
|
130
|
+
moves.each do |move|
|
|
131
|
+
position = position.move(move.notation)
|
|
132
|
+
yield position
|
|
118
133
|
end
|
|
134
|
+
self
|
|
119
135
|
end
|
|
120
136
|
|
|
121
137
|
# @return [Array<String>] list of the fen representations of the positions
|
|
@@ -124,6 +140,70 @@ module PGN
|
|
|
124
140
|
positions.map { |p| p.to_fen.inspect }
|
|
125
141
|
end
|
|
126
142
|
|
|
143
|
+
# The current {PGN::Position} (the last position after replaying all
|
|
144
|
+
# moves), or the starting position when there are no moves.
|
|
145
|
+
#
|
|
146
|
+
# @return [PGN::Position]
|
|
147
|
+
def current_position
|
|
148
|
+
positions.last
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Append a move in SAN, validating legality when the native engine is
|
|
152
|
+
# available. Raises ArgumentError for an illegal move (when the engine is
|
|
153
|
+
# loaded) and invalidates the memoized position list.
|
|
154
|
+
#
|
|
155
|
+
# @param san [String, PGN::MoveText] the move to append
|
|
156
|
+
# @return [self]
|
|
157
|
+
def push(san)
|
|
158
|
+
move = standardize_castling(san)
|
|
159
|
+
if PGN::Bitboard.const_defined?(:Engine, false) && !current_position.legal?(move.notation)
|
|
160
|
+
raise ArgumentError, "illegal move: #{san}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
@moves << move
|
|
164
|
+
@positions = nil
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Remove and return the last move, or nil if there are none. Invalidates
|
|
169
|
+
# the memoized position list.
|
|
170
|
+
#
|
|
171
|
+
# @return [PGN::MoveText, nil]
|
|
172
|
+
def pop
|
|
173
|
+
return nil if @moves.empty?
|
|
174
|
+
|
|
175
|
+
move = @moves.pop
|
|
176
|
+
@positions = nil
|
|
177
|
+
move
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Whether any position has occurred three times in this game (the
|
|
181
|
+
# threefold-repetition draw). Uses {PGN::Position#hash} (the Zobrist
|
|
182
|
+
# hash of the FEN-relevant state), streaming {#each_position} so the
|
|
183
|
+
# full position array need not be materialized for this check.
|
|
184
|
+
#
|
|
185
|
+
# @return [Boolean]
|
|
186
|
+
def threefold?
|
|
187
|
+
counts = Hash.new(0)
|
|
188
|
+
each_position { |position| counts[position.hash] += 1 }
|
|
189
|
+
counts.any? { |_, count| count >= 3 }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The terminal status of the game: :checkmate, :stalemate, or :draw
|
|
193
|
+
# (insufficient material, 50-move rule, or threefold repetition). nil
|
|
194
|
+
# if the game is still in progress. Requires the native extension for
|
|
195
|
+
# checkmate/stalemate detection.
|
|
196
|
+
#
|
|
197
|
+
# @return [Symbol, nil]
|
|
198
|
+
def outcome
|
|
199
|
+
final = positions.last
|
|
200
|
+
result = final&.outcome
|
|
201
|
+
return result if result
|
|
202
|
+
return :draw if threefold?
|
|
203
|
+
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
127
207
|
# Interactively step through the game
|
|
128
208
|
#
|
|
129
209
|
# Use +d+ to move forward, +a+ to move backward, and +^C+ to exit.
|
|
@@ -148,19 +228,27 @@ module PGN
|
|
|
148
228
|
end
|
|
149
229
|
end
|
|
150
230
|
|
|
231
|
+
# Build a fresh, navigable {PGN::Node} tree over the mainline. The tree
|
|
232
|
+
# is a live view of the underlying +MoveText+ structure; mutate it
|
|
233
|
+
# through the node API, then call +#root+ again for a fresh tree.
|
|
234
|
+
def root
|
|
235
|
+
PGN::Node.new(
|
|
236
|
+
move: nil, parent: nil, line: @moves, index: -1,
|
|
237
|
+
starting_position: starting_position, game: self
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
151
241
|
private
|
|
152
242
|
|
|
153
243
|
# A MoveText is reused as-is (no new object) when its notation needs no
|
|
154
|
-
# '0'->'O' fix
|
|
155
|
-
#
|
|
156
|
-
#
|
|
244
|
+
# '0'->'O' fix; otherwise a new MoveText is built. clean_text is idempotent
|
|
245
|
+
# (it only strips a *single* outermost brace pair), so reusing or rebuilding
|
|
246
|
+
# a MoveText never corrupts a comment that still contains inner braces.
|
|
157
247
|
def standardize_castling(entry)
|
|
158
248
|
return MoveText.new(entry.include?('0') ? entry.gsub('0', 'O') : entry) if entry.is_a?(String)
|
|
159
249
|
|
|
160
250
|
notation = entry.notation.include?('0') ? entry.notation.gsub('0', 'O') : entry.notation
|
|
161
|
-
if notation.equal?(entry.notation)
|
|
162
|
-
return entry
|
|
163
|
-
end
|
|
251
|
+
return entry if notation.equal?(entry.notation)
|
|
164
252
|
|
|
165
253
|
MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
|
|
166
254
|
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>
|
|
77
|
+
(?<castle> [O0]-[O0](?:-[O0])? ){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
|
|
@@ -108,17 +112,17 @@ module PGN
|
|
|
108
112
|
# Castling SAN (O-O / O-O-O) has no piece attribute; the castle attribute
|
|
109
113
|
# is set later in #initialize. Use a non-allocating prefix check instead
|
|
110
114
|
# of `san.match('O-O')`, which allocated a MatchData on every Move.new.
|
|
111
|
-
return if san.start_with?('O')
|
|
115
|
+
return if san.start_with?('O') || san.start_with?('0')
|
|
112
116
|
|
|
113
117
|
val ||= 'P'
|
|
114
118
|
@piece = black? ? val.downcase : val
|
|
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 %w[O-O 0-0].include?(val)
|
|
140
|
+
@castle = 'Q' if %w[O-O-O 0-0-0].include?(val)
|
|
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
|
#
|