feen 5.0.0.beta2 → 5.0.0.beta3

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.
@@ -1,170 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Converter
5
- module FromFen
6
- # Constants
7
- WHITE_TURN = "CHESS/chess"
8
- BLACK_TURN = "chess/CHESS"
9
- NO_PIECES_IN_HAND = "-"
10
- EN_PASSANT_NONE = "-"
11
-
12
- # Castling-related constants
13
- CASTLING_SUFFIX_BOTH = "="
14
- CASTLING_SUFFIX_KINGSIDE = ">"
15
- CASTLING_SUFFIX_QUEENSIDE = "<"
16
- CASTLING_SUFFIX_NONE = ""
17
-
18
- # Piece identifiers
19
- WHITE_KING = "K"
20
- BLACK_KING = "k"
21
- WHITE_PAWN = "P"
22
- BLACK_PAWN = "p"
23
-
24
- # Board indices
25
- WHITE_KING_STARTING_ROW = 7
26
- BLACK_KING_STARTING_ROW = 0
27
-
28
- # Converts a FEN string to a FEEN string for chess positions
29
- # @param fen_string [String] Standard FEN notation string for chess
30
- # @return [String] Equivalent FEEN notation string
31
- # @raise [ArgumentError] If the FEN string is invalid
32
- def self.call(fen_string)
33
- unless fen_string.is_a?(String) && !fen_string.strip.empty?
34
- raise ArgumentError, "FEN must be a non-empty string"
35
- end
36
-
37
- parts = fen_string.strip.split
38
- raise ArgumentError, "Invalid FEN format: expected at least 4 fields" unless parts.size >= 4
39
-
40
- placement_fen, active_color, castling, en_passant = parts[0..3]
41
- board = parse_board(placement_fen)
42
-
43
- apply_castling!(board, castling)
44
- apply_en_passant!(board, en_passant)
45
-
46
- piece_placement = format_board(board)
47
- games_turn = active_color == "w" ? WHITE_TURN : BLACK_TURN
48
-
49
- "#{piece_placement} #{games_turn} #{NO_PIECES_IN_HAND}"
50
- end
51
-
52
- # Parses the FEN piece placement into a 2D board array
53
- # @param fen_placement [String] FEN piece placement string
54
- # @return [Array<Array<String, nil>>] 2D array representing the board
55
- def self.parse_board(fen_placement)
56
- fen_placement.split("/").map do |row|
57
- cells = []
58
- row.each_char do |char|
59
- if /[1-8]/.match?(char)
60
- cells.concat([nil] * char.to_i)
61
- else
62
- cells << char
63
- end
64
- end
65
- cells
66
- end
67
- end
68
-
69
- # Applies castling rights to kings on the board
70
- # @param board [Array<Array<String, nil>>] 2D board array
71
- # @param castling [String] FEN castling rights string
72
- # @return [void]
73
- def self.apply_castling!(board, castling)
74
- return if castling == EN_PASSANT_NONE
75
-
76
- # Determine suffix for each king
77
- wk_suffix = determine_castling_suffix(castling.include?("K"), castling.include?("Q"))
78
- bk_suffix = determine_castling_suffix(castling.include?("k"), castling.include?("q"))
79
-
80
- # Apply the suffixes to the kings
81
- apply_king_suffix!(board, WHITE_KING, wk_suffix)
82
- apply_king_suffix!(board, BLACK_KING, bk_suffix)
83
- end
84
-
85
- # Applies a castling suffix to a king on the board
86
- # @param board [Array<Array<String, nil>>] 2D board array
87
- # @param king_char [String] King character ('K' or 'k')
88
- # @param suffix [String] Castling suffix to apply
89
- # @return [void]
90
- def self.apply_king_suffix!(board, king_char, suffix)
91
- return if suffix.empty?
92
-
93
- board.each_with_index do |row, r|
94
- row.each_with_index do |cell, c|
95
- board[r][c] = "#{king_char}#{suffix}" if cell == king_char
96
- end
97
- end
98
- end
99
-
100
- # Determines the appropriate castling suffix based on kingside and queenside rights
101
- # @param kingside [Boolean] Whether kingside castling is allowed
102
- # @param queenside [Boolean] Whether queenside castling is allowed
103
- # @return [String] The castling suffix
104
- def self.determine_castling_suffix(kingside, queenside)
105
- if kingside && queenside
106
- CASTLING_SUFFIX_BOTH
107
- elsif kingside
108
- CASTLING_SUFFIX_KINGSIDE
109
- elsif queenside
110
- CASTLING_SUFFIX_QUEENSIDE
111
- else
112
- CASTLING_SUFFIX_NONE
113
- end
114
- end
115
-
116
- # Applies en passant rights to pawns on the board
117
- # @param board [Array<Array<String, nil>>] 2D board array
118
- # @param en_passant [String] FEN en passant target square
119
- # @return [void]
120
- def self.apply_en_passant!(board, en_passant)
121
- return if en_passant == EN_PASSANT_NONE
122
-
123
- col = en_passant[0].ord - "a".ord
124
- row = 8 - en_passant[1].to_i
125
-
126
- if row == 2 # White just moved: check black pawns on row 3
127
- apply_en_passant_for_pawn!(board, 3, col, BLACK_PAWN)
128
- elsif row == 5 # Black just moved: check white pawns on row 4
129
- apply_en_passant_for_pawn!(board, 4, col, WHITE_PAWN)
130
- end
131
- end
132
-
133
- # Applies en passant rights to pawns at a specific row and column
134
- # @param board [Array<Array<String, nil>>] 2D board array
135
- # @param pawn_row [Integer] Row where pawns can capture en passant
136
- # @param target_col [Integer] Column of the pawn that moved two squares
137
- # @param pawn_char [String] Pawn character ('P' or 'p')
138
- # @return [void]
139
- def self.apply_en_passant_for_pawn!(board, pawn_row, target_col, pawn_char)
140
- [-1, 1].each do |dx|
141
- x = target_col + dx
142
-
143
- next unless x.between?(0, 7) && board[pawn_row][x] == pawn_char
144
-
145
- # Determine the en passant suffix based on relative position
146
- suffix = dx == -1 ? ">" : "<"
147
- board[pawn_row][x] = "#{pawn_char}#{suffix}"
148
- end
149
- end
150
-
151
- # Formats the board array back into FEN piece placement
152
- # @param board [Array<Array<String, nil>>] 2D board array
153
- # @return [String] FEN piece placement string
154
- def self.format_board(board)
155
- board.map do |row|
156
- format_row(row)
157
- end.join("/")
158
- end
159
-
160
- # Formats a row of the board into FEN notation
161
- # @param row [Array<String, nil>] Row of the board
162
- # @return [String] FEN notation for this row
163
- def self.format_row(row)
164
- row.chunk_while { |a, b| a.nil? == b.nil? }.map do |chunk|
165
- chunk.first.nil? ? chunk.size.to_s : chunk.join
166
- end.join
167
- end
168
- end
169
- end
170
- end
@@ -1,153 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Converter
5
- module ToFen
6
- # Constants for game types and formats
7
- WHITE_ACTIVE = "CHESS/chess"
8
- BLACK_ACTIVE = "chess/CHESS"
9
-
10
- # Constants for FEN components
11
- NO_EN_PASSANT = "-"
12
- NO_CASTLING = "-"
13
- HALF_MOVE_DEFAULT = "0"
14
- FULL_MOVE_DEFAULT = "1"
15
-
16
- # Castling-related constants
17
- WHITE_KINGSIDE = "K"
18
- WHITE_QUEENSIDE = "Q"
19
- BLACK_KINGSIDE = "k"
20
- BLACK_QUEENSIDE = "q"
21
-
22
- # En passant constants
23
- EN_PASSANT_WHITE_RANK = "3"
24
- EN_PASSANT_BLACK_RANK = "6"
25
-
26
- # Converts a FEEN string to a standard FEN string for chess positions
27
- # @param feen_string [String] FEEN notation string
28
- # @return [String] Standard FEN notation string
29
- # @raise [ArgumentError] If the FEEN string is invalid or not supported
30
- def self.call(feen_string)
31
- # Validate input
32
- unless feen_string.is_a?(String) && !feen_string.strip.empty?
33
- raise ArgumentError, "FEEN must be a non-empty string"
34
- end
35
-
36
- # Split into components
37
- parts = feen_string.strip.split
38
- raise ArgumentError, "Invalid FEEN format: expected 3 fields" unless parts.size == 3
39
-
40
- piece_placement, games_turn, = parts
41
-
42
- # Verify game type is supported
43
- unless [WHITE_ACTIVE, BLACK_ACTIVE].include?(games_turn)
44
- raise ArgumentError, "Only CHESS/chess FEEN formats are supported"
45
- end
46
-
47
- # Extract FEN components
48
- castling_rights = extract_castling_rights(piece_placement)
49
- en_passant = extract_en_passant(piece_placement)
50
- fen_board = convert_board_to_fen(piece_placement)
51
- active_color = games_turn.start_with?("CHESS") ? "w" : "b"
52
-
53
- # Construct FEN string
54
- "#{fen_board} #{active_color} #{castling_rights} #{en_passant} #{HALF_MOVE_DEFAULT} #{FULL_MOVE_DEFAULT}"
55
- end
56
-
57
- # Converts the FEEN board representation to FEN format by removing special markings
58
- # @param piece_placement [String] FEEN piece placement string
59
- # @return [String] FEN piece placement string
60
- def self.convert_board_to_fen(piece_placement)
61
- piece_placement.split("/").map do |row|
62
- # Remove castling suffixes from kings
63
- row_without_king_suffix = row.gsub(/([Kk])([=<>])/) { Regexp.last_match(1) }
64
- # Remove en passant suffixes from pawns
65
- row_without_suffixes = row_without_king_suffix.gsub(/([Pp])([<>])/) { Regexp.last_match(1) }
66
- # Ensure only single character pieces (in case of promoted pieces with prefixes)
67
- row_without_suffixes.gsub(/\d+|\w/) { |s| /\d/.match?(s) ? s : s[0] }
68
- end.join("/")
69
- end
70
-
71
- # Extracts castling rights from FEEN piece placement
72
- # @param piece_placement [String] FEEN piece placement string
73
- # @return [String] FEN castling rights component
74
- def self.extract_castling_rights(piece_placement)
75
- castling = ""
76
-
77
- # White castling rights - always in uppercase and first
78
- piece_placement.split("/").each do |row|
79
- castling += WHITE_KINGSIDE if row.include?("K>") || row.include?("K=")
80
- castling += WHITE_QUEENSIDE if row.include?("K<") || row.include?("K=")
81
- end
82
-
83
- # Black castling rights - always in lowercase and after white
84
- piece_placement.split("/").each do |row|
85
- castling += BLACK_KINGSIDE if row.include?("k>") || row.include?("k=")
86
- castling += BLACK_QUEENSIDE if row.include?("k<") || row.include?("k=")
87
- end
88
-
89
- castling.empty? ? NO_CASTLING : castling
90
- end
91
-
92
- # Extracts en passant target square from FEEN piece placement
93
- # @param piece_placement [String] FEEN piece placement string
94
- # @return [String] FEN en passant target square or "-" if none
95
- # @raise [ArgumentError] If multiple en passant markers are detected
96
- def self.extract_en_passant(piece_placement)
97
- rows = piece_placement.split("/")
98
- en_passant_targets = []
99
-
100
- rows.each_with_index do |row, _rank|
101
- file_index = 0
102
- char_index = 0
103
-
104
- while char_index < row.length
105
- current_char = row[char_index]
106
-
107
- if /[1-8]/.match?(current_char)
108
- # Skip empty squares
109
- file_index += current_char.to_i
110
- char_index += 1
111
- elsif char_index + 1 < row.length && row[char_index..(char_index + 1)] =~ /([Pp])([<>])/
112
- # Found a pawn with en passant marker
113
- pawn_type = ::Regexp.last_match(1) # 'P' or 'p'
114
- ::Regexp.last_match(2) # '<' or '>'
115
-
116
- # Calculate the file (column) letter
117
- file_char = ("a".ord + file_index).chr
118
-
119
- # Calculate the rank (row) number based on pawn type
120
- # For white pawn (P) on rank 4 (index 3), the target is rank 3
121
- # For black pawn (p) on rank 5 (index 2), the target is rank 6
122
- rank_num = if pawn_type == "P"
123
- "3" # White pawn's en passant target is always on rank 3
124
- else
125
- "6" # Black pawn's en passant target is always on rank 6
126
- end
127
-
128
- en_passant_targets << "#{file_char}#{rank_num}"
129
-
130
- # Move past this pawn and its suffix
131
- file_index += 1
132
- char_index += 2
133
- else
134
- # Regular piece
135
- file_index += 1
136
- char_index += 1
137
-
138
- # Skip any suffix attached to this piece
139
- char_index += 1 if char_index < row.length && row[char_index] =~ /[<>=]/
140
- end
141
- end
142
- end
143
-
144
- # Only one en passant target should be present in a valid position
145
- if en_passant_targets.size > 1
146
- raise ArgumentError, "Multiple en passant markers detected: #{en_passant_targets.join(', ')}"
147
- end
148
-
149
- en_passant_targets.first || "-"
150
- end
151
- end
152
- end
153
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative File.join("converter", "from_fen")
4
- require_relative File.join("converter", "to_fen")
5
-
6
- module Feen
7
- module Converter
8
- def self.from_fen(fen_string)
9
- FromFen.call(fen_string)
10
- end
11
-
12
- def self.to_fen(feen_string)
13
- ToFen.call(feen_string)
14
- end
15
- end
16
- end
@@ -1,119 +0,0 @@
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