feen 5.0.0.beta1 → 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,84 +2,120 @@
2
2
 
3
3
  module Feen
4
4
  module Dumper
5
- # The PiecePlacement class.
6
- #
7
- # @example Dump an empty board of Xiangqi
8
- # PiecePlacement.new([10, 9]).to_s # => "9/9/9/9/9/9/9/9/9/9"
9
- #
10
- # @example Dump the Xiangqi starting position board
11
- # PiecePlacement.new(
12
- # [10, 9],
13
- # {
14
- # 0 => "車",
15
- # 1 => "馬",
16
- # 2 => "",
17
- # 3 => "士",
18
- # 4 => "將",
19
- # 5 => "士",
20
- # 6 => "象",
21
- # 7 => "馬",
22
- # 8 => "車",
23
- # 19 => "砲",
24
- # 25 => "砲",
25
- # 27 => "",
26
- # 29 => "卒",
27
- # 31 => "卒",
28
- # 33 => "卒",
29
- # 35 => "卒",
30
- # 54 => "兵",
31
- # 56 => "兵",
32
- # 58 => "兵",
33
- # 60 => "兵",
34
- # 62 => "兵",
35
- # 64 => "炮",
36
- # 70 => "炮",
37
- # 81 => "俥",
38
- # 82 => "傌",
39
- # 83 => "相",
40
- # 84 => "仕",
41
- # 85 => "帥",
42
- # 86 => "仕",
43
- # 87 => "相",
44
- # 88 => "傌",
45
- # 89 => "俥"
46
- # }
47
- # ).to_s # => "車馬象士將士象馬車/9/1砲5砲1/卒1卒1卒1卒1卒/9/9/兵1兵1兵1兵1兵/1炮5炮1/9/俥傌相仕帥仕相傌俥"
48
- class PiecePlacement
49
- # @param indexes [Array] The shape of the board.
50
- # @param piece_placement [Hash] The index of each piece on the board.
51
- def initialize(indexes, piece_placement = {})
52
- @indexes = indexes
53
- @squares = ::Array.new(length) { |i| piece_placement.fetch(i, nil) }
5
+ # Handles conversion of piece placement data structure to FEEN notation string
6
+ module PiecePlacement
7
+ # Error messages
8
+ ERRORS = {
9
+ empty_input: "Piece placement cannot be empty"
10
+ }.freeze
11
+
12
+ # Empty string for initialization
13
+ EMPTY_STRING = ""
14
+
15
+ # Dimension separator character
16
+ DIMENSION_SEPARATOR = "/"
17
+
18
+ # Converts the internal piece placement representation to a FEEN string
19
+ #
20
+ # @param piece_placement [Array] Hierarchical array structure representing the board
21
+ # @return [String] FEEN-formatted piece placement string
22
+ # @example
23
+ # piece_placement = [[{id: 'r'}, {id: 'n'}, nil, nil]]
24
+ # PiecePlacement.dump(piece_placement)
25
+ # # => "rn2"
26
+ def self.dump(piece_placement)
27
+ return EMPTY_STRING if piece_placement.nil? || piece_placement.empty?
28
+
29
+ # Step 1: Calculate the total depth of the structure
30
+ depth = calculate_depth(piece_placement)
31
+
32
+ # Step 2: Convert the structure to a string
33
+ dump_recursive(piece_placement, depth)
54
34
  end
55
35
 
56
- # @return [String] The string representing the board.
57
- def to_s
58
- unflatten(@squares, @indexes)
36
+ # Calculates the maximum depth of the data structure
37
+ #
38
+ # @param data [Array] Data structure to evaluate
39
+ # @return [Integer] Maximum depth
40
+ def self.calculate_depth(data)
41
+ return 0 unless data.is_a?(Array) && !data.empty?
42
+
43
+ if data.first.is_a?(Array)
44
+ 1 + calculate_depth(data.first)
45
+ else
46
+ 1
47
+ end
48
+ end
49
+
50
+ # Recursively converts the structure to FEEN notation
51
+ #
52
+ # @param data [Array] Data to convert
53
+ # @param max_depth [Integer] Maximum depth of the structure
54
+ # @param current_depth [Integer] Current recursion depth
55
+ # @return [String] FEEN representation
56
+ def self.dump_recursive(data, max_depth, current_depth = 1)
57
+ return EMPTY_STRING if data.nil? || data.empty?
58
+
59
+ if data.first.is_a?(Array)
60
+ parts = data.map { |subdata| dump_recursive(subdata, max_depth, current_depth + 1) }
61
+
62
+ # The number of separators depends on the current depth
63
+ # The lowest level uses a single '/', then increases progressively
64
+ separator_count = max_depth - current_depth
65
+ separator = DIMENSION_SEPARATOR * separator_count
66
+
67
+ parts.join(separator)
68
+ else
69
+ # This is a simple row (rank)
70
+ dump_rank(data)
71
+ end
59
72
  end
60
73
 
61
- private
74
+ # Converts a single rank (row/array of cells) to FEEN notation
75
+ #
76
+ # @param cells [Array] Array of cell values (nil for empty, hash for piece)
77
+ # @return [String] FEEN representation of this rank
78
+ # @example
79
+ # cells = [{id: 'r'}, {id: 'n'}, nil, nil]
80
+ # PiecePlacement.dump_rank(cells)
81
+ # # => "rn2"
82
+ def self.dump_rank(cells)
83
+ return EMPTY_STRING if cells.nil? || cells.empty?
62
84
 
63
- def length
64
- @indexes.inject(:*)
85
+ # Use chunk_while to group consecutive empty/non-empty cells
86
+ cells.chunk_while { |a, b| a.nil? == b.nil? }.map do |chunk|
87
+ if chunk.first.nil?
88
+ # Group of empty cells -> count
89
+ chunk.size.to_s
90
+ else
91
+ # Group of pieces -> concatenate FEEN representations
92
+ chunk.map { |cell| dump_cell(cell) }.join(EMPTY_STRING)
93
+ end
94
+ end.join(EMPTY_STRING)
65
95
  end
66
96
 
67
- def unflatten(squares, remaining_indexes)
68
- return row(squares) if remaining_indexes.length == 1
97
+ # Converts a single piece cell to its FEEN representation
98
+ #
99
+ # @param cell [Hash] Hash with :id and optional :prefix and :suffix
100
+ # @return [String] FEEN representation of this piece
101
+ # @example
102
+ # cell = {id: 'P', suffix: '='}
103
+ # PiecePlacement.dump_cell(cell)
104
+ # # => "P="
105
+ def self.dump_cell(cell)
106
+ return EMPTY_STRING if cell.nil?
69
107
 
70
- squares
71
- .each_slice(squares.length / remaining_indexes.fetch(0))
72
- .to_a
73
- .map { |sub_squares| unflatten(sub_squares, remaining_indexes[1..]) }
74
- .join("/" * remaining_indexes.length.pred)
108
+ # Combine prefix (if any) + piece identifier + suffix (if any)
109
+ [
110
+ cell[:prefix],
111
+ cell[:id],
112
+ cell[:suffix]
113
+ ].compact.join(EMPTY_STRING)
75
114
  end
76
115
 
77
- def row(squares)
78
- squares
79
- .map { |square| square.nil? ? 1 : square }
80
- .join(",")
81
- .gsub(/1,[1,]*1/) { |str| str.split(",").length }
82
- .delete(",")
116
+ def self.dump_dimension_group(group)
117
+ max_depth = calculate_depth(group)
118
+ dump_recursive(group, max_depth)
83
119
  end
84
120
  end
85
121
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feen
4
+ module Dumper
5
+ # Handles conversion of pieces in hand data structure to FEEN notation string
6
+ module PiecesInHand
7
+ EMPTY_RESULT = "-"
8
+ ERRORS = {
9
+ invalid_piece: "Invalid piece at index %d: must be a Hash with an :id key",
10
+ invalid_id: "Invalid piece ID at index %d: must be a single alphabetic character",
11
+ prefix_not_allowed: "Prefix is not allowed for pieces in hand at index %d",
12
+ suffix_not_allowed: "Suffix is not allowed for pieces in hand at index %d"
13
+ }.freeze
14
+
15
+ # Converts the internal pieces in hand representation to a FEEN string
16
+ #
17
+ # @param pieces_in_hand [Array<Hash>] Array of piece hashes
18
+ # @return [String] FEEN-formatted pieces in hand string
19
+ def self.dump(pieces_in_hand)
20
+ # If no pieces in hand, return a single hyphen
21
+ return EMPTY_RESULT if pieces_in_hand.nil? || pieces_in_hand.empty?
22
+
23
+ # Validate all pieces have the required structure
24
+ validate_pieces(pieces_in_hand)
25
+
26
+ # Sort pieces in ASCII lexicographic order by their ID
27
+ pieces_in_hand
28
+ .sort_by { |piece| piece[:id] }
29
+ .map { |piece| piece[:id] }
30
+ .join
31
+ end
32
+
33
+ # Validates the structure of each piece in the array
34
+ #
35
+ # @param pieces [Array<Hash>] Array of piece hashes to validate
36
+ # @raise [ArgumentError] If any piece has an invalid structure
37
+ # @return [void]
38
+ def self.validate_pieces(pieces)
39
+ pieces.each_with_index do |piece, index|
40
+ # Check basic piece structure
41
+ raise ArgumentError, format(ERRORS[:invalid_piece], index) unless piece.is_a?(Hash) && piece.key?(:id)
42
+
43
+ # Validate piece ID
44
+ unless piece[:id].is_a?(String) && piece[:id].match?(/\A[a-zA-Z]\z/)
45
+ raise ArgumentError, format(ERRORS[:invalid_id], index)
46
+ end
47
+
48
+ # Ensure no prefix or suffix is present
49
+ raise ArgumentError, format(ERRORS[:prefix_not_allowed], index) if piece.key?(:prefix) && !piece[:prefix].nil?
50
+
51
+ raise ArgumentError, format(ERRORS[:suffix_not_allowed], index) if piece.key?(:suffix) && !piece[:suffix].nil?
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/feen/dumper.rb CHANGED
@@ -1,36 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative File.join("dumper", "games_turn")
3
4
  require_relative File.join("dumper", "piece_placement")
5
+ require_relative File.join("dumper", "pieces_in_hand")
4
6
 
5
7
  module Feen
6
- # The dumper module.
8
+ # Module responsible for converting internal data structures to FEEN notation strings
7
9
  module Dumper
8
- # Dump position params into a FEEN string.
10
+ # Converts a complete position data structure to a FEEN string
9
11
  #
10
- # @param board_shape [Array] The shape of the board.
11
- # @param side_to_move [String] Identify the active side.
12
- # @param piece_placement [Hash] The index of each piece on the board.
13
- #
14
- # @example Dump a classic Tsume Shogi problem
15
- # call(
16
- # "board_shape": [9, 9],
17
- # "side_to_move": "s",
18
- # "piece_placement": {
19
- # 3 => "s",
20
- # 4 => "k",
21
- # 5 => "s",
22
- # 22 => "+P",
23
- # 43 => "+B"
24
- # }
25
- # )
26
- # # => "3sks3/9/4+P4/9/7+B1/9/9/9/9 s"
27
- #
28
- # @return [String] The FEEN string representing the position.
29
- def self.call(board_shape:, side_to_move:, piece_placement:)
12
+ # @param position [Hash] Hash containing the complete position data
13
+ # @option position [Array] :piece_placement Board position data
14
+ # @option position [Hash] :games_turn Games and turn data
15
+ # @option position [Array<Hash>] :pieces_in_hand Pieces in hand data
16
+ # @return [String] Complete FEEN string representation
17
+ def self.dump(position)
18
+ validate_position(position)
19
+
30
20
  [
31
- PiecePlacement.new(board_shape, piece_placement).to_s,
32
- side_to_move
21
+ PiecePlacement.dump(position[:piece_placement]),
22
+ GamesTurn.dump(position[:games_turn]),
23
+ PiecesInHand.dump(position[:pieces_in_hand])
33
24
  ].join(" ")
34
25
  end
26
+
27
+ # Validates the position data structure
28
+ #
29
+ # @param position [Hash] Position data to validate
30
+ # @raise [ArgumentError] If the position data is invalid
31
+ # @return [void]
32
+ def self.validate_position(position)
33
+ raise ArgumentError, "Position must be a Hash, got #{position.class}" unless position.is_a?(Hash)
34
+
35
+ # Check for required keys
36
+ required_keys = %i[piece_placement games_turn pieces_in_hand]
37
+ missing_keys = required_keys - position.keys
38
+
39
+ raise ArgumentError, "Missing required keys in position: #{missing_keys.join(', ')}" unless missing_keys.empty?
40
+
41
+ # Validate types of values
42
+ unless position[:piece_placement].is_a?(Array)
43
+ raise ArgumentError, "piece_placement must be an Array, got #{position[:piece_placement].class}"
44
+ end
45
+
46
+ unless position[:games_turn].is_a?(Hash)
47
+ raise ArgumentError, "games_turn must be a Hash, got #{position[:games_turn].class}"
48
+ end
49
+
50
+ return if position[:pieces_in_hand].is_a?(Array)
51
+
52
+ raise ArgumentError, "pieces_in_hand must be an Array, got #{position[:pieces_in_hand].class}"
53
+ end
35
54
  end
36
55
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feen
4
+ module Parser
5
+ # Handles parsing of the games turn section of a FEEN string
6
+ module GamesTurn
7
+ ERRORS = {
8
+ invalid_type: "Games turn must be a string, got %s",
9
+ empty_string: "Games turn string cannot be empty",
10
+ separator: "Games turn must contain exactly one '/' separator",
11
+ mixed_casing: "%s game has mixed case: %s",
12
+ casing_requirement: "One game must use uppercase letters and the other lowercase letters",
13
+ invalid_chars: "Invalid characters in %s game identifier: %s",
14
+ empty_identifier: "%s game identifier cannot be empty"
15
+ }.freeze
16
+
17
+ # Parses the games turn section of a FEEN string
18
+ #
19
+ # @param games_turn_str [String] FEEN games turn string
20
+ # @return [Hash] Hash containing game turn information
21
+ # @raise [ArgumentError] If the input string is invalid
22
+ def self.parse(games_turn_str)
23
+ validate_games_turn_string(games_turn_str)
24
+
25
+ # Split by the forward slash
26
+ parts = games_turn_str.split("/")
27
+ active_player = parts[0]
28
+ inactive_player = parts[1]
29
+
30
+ # Determine casing
31
+ active_uppercase = contains_uppercase?(active_player)
32
+
33
+ # Build result hash
34
+ {
35
+ active_player: active_player,
36
+ inactive_player: inactive_player,
37
+ uppercase_game: active_uppercase ? active_player : inactive_player,
38
+ lowercase_game: active_uppercase ? inactive_player : active_player,
39
+ active_player_casing: active_uppercase ? :uppercase : :lowercase
40
+ }
41
+ end
42
+
43
+ # Validates the games turn string for syntax and semantics
44
+ #
45
+ # @param str [String] FEEN games turn string
46
+ # @raise [ArgumentError] If the string is invalid
47
+ # @return [void]
48
+ def self.validate_games_turn_string(str)
49
+ validate_basic_structure(str)
50
+
51
+ parts = str.split("/")
52
+ validate_game_identifiers(parts)
53
+ validate_casing_requirements(parts)
54
+ end
55
+
56
+ # Validates the basic structure of the games turn string
57
+ #
58
+ # @param str [String] FEEN games turn string
59
+ # @raise [ArgumentError] If the structure is invalid
60
+ # @return [void]
61
+ private_class_method def self.validate_basic_structure(str)
62
+ raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
63
+
64
+ raise ArgumentError, ERRORS[:empty_string] if str.empty?
65
+
66
+ # Check for exactly one separator '/'
67
+ return if str.count("/") == 1
68
+
69
+ raise ArgumentError, ERRORS[:separator]
70
+ end
71
+
72
+ # Validates the individual game identifiers
73
+ #
74
+ # @param parts [Array<String>] Split game identifiers
75
+ # @raise [ArgumentError] If any identifier is invalid
76
+ # @return [void]
77
+ private_class_method def self.validate_game_identifiers(parts)
78
+ parts.each_with_index do |game_id, idx|
79
+ position = idx == 0 ? "active" : "inactive"
80
+
81
+ raise ArgumentError, format(ERRORS[:empty_identifier], position.capitalize) if game_id.nil? || game_id.empty?
82
+
83
+ unless game_id.match?(/\A[a-zA-Z]+\z/)
84
+ invalid_chars = game_id.scan(/[^a-zA-Z]/).uniq.join(", ")
85
+ raise ArgumentError, format(ERRORS[:invalid_chars], position, invalid_chars)
86
+ end
87
+ end
88
+ end
89
+
90
+ # Validates the casing requirements (one uppercase, one lowercase)
91
+ #
92
+ # @param parts [Array<String>] Split game identifiers
93
+ # @raise [ArgumentError] If casing requirements aren't met
94
+ # @return [void]
95
+ private_class_method def self.validate_casing_requirements(parts)
96
+ active_uppercase = contains_uppercase?(parts[0])
97
+ inactive_uppercase = contains_uppercase?(parts[1])
98
+
99
+ raise ArgumentError, ERRORS[:casing_requirement] if active_uppercase == inactive_uppercase
100
+
101
+ # Verify consistent casing in each identifier
102
+ if active_uppercase && contains_lowercase?(parts[0])
103
+ raise ArgumentError, format(ERRORS[:mixed_casing], "Active", parts[0])
104
+ end
105
+
106
+ if inactive_uppercase && contains_lowercase?(parts[1])
107
+ raise ArgumentError, format(ERRORS[:mixed_casing], "Inactive", parts[1])
108
+ end
109
+
110
+ if !active_uppercase && contains_uppercase?(parts[0])
111
+ raise ArgumentError, format(ERRORS[:mixed_casing], "Active", parts[0])
112
+ end
113
+
114
+ return unless !inactive_uppercase && contains_uppercase?(parts[1])
115
+
116
+ raise ArgumentError, format(ERRORS[:mixed_casing], "Inactive", parts[1])
117
+ end
118
+
119
+ # Checks if a string contains any uppercase letters
120
+ #
121
+ # @param str [String] String to check
122
+ # @return [Boolean] True if the string contains uppercase letters
123
+ def self.contains_uppercase?(str)
124
+ str.match?(/[A-Z]/)
125
+ end
126
+
127
+ # Checks if a string contains any lowercase letters
128
+ #
129
+ # @param str [String] String to check
130
+ # @return [Boolean] True if the string contains lowercase letters
131
+ def self.contains_lowercase?(str)
132
+ str.match?(/[a-z]/)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feen
4
+ module Parser
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
86
+ end
87
+
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
120
+ end
121
+
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
133
+
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
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
161
+ end
162
+
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
216
+
217
+ cells
218
+ end
219
+ end
220
+ end
221
+ end