feen 5.0.0.beta0 → 5.0.0.beta2

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.
@@ -2,59 +2,219 @@
2
2
 
3
3
  module Feen
4
4
  module Parser
5
- # The PiecePlacement class.
6
- #
7
- # @example Parse a Shogi problem board and return an array
8
- # PiecePlacement.new("3,s,k,s,3/9/4,+P,4/9/7,+B,1/9/9/9/9").to_a
9
- # # => [
10
- # # nil, nil, nil, "s", "k", "s", nil, nil, nil,
11
- # # nil, nil, nil, nil, nil, nil, nil, nil, nil,
12
- # # nil, nil, nil, nil, "+P", nil, nil, nil, nil,
13
- # # nil, nil, nil, nil, nil, nil, nil, nil, nil,
14
- # # nil, nil, nil, nil, nil, nil, nil, "+B", nil,
15
- # # nil, nil, nil, nil, nil, nil, nil, nil, nil,
16
- # # nil, nil, nil, nil, nil, nil, nil, nil, nil,
17
- # # nil, nil, nil, nil, nil, nil, nil, nil, nil,
18
- # # nil, nil, nil, nil, nil, nil, nil, nil, nil
19
- # # ]
20
- #
21
- # @example Parse a Shogi problem board and return a hash
22
- # PiecePlacement.new("3,s,k,s,3/9/4,+P,4/9/7,+B,1/9/9/9/9").to_h
23
- # # => {
24
- # # 3 => "s",
25
- # # 4 => "k",
26
- # # 5 => "s",
27
- # # 22 => "+P",
28
- # # 43 => "+B"
29
- # # }
30
- class PiecePlacement
31
- # @param piece_placement_str [String] The placement of pieces on the board.
32
- def initialize(piece_placement_str)
33
- @piece_placement_str = piece_placement_str
5
+ # Handles parsing of the piece placement section of a FEEN string
6
+ module PiecePlacement
7
+ # Error messages
8
+ ERRORS = {
9
+ invalid_type: "Piece placement must be a string, got %s",
10
+ empty_string: "Piece placement string cannot be empty",
11
+ invalid_chars: "Invalid characters in piece placement: %s",
12
+ invalid_prefix: "Expected piece identifier after '+' prefix",
13
+ invalid_piece: "Invalid piece identifier at position %d: %s",
14
+ trailing_separator: "Unexpected separator at the end of string or dimension group"
15
+ }.freeze
16
+
17
+ # Valid characters for validation
18
+ VALID_CHARS_PATTERN = %r{\A[a-zA-Z0-9/+<=>\s]+\z}
19
+
20
+ # Empty string for initialization
21
+ EMPTY_STRING = ""
22
+
23
+ # Dimension separator character
24
+ DIMENSION_SEPARATOR = "/"
25
+
26
+ # Piece promotion prefix
27
+ PREFIX_PROMOTION = "+"
28
+
29
+ # Valid piece suffixes
30
+ SUFFIX_EQUALS = "="
31
+ SUFFIX_LEFT = "<"
32
+ SUFFIX_RIGHT = ">"
33
+ VALID_SUFFIXES = [SUFFIX_EQUALS, SUFFIX_LEFT, SUFFIX_RIGHT].freeze
34
+
35
+ # Parses the piece placement section of a FEEN string
36
+ #
37
+ # @param piece_placement_str [String] FEEN piece placement string
38
+ # @return [Array] Hierarchical array structure representing the board
39
+ # @raise [ArgumentError] If the input string is invalid
40
+ def self.parse(piece_placement_str)
41
+ validate_piece_placement_string(piece_placement_str)
42
+
43
+ # Check for trailing separators that don't contribute to dimension structure
44
+ raise ArgumentError, ERRORS[:trailing_separator] if piece_placement_str.end_with?(DIMENSION_SEPARATOR)
45
+
46
+ parse_dimension_group(piece_placement_str)
47
+ end
48
+
49
+ # Validates the piece placement string for basic syntax
50
+ #
51
+ # @param str [String] FEEN piece placement string
52
+ # @raise [ArgumentError] If the string is invalid
53
+ # @return [void]
54
+ def self.validate_piece_placement_string(str)
55
+ raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
56
+
57
+ raise ArgumentError, ERRORS[:empty_string] if str.empty?
58
+
59
+ # Check for valid characters
60
+ return if str.match?(VALID_CHARS_PATTERN)
61
+
62
+ invalid_chars = str.scan(%r{[^a-zA-Z0-9/+<=>]}).uniq.join(", ")
63
+ raise ArgumentError, format(ERRORS[:invalid_chars], invalid_chars)
64
+ end
65
+
66
+ # Finds all separator types present in the string (e.g., /, //, ///)
67
+ #
68
+ # @param str [String] FEEN dimension group string
69
+ # @return [Array<Integer>] Sorted array of separator depths (1 for /, 2 for //, etc.)
70
+ def self.find_separator_types(str)
71
+ # Find all consecutive sequences of '/'
72
+ separators = str.scan(%r{/+})
73
+ return [] if separators.empty?
74
+
75
+ # Return a unique sorted array of separator lengths
76
+ separators.map(&:length).uniq.sort
77
+ end
78
+
79
+ # Finds the minimum dimension depth in the string
80
+ #
81
+ # @param str [String] FEEN dimension group string
82
+ # @return [Integer] Minimum dimension depth (defaults to 1)
83
+ def self.find_min_dimension_depth(str)
84
+ separator_types = find_separator_types(str)
85
+ separator_types.empty? ? 1 : separator_types.first
34
86
  end
35
87
 
36
- # @return [Array] The list of pieces on the board.
37
- def to_a
38
- @piece_placement_str
39
- .split(%r{[/,]+})
40
- .flat_map { |str| row(str) }
88
+ # Recursively parses a dimension group
89
+ #
90
+ # @param str [String] FEEN dimension group string
91
+ # @return [Array] Hierarchical array structure representing the dimension group
92
+ def self.parse_dimension_group(str)
93
+ # Check for trailing separators at each level
94
+ raise ArgumentError, ERRORS[:trailing_separator] if str.end_with?(DIMENSION_SEPARATOR)
95
+
96
+ # Find all separator types present in the string
97
+ separator_types = find_separator_types(str)
98
+ return parse_rank(str) if separator_types.empty?
99
+
100
+ # Start with the deepest separator (largest number of consecutive /)
101
+ max_depth = separator_types.last
102
+ separator = DIMENSION_SEPARATOR * max_depth
103
+
104
+ # Split the string by this separator depth
105
+ parts = split_by_separator(str, separator)
106
+
107
+ # Create the hierarchical structure
108
+ parts.map do |part|
109
+ # Check each part for trailing separators of lower depths
110
+ raise ArgumentError, ERRORS[:trailing_separator] if part.end_with?(DIMENSION_SEPARATOR)
111
+
112
+ if max_depth == 1
113
+ # If this is the lowest level separator, parse as ranks
114
+ parse_rank(part)
115
+ else
116
+ # Otherwise, continue recursively with lower level separators
117
+ parse_dimension_group(part)
118
+ end
119
+ end
41
120
  end
42
121
 
43
- # @return [Hash] The index of each piece on the board.
44
- def to_h
45
- to_a
46
- .each_with_index
47
- .inject({}) do |h, (v, i)|
48
- next h if v.nil?
122
+ # Splits a string by a given separator, preserving separators of different depths
123
+ #
124
+ # @param str [String] String to split
125
+ # @param separator [String] Separator to split by (e.g., "/", "//")
126
+ # @return [Array<String>] Array of split parts
127
+ def self.split_by_separator(str, separator)
128
+ return [str] unless str.include?(separator)
129
+
130
+ parts = []
131
+ current_part = ""
132
+ i = 0
49
133
 
50
- h.merge(i => v)
134
+ while i < str.length
135
+ # Si nous trouvons le début d'un séparateur potentiel
136
+ if str[i] == DIMENSION_SEPARATOR[0]
137
+ # Vérifier si c'est notre séparateur exact
138
+ if i <= str.length - separator.length && str[i, separator.length] == separator
139
+ # C'est notre séparateur, ajouter la partie actuelle à la liste
140
+ parts << current_part unless current_part.empty?
141
+ current_part = ""
142
+ i += separator.length
143
+ else
144
+ # Ce n'est pas notre séparateur exact, compter combien de '/' consécutifs
145
+ start = i
146
+ i += 1 while i < str.length && str[i] == DIMENSION_SEPARATOR[0]
147
+ # Ajouter ces '/' à la partie actuelle
148
+ current_part += str[start...i]
149
+ end
150
+ else
151
+ # Caractère normal, l'ajouter à la partie actuelle
152
+ current_part += str[i]
153
+ i += 1
51
154
  end
155
+ end
156
+
157
+ # Ajouter la dernière partie si elle n'est pas vide
158
+ parts << current_part unless current_part.empty?
159
+
160
+ parts
52
161
  end
53
162
 
54
- private
163
+ # Parses a rank (sequence of cells)
164
+ #
165
+ # @param str [String] FEEN rank string
166
+ # @return [Array] Array of cells (nil for empty, hash for piece)
167
+ def self.parse_rank(str)
168
+ return [] if str.nil? || str.empty?
169
+
170
+ cells = []
171
+ i = 0
172
+
173
+ while i < str.length
174
+ char = str[i]
175
+
176
+ if char.match?(/[1-9]/)
177
+ # Handle empty cells (digits represent consecutive empty squares)
178
+ empty_count = EMPTY_STRING
179
+ while i < str.length && str[i].match?(/[0-9]/)
180
+ empty_count += str[i]
181
+ i += 1
182
+ end
183
+
184
+ empty_count.to_i.times { cells << nil }
185
+ else
186
+ # Handle pieces
187
+ piece = {}
188
+
189
+ # Check for prefix
190
+ if char == PREFIX_PROMOTION
191
+ piece[:prefix] = PREFIX_PROMOTION
192
+ i += 1
193
+
194
+ # Ensure there's a piece identifier after the prefix
195
+ raise ArgumentError, ERRORS[:invalid_prefix] if i >= str.length || !str[i].match?(/[a-zA-Z]/)
196
+
197
+ char = str[i]
198
+ end
199
+
200
+ # Get the piece identifier
201
+ raise ArgumentError, format(ERRORS[:invalid_piece], i, char) unless char.match?(/[a-zA-Z]/)
202
+
203
+ piece[:id] = char
204
+ i += 1
205
+
206
+ # Check for suffix
207
+ if i < str.length && VALID_SUFFIXES.include?(str[i])
208
+ piece[:suffix] = str[i]
209
+ i += 1
210
+ end
211
+
212
+ cells << piece
213
+
214
+ end
215
+ end
55
216
 
56
- def row(string)
57
- string.match?(/[0-9]+/) ? ::Array.new(Integer(string)) : string
217
+ cells
58
218
  end
59
219
  end
60
220
  end
@@ -2,32 +2,73 @@
2
2
 
3
3
  module Feen
4
4
  module Parser
5
- # The pieces in hand module.
5
+ # Handles parsing of the pieces in hand section of a FEEN string
6
6
  module PiecesInHand
7
- # The list of pieces in hand grouped by players.
8
- #
9
- # @param pieces_in_hand [String, nil] The serialized list of pieces in hand.
7
+ NO_PIECES = "-"
8
+ ERRORS = {
9
+ invalid_type: "Pieces in hand must be a string, got %s",
10
+ empty_string: "Pieces in hand string cannot be empty",
11
+ invalid_chars: "Invalid characters in pieces in hand: %s",
12
+ invalid_identifier: "Invalid piece identifier at position %d"
13
+ }.freeze
14
+
15
+ # Parses the pieces in hand section of a FEEN string
10
16
  #
11
- # @example Parse a list of serialized pieces in hand
12
- # parse("S,b,g*4,n*4,p*17,r*2,s")
13
- # # => ["S", "b", "g", "g", "g", "g", "n", "n", "n", "n", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "r", "r", "s"]
17
+ # @param pieces_in_hand_str [String] FEEN pieces in hand string
18
+ # @return [Array<Hash>] Array of pieces in hand
19
+ # @raise [ArgumentError] If the input string is invalid
20
+ def self.parse(pieces_in_hand_str)
21
+ validate_pieces_in_hand_string(pieces_in_hand_str)
22
+
23
+ # Handle the special case of no pieces in hand
24
+ return [] if pieces_in_hand_str == NO_PIECES
25
+
26
+ pieces = []
27
+ i = 0
28
+
29
+ while i < pieces_in_hand_str.length
30
+ # Vérifier que le caractère est une lettre
31
+ raise ArgumentError, format(ERRORS[:invalid_identifier], i) unless pieces_in_hand_str[i].match?(/[a-zA-Z]/)
32
+
33
+ pieces << { id: pieces_in_hand_str[i] }
34
+ i += 1
35
+
36
+ end
37
+
38
+ # Vérifier que les pièces sont triées par ordre lexicographique
39
+ raise ArgumentError, "Pieces in hand must be in ASCII lexicographic order" unless pieces_sorted?(pieces)
40
+
41
+ pieces
42
+ end
43
+
44
+ # Validates the pieces in hand string for syntax
14
45
  #
15
- # @example Parse an empty list of serialized pieces in hand
16
- # parse("-")
17
- # # => []
46
+ # @param str [String] FEEN pieces in hand string
47
+ # @raise [ArgumentError] If the string is invalid
48
+ # @return [void]
49
+ def self.validate_pieces_in_hand_string(str)
50
+ raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
51
+
52
+ raise ArgumentError, ERRORS[:empty_string] if str.empty?
53
+
54
+ # Check for the special case of no pieces in hand
55
+ return if str == NO_PIECES
56
+
57
+ # Check for valid characters (only letters)
58
+ valid_chars = /\A[a-zA-Z]+\z/
59
+ return if str.match?(valid_chars)
60
+
61
+ invalid_chars = str.scan(/[^a-zA-Z]/).uniq.join(", ")
62
+ raise ArgumentError, format(ERRORS[:invalid_chars], invalid_chars)
63
+ end
64
+
65
+ # Checks if pieces are sorted in ASCII lexicographic order
18
66
  #
19
- # @return [Array] The list of pieces in hand grouped by players.
20
- def self.parse(pieces_in_hand)
21
- return if pieces_in_hand.nil?
22
-
23
- pieces_in_hand.split(",").flat_map do |piece|
24
- if piece.include?("*")
25
- letter, count = piece.split("*")
26
- [letter] * count.to_i
27
- else
28
- piece
29
- end
30
- end.sort
67
+ # @param pieces [Array<Hash>] Array of piece hashes
68
+ # @return [Boolean] True if pieces are sorted
69
+ def self.pieces_sorted?(pieces)
70
+ piece_ids = pieces.map { |piece| piece[:id] }
71
+ piece_ids == piece_ids.sort
31
72
  end
32
73
  end
33
74
  end
data/lib/feen/parser.rb CHANGED
@@ -1,40 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("parser", "board_shape")
4
- require_relative File.join("parser", "pieces_in_hand")
3
+ require_relative File.join("parser", "games_turn")
5
4
  require_relative File.join("parser", "piece_placement")
5
+ require_relative File.join("parser", "pieces_in_hand")
6
6
 
7
7
  module Feen
8
- # The parser module.
8
+ # Module responsible for parsing FEEN notation strings into internal data structures
9
9
  module Parser
10
- # Parse a FEEN string into position params.
11
- #
12
- # @param feen [String] The FEEN string representing a position.
13
- #
14
- # @example Parse a classic Tsume Shogi problem
15
- # call("3,s,k,s,3/9/4,+P,4/9/7,+B,1/9/9/9/9 s S,b,g*4,n*4,p*17,r*2,s")
16
- # # => {
17
- # # "side_to_move": "s",
18
- # # "pieces_in_hand": ["S", "b", "g", "g", "g", "g", "n", "n", "n", "n", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "r", "r", "s"],
19
- # # "board_shape": [9, 9],
20
- # # "piece_placement": {
21
- # # 3 => "s",
22
- # # 4 => "k",
23
- # # 5 => "s",
24
- # # 22 => "+P",
25
- # # 43 => "+B"
26
- # # }
10
+ # Parses a complete FEEN string into a structured representation
27
11
  #
28
- # @return [Hash] The position params representing the position.
29
- def self.call(feen)
30
- piece_placement, side_to_move, pieces_in_hand = feen.split
12
+ # @param feen_string [String] Complete FEEN notation string
13
+ # @return [Hash] Hash containing the parsed position data
14
+ # @raise [ArgumentError] If the FEEN string is invalid
15
+ def self.parse(feen_string)
16
+ validate_feen_string(feen_string)
17
+
18
+ # Split the FEEN string into its three fields
19
+ fields = feen_string.strip.split(/\s+/)
20
+
21
+ raise ArgumentError, "Invalid FEEN format: expected 3 fields, got #{fields.size}" unless fields.size == 3
22
+
23
+ # Parse each field using the appropriate submodule
24
+ piece_placement = PiecePlacement.parse(fields[0])
25
+ games_turn = GamesTurn.parse(fields[1])
26
+ pieces_in_hand = PiecesInHand.parse(fields[2])
31
27
 
28
+ # Return a structured representation of the position
32
29
  {
33
- board_shape: BoardShape.new(piece_placement).to_a,
34
- pieces_in_hand: PiecesInHand.parse(pieces_in_hand),
35
- piece_placement: PiecePlacement.new(piece_placement).to_h,
36
- side_to_move:
30
+ piece_placement: piece_placement,
31
+ games_turn: games_turn,
32
+ pieces_in_hand: pieces_in_hand
37
33
  }
38
34
  end
35
+
36
+ # Validates the FEEN string for basic format
37
+ #
38
+ # @param feen_string [String] FEEN string to validate
39
+ # @raise [ArgumentError] If the FEEN string is fundamentally invalid
40
+ # @return [void]
41
+ def self.validate_feen_string(feen_string)
42
+ raise ArgumentError, "FEEN must be a string, got #{feen_string.class}" unless feen_string.is_a?(String)
43
+
44
+ raise ArgumentError, "FEEN string cannot be empty" if feen_string.empty?
45
+
46
+ # Check for at least two spaces (three fields)
47
+ return unless feen_string.count(" ") < 2
48
+
49
+ raise ArgumentError, "Invalid FEEN format: must contain at least two spaces separating three fields"
50
+ end
39
51
  end
40
52
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feen
4
+ # Provides methods for sanitizing and validating chess-related notation strings
5
+ module Sanitizer
6
+ # Cleans a FEN (Forsyth-Edwards Notation) string by removing invalid castling rights
7
+ # and en passant targets based on the current position.
8
+ #
9
+ # The method performs the following validations:
10
+ # - Verifies that kings and rooks are in correct positions for castling rights
11
+ # - Verifies that en passant captures are actually possible
12
+ #
13
+ # @param fen_string [String] The FEN string to clean
14
+ # @return [String] A sanitized FEN string with invalid castling rights and en passant targets removed
15
+ # @raise [ArgumentError] If the FEN string is malformed (less than 4 parts)
16
+ #
17
+ # @example Clean a valid FEN string (unchanged)
18
+ # fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
19
+ # Feen::Sanitizer.clean_fen(fen) # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
20
+ #
21
+ # @example Remove invalid castling rights when king has moved
22
+ # fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQ1BNR w KQkq - 0 1"
23
+ # Feen::Sanitizer.clean_fen(fen) # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQ1BNR w kq - 0 1"
24
+ #
25
+ # @example Remove invalid castling rights when rook has moved
26
+ # fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/1NBQKBNR w KQkq - 0 1"
27
+ # Feen::Sanitizer.clean_fen(fen) # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/1NBQKBNR w Kkq - 0 1"
28
+ #
29
+ # @example Remove invalid en passant target when no capturing pawn exists
30
+ # fen = "rnbqkbnr/pppp1ppp/8/4p3/8/8/PPPPPPPP/RNBQKBNR w KQkq e6 0 2"
31
+ # Feen::Sanitizer.clean_fen(fen) # => "rnbqkbnr/pppp1ppp/8/4p3/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 2"
32
+ #
33
+ # @example Keep valid en passant target when capturing is possible
34
+ # fen = "rnbqkbnr/pppp1ppp/8/3Pp3/8/8/PPP1PPPP/RNBQKBNR w KQkq e6 0 2"
35
+ # Feen::Sanitizer.clean_fen(fen) # => "rnbqkbnr/pppp1ppp/8/3Pp3/8/8/PPP1PPPP/RNBQKBNR w KQkq e6 0 2"
36
+ def self.clean_fen(fen_string)
37
+ parts = fen_string.strip.split
38
+ return fen_string unless parts.size >= 4
39
+
40
+ board, active_color, castling, en_passant, *rest = parts
41
+
42
+ # Parse board into a 2D array for easier access
43
+ board_matrix = []
44
+ board.split("/").each do |row|
45
+ current_row = []
46
+ row.each_char do |c|
47
+ if /[1-8]/.match?(c)
48
+ c.to_i.times { current_row << nil }
49
+ else
50
+ current_row << c
51
+ end
52
+ end
53
+ board_matrix << current_row
54
+ end
55
+
56
+ # Clean castling rights
57
+ new_castling = castling.dup
58
+ new_castling = clean_castling_rights(new_castling, board_matrix)
59
+
60
+ # Clean en passant target
61
+ new_en_passant = clean_en_passant_target(en_passant, board_matrix, active_color)
62
+
63
+ ([board, active_color, new_castling, new_en_passant] + rest).join(" ")
64
+ end
65
+
66
+ # Validates and cleans castling rights based on the position of kings and rooks
67
+ #
68
+ # @param castling [String] The castling rights string from FEN
69
+ # @param board [Array<Array<String, nil>>] 2D array representing the board
70
+ # @return [String] Cleaned castling rights or "-" if none are valid
71
+ # @api private
72
+ private_class_method def self.clean_castling_rights(castling, board)
73
+ return "-" if castling == "-"
74
+
75
+ new_castling = castling.dup
76
+
77
+ # White castling rights
78
+ new_castling.gsub!(/[KQ]/, "") unless board[7][4] == "K"
79
+ new_castling.delete!("K") unless board[7][7] == "R"
80
+ new_castling.delete!("Q") unless board[7][0] == "R"
81
+
82
+ # Black castling rights
83
+ new_castling.gsub!(/[kq]/, "") unless board[0][4] == "k"
84
+ new_castling.delete!("k") unless board[0][7] == "r"
85
+ new_castling.delete!("q") unless board[0][0] == "r"
86
+
87
+ new_castling.empty? ? "-" : new_castling
88
+ end
89
+
90
+ # Validates and cleans en passant target based on the position of pawns
91
+ #
92
+ # @param en_passant [String] The en passant target square from FEN
93
+ # @param board [Array<Array<String, nil>>] 2D array representing the board
94
+ # @param active_color [String] The active color ("w" or "b")
95
+ # @return [String] Cleaned en passant target or "-" if invalid
96
+ # @api private
97
+ private_class_method def self.clean_en_passant_target(en_passant, board, active_color)
98
+ return "-" if en_passant == "-"
99
+
100
+ file = en_passant[0].ord - "a".ord
101
+ rank = en_passant[1].to_i
102
+
103
+ # Validate en passant square coordinates
104
+ return "-" unless file.between?(0, 7) && [3, 6].include?(rank)
105
+
106
+ # For white's move (after black pawn double advance)
107
+ if active_color == "w" && rank == 6
108
+ # Check for white pawns on the 5th rank (index 3) that can capture
109
+ return en_passant if [file - 1, file + 1].any? { |f| f.between?(0, 7) && board[3][f] == "P" }
110
+ # For black's move (after white pawn double advance)
111
+ elsif active_color == "b" && rank == 3
112
+ # Check for black pawns on the 4th rank (index 4) that can capture
113
+ return en_passant if [file - 1, file + 1].any? { |f| f.between?(0, 7) && board[4][f] == "p" }
114
+ end
115
+
116
+ "-" # Invalid en passant square
117
+ end
118
+ end
119
+ end