feen 5.0.0.beta9 → 5.0.0.beta10

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,48 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pnn"
4
+
3
5
  module Feen
4
6
  module Dumper
5
7
  # Handles conversion of pieces in hand data to FEEN notation string
6
8
  module PiecesInHand
7
9
  # Error messages for validation
8
10
  ERRORS = {
9
- invalid_type: "Piece at index %d must be a String, got %s",
10
- invalid_format: "Piece at index %d must be base form only (single letter): '%s'",
11
- has_modifiers: "Piece at index %d cannot contain modifiers: '%s'. Pieces in hand must be base form only"
11
+ invalid_type: "Piece at index %d must be a String, got %s",
12
+ invalid_pnn: "Piece at index %d must be valid PNN notation: '%s'"
12
13
  }.freeze
13
14
 
14
15
  # Converts an array of piece identifiers to a FEEN-formatted pieces in hand string
15
16
  #
16
- # @param piece_chars [Array<String>] Array of piece identifiers in base form only (e.g., ["P", "p", "B", "B", "p"])
17
- # @return [String] FEEN-formatted pieces in hand string following the format:
17
+ # @param piece_chars [Array<String>] Array of piece identifiers following PNN notation.
18
+ # May include modifiers (per FEEN v1.0.0 specification): prefixes (+, -) and suffixes (')
19
+ # @return [String] FEEN-formatted pieces in hand string following the canonical sorting:
18
20
  # - Groups pieces by case: uppercase first, then lowercase, separated by "/"
19
- # - Within each group, sorts by quantity (descending), then alphabetically (ascending)
21
+ # - Within each group, sorts by quantity (descending), then base letter (ascending),
22
+ # then prefix (-, +, none), then suffix (none, ')
20
23
  # - Uses count notation for quantities > 1 (e.g., "3P" instead of "PPP")
21
- # @raise [ArgumentError] If any piece identifier is invalid or contains modifiers
24
+ # @raise [ArgumentError] If any piece identifier is invalid PNN notation
25
+ #
26
+ # @example Valid pieces in hand with modifiers
27
+ # PiecesInHand.dump("+B", "+B", "B", "B", "B", "B", "B", "K", "-P", "-P", "-P", "-P'", "+P'", "+P'", "+P'", "P", "P", "P", "P", "P", "P", "P", "P", "P", "R", "S", "S", "S'", "b", "p")
28
+ # # => "2+B5BK3-P-P'3+P'9PR2SS'/bp"
22
29
  #
23
- # @example Valid pieces in hand
30
+ # @example Valid pieces in hand without modifiers
24
31
  # PiecesInHand.dump("P", "P", "P", "B", "B", "p", "p", "p", "p", "p")
25
32
  # # => "3P2B/5p"
26
33
  #
27
- # @example Valid pieces in hand with mixed order
28
- # PiecesInHand.dump("p", "P", "B")
29
- # # => "BP/p"
30
- #
31
34
  # @example No pieces in hand
32
35
  # PiecesInHand.dump()
33
36
  # # => "/"
34
- #
35
- # @example Invalid - modifiers not allowed
36
- # PiecesInHand.dump("+P", "p")
37
- # # => ArgumentError: Piece at index 0 cannot contain modifiers: '+P'
38
37
  def self.dump(*piece_chars)
39
- # Validate each piece character according to FEEN specification (base form only)
38
+ # Validate each piece character according to PNN specification
40
39
  validated_chars = validate_piece_chars(piece_chars)
41
40
 
42
41
  # Group pieces by case
43
42
  uppercase_pieces, lowercase_pieces = group_pieces_by_case(validated_chars)
44
43
 
45
- # Format each group according to FEEN specification
44
+ # Format each group according to FEEN canonical sorting specification
46
45
  uppercase_formatted = format_pieces_group(uppercase_pieces)
47
46
  lowercase_formatted = format_pieces_group(lowercase_pieces)
48
47
 
@@ -50,30 +49,36 @@ module Feen
50
49
  "#{uppercase_formatted}/#{lowercase_formatted}"
51
50
  end
52
51
 
53
- # Groups pieces by case (uppercase vs lowercase)
52
+ # Groups pieces by case (uppercase vs lowercase base letter)
54
53
  #
55
54
  # @param pieces [Array<String>] Array of validated piece identifiers
56
55
  # @return [Array<Array<String>, Array<String>>] Two arrays: [uppercase_pieces, lowercase_pieces]
57
56
  private_class_method def self.group_pieces_by_case(pieces)
58
- uppercase_pieces = pieces.grep(/[A-Z]/)
59
- lowercase_pieces = pieces.grep(/[a-z]/)
57
+ uppercase_pieces = pieces.select { |piece| extract_base_letter(piece).match?(/[A-Z]/) }
58
+ lowercase_pieces = pieces.select { |piece| extract_base_letter(piece).match?(/[a-z]/) }
60
59
 
61
60
  [uppercase_pieces, lowercase_pieces]
62
61
  end
63
62
 
64
- # Formats a group of pieces according to FEEN specification
63
+ # Formats a group of pieces according to FEEN canonical sorting specification
64
+ #
65
+ # Sorting algorithm (FEEN v1.0.0):
66
+ # 1. By quantity (descending)
67
+ # 2. By base letter (ascending)
68
+ # 3. By prefix (-, +, none)
69
+ # 4. By suffix (none, ')
65
70
  #
66
71
  # @param pieces [Array<String>] Array of pieces from the same case group
67
- # @return [String] Formatted string for this group (e.g., "3P2B", "5pq")
72
+ # @return [String] Formatted string for this group (e.g., "2+B5BK3-P-P'3+P'9PR2SS'")
68
73
  private_class_method def self.format_pieces_group(pieces)
69
74
  return "" if pieces.empty?
70
75
 
71
- # Count occurrences of each piece type
72
- piece_counts = pieces.each_with_object(Hash.new(0)) do |piece, counts|
76
+ # Count occurrences of each unique piece (including modifiers)
77
+ piece_counts = pieces.each_with_object(::Hash.new(0)) do |piece, counts|
73
78
  counts[piece] += 1
74
79
  end
75
80
 
76
- # Sort by count (descending) then alphabetically (ascending)
81
+ # Sort according to FEEN canonical sorting algorithm
77
82
  sorted_pieces = piece_counts.sort do |a, b|
78
83
  piece_a, count_a = a
79
84
  piece_b, count_b = b
@@ -82,8 +87,22 @@ module Feen
82
87
  count_comparison = count_b <=> count_a
83
88
  next count_comparison unless count_comparison.zero?
84
89
 
85
- # Secondary sort: by piece name (ascending)
86
- piece_a <=> piece_b
90
+ # Secondary sort: by base letter (ascending)
91
+ base_a = extract_base_letter(piece_a)
92
+ base_b = extract_base_letter(piece_b)
93
+ base_comparison = base_a <=> base_b
94
+ next base_comparison unless base_comparison.zero?
95
+
96
+ # Tertiary sort: by prefix (-, +, none)
97
+ prefix_a = extract_prefix(piece_a)
98
+ prefix_b = extract_prefix(piece_b)
99
+ prefix_comparison = compare_prefixes(prefix_a, prefix_b)
100
+ next prefix_comparison unless prefix_comparison.zero?
101
+
102
+ # Quaternary sort: by suffix (none, ')
103
+ suffix_a = extract_suffix(piece_a)
104
+ suffix_b = extract_suffix(piece_b)
105
+ compare_suffixes(suffix_a, suffix_b)
87
106
  end
88
107
 
89
108
  # Format each piece with its count
@@ -96,34 +115,76 @@ module Feen
96
115
  end.join
97
116
  end
98
117
 
99
- # Validates all piece characters according to FEEN specification (base form only)
118
+ # Extracts the base letter from a PNN piece identifier
119
+ #
120
+ # @param piece [String] PNN piece identifier (e.g., "+P'", "-R", "K")
121
+ # @return [String] Base letter (e.g., "P", "R", "K")
122
+ private_class_method def self.extract_base_letter(piece)
123
+ piece.match(/[a-zA-Z]/)[0]
124
+ end
125
+
126
+ # Extracts the prefix from a PNN piece identifier
127
+ #
128
+ # @param piece [String] PNN piece identifier
129
+ # @return [String, nil] Prefix ("+" or "-") or nil if no prefix
130
+ private_class_method def self.extract_prefix(piece)
131
+ match = piece.match(/\A([+-])/)
132
+ match ? match[1] : nil
133
+ end
134
+
135
+ # Extracts the suffix from a PNN piece identifier
136
+ #
137
+ # @param piece [String] PNN piece identifier
138
+ # @return [String, nil] Suffix ("'") or nil if no suffix
139
+ private_class_method def self.extract_suffix(piece)
140
+ piece.end_with?("'") ? "'" : nil
141
+ end
142
+
143
+ # Compares prefixes according to FEEN sorting order: -, +, none
144
+ #
145
+ # @param prefix_a [String, nil] First prefix
146
+ # @param prefix_b [String, nil] Second prefix
147
+ # @return [Integer] Comparison result (-1, 0, 1)
148
+ private_class_method def self.compare_prefixes(prefix_a, prefix_b)
149
+ prefix_order = { "-" => 0, "+" => 1, nil => 2 }
150
+ prefix_order[prefix_a] <=> prefix_order[prefix_b]
151
+ end
152
+
153
+ # Compares suffixes according to FEEN sorting order: none, '
154
+ #
155
+ # @param suffix_a [String, nil] First suffix
156
+ # @param suffix_b [String, nil] Second suffix
157
+ # @return [Integer] Comparison result (-1, 0, 1)
158
+ private_class_method def self.compare_suffixes(suffix_a, suffix_b)
159
+ suffix_order = { nil => 0, "'" => 1 }
160
+ suffix_order[suffix_a] <=> suffix_order[suffix_b]
161
+ end
162
+
163
+ # Validates all piece characters according to PNN specification
100
164
  #
101
165
  # @param piece_chars [Array<Object>] Array of piece character candidates
102
166
  # @return [Array<String>] Array of validated piece characters
103
- # @raise [ArgumentError] If any piece character is invalid or contains modifiers
167
+ # @raise [ArgumentError] If any piece character is invalid PNN notation
104
168
  private_class_method def self.validate_piece_chars(piece_chars)
105
169
  piece_chars.each_with_index.map do |char, index|
106
170
  validate_piece_char(char, index)
107
171
  end
108
172
  end
109
173
 
110
- # Validates a single piece character according to FEEN specification
111
- # For pieces in hand, only base form is allowed: single letter (a-z or A-Z)
112
- # NO modifiers (+, -, ') are allowed in pieces in hand
174
+ # Validates a single piece character according to PNN specification
175
+ # Per FEEN v1.0.0: pieces in hand may include PNN modifiers (prefixes and suffixes)
113
176
  #
114
177
  # @param char [Object] Piece character candidate
115
178
  # @param index [Integer] Index of the character in the original array
116
179
  # @return [String] Validated piece character
117
- # @raise [ArgumentError] If the piece character is invalid or contains modifiers
180
+ # @raise [ArgumentError] If the piece character is invalid PNN notation
118
181
  private_class_method def self.validate_piece_char(char, index)
119
182
  # Validate type
120
- raise ArgumentError, format(ERRORS[:invalid_type], index, char.class) unless char.is_a?(String)
121
-
122
- # Check for forbidden modifiers first (clearer error message)
123
- raise ArgumentError, format(ERRORS[:has_modifiers], index, char) if char.match?(/[+\-']/)
183
+ raise ::ArgumentError, format(ERRORS[:invalid_type], index, char.class) unless char.is_a?(::String)
124
184
 
125
- # Validate format: must be exactly one letter (base form only)
126
- raise ArgumentError, format(ERRORS[:invalid_format], index, char) unless char.match?(/\A[a-zA-Z]\z/)
185
+ # Validate PNN format using the PNN gem
186
+ # @see https://rubygems.org/gems/pnn
187
+ raise ::ArgumentError, format(ERRORS[:invalid_pnn], index, char) unless ::Pnn.valid?(char)
127
188
 
128
189
  char
129
190
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sashite-snn"
4
+
5
+ module Feen
6
+ module Dumper
7
+ # Handles conversion of style turn data to FEEN notation string
8
+ module StyleTurn
9
+ # Error messages for validation
10
+ ERRORS = {
11
+ invalid_type: "%s must be a String, got %s",
12
+ empty_string: "%s cannot be empty",
13
+ invalid_snn: "%s must be valid SNN notation: %s",
14
+ same_casing: "One style must be uppercase and the other lowercase"
15
+ }.freeze
16
+
17
+ # Converts the active and inactive style identifiers to a FEEN-formatted style turn string
18
+ #
19
+ # @param active_style [String] Identifier for the player to move and their style
20
+ # @param inactive_style [String] Identifier for the opponent and their style
21
+ # @return [String] FEEN-formatted style turn string
22
+ # @raise [ArgumentError] If the style identifiers are invalid
23
+ #
24
+ # @example Valid style turn
25
+ # StyleTurn.dump("CHESS", "chess")
26
+ # # => "CHESS/chess"
27
+ #
28
+ # @example Valid style turn with variants
29
+ # StyleTurn.dump("CHESS960", "makruk")
30
+ # # => "CHESS960/makruk"
31
+ #
32
+ # @example Invalid - same casing
33
+ # StyleTurn.dump("CHESS", "MAKRUK")
34
+ # # => ArgumentError: One style must be uppercase and the other lowercase
35
+ def self.dump(active_style, inactive_style)
36
+ validate_styles(active_style, inactive_style)
37
+ "#{active_style}/#{inactive_style}"
38
+ end
39
+
40
+ # Validates the style identifiers according to SNN specification
41
+ #
42
+ # @param active [String] The active player's style identifier
43
+ # @param inactive [String] The inactive player's style identifier
44
+ # @raise [ArgumentError] If the style identifiers are invalid
45
+ # @return [void]
46
+ private_class_method def self.validate_styles(active, inactive)
47
+ # Validate basic type and SNN format
48
+ [["Active style", active], ["Inactive style", inactive]].each do |name, style|
49
+ # Type validation
50
+ raise ArgumentError, format(ERRORS[:invalid_type], name, style.class) unless style.is_a?(::String)
51
+
52
+ # Empty validation
53
+ raise ArgumentError, format(ERRORS[:empty_string], name) if style.empty?
54
+
55
+ # SNN format validation using the sashite-snn gem
56
+ # @see https://rubygems.org/gems/sashite-snn
57
+ raise ArgumentError, format(ERRORS[:invalid_snn], name, style) unless ::Sashite::Snn.valid?(style)
58
+ end
59
+
60
+ # Casing difference validation
61
+ active_is_uppercase = active == active.upcase
62
+ inactive_is_uppercase = inactive == inactive.upcase
63
+
64
+ # Both styles must have different casing
65
+ return unless active_is_uppercase == inactive_is_uppercase
66
+
67
+ raise ArgumentError, ERRORS[:same_casing]
68
+ end
69
+ end
70
+ end
71
+ end
data/lib/feen/dumper.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("dumper", "games_turn")
3
+ require_relative File.join("dumper", "style_turn")
4
4
  require_relative File.join("dumper", "piece_placement")
5
5
  require_relative File.join("dumper", "pieces_in_hand")
6
6
 
@@ -14,7 +14,7 @@ module Feen
14
14
  # Error messages for validation
15
15
  ERRORS = {
16
16
  invalid_piece_placement_type: "Piece placement must be an Array, got %s",
17
- invalid_games_turn_type: "Games turn must be an Array with exactly two elements, got %s",
17
+ invalid_style_turn_type: "Style turn must be an Array with exactly two elements, got %s",
18
18
  invalid_pieces_in_hand_type: "Pieces in hand must be an Array, got %s"
19
19
  }.freeze
20
20
 
@@ -33,52 +33,52 @@ module Feen
33
33
  # ["R", "N", "B", "Q", "K", "B", "N", "R"]
34
34
  # ],
35
35
  # pieces_in_hand: [],
36
- # games_turn: ["CHESS", "chess"]
36
+ # style_turn: ["CHESS", "chess"]
37
37
  # )
38
38
  # # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
39
39
  #
40
40
  # @param piece_placement [Array] Board position data structure representing the spatial
41
41
  # distribution of pieces across the board, where each cell
42
42
  # is represented by a String (or empty string for empty cells)
43
- # @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board,
44
- # each represented as a single character string (base form only)
45
- # @param games_turn [Array<String>] A two-element array where the first element is the
46
- # active player's variant and the second is the inactive player's variant
43
+ # @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board.
44
+ # May include PNN modifiers (per FEEN v1.0.0 specification)
45
+ # @param style_turn [Array<String>] A two-element array where the first element is the
46
+ # active player's style and the second is the inactive player's style
47
47
  # @return [String] Complete FEEN string representation compliant with the specification
48
48
  # @raise [ArgumentError] If any input parameter is invalid
49
49
  # @see https://sashite.dev/documents/feen/1.0.0/ FEEN Specification v1.0.0
50
- def self.dump(piece_placement:, pieces_in_hand:, games_turn:)
50
+ def self.dump(piece_placement:, pieces_in_hand:, style_turn:)
51
51
  # Validate input types
52
- validate_inputs(piece_placement, games_turn, pieces_in_hand)
52
+ validate_inputs(piece_placement, style_turn, pieces_in_hand)
53
53
 
54
54
  # Process each component with appropriate submodule and combine into final FEEN string
55
55
  [
56
56
  PiecePlacement.dump(piece_placement),
57
57
  PiecesInHand.dump(*pieces_in_hand),
58
- GamesTurn.dump(*games_turn)
58
+ StyleTurn.dump(*style_turn)
59
59
  ].join(FIELD_SEPARATOR)
60
60
  end
61
61
 
62
62
  # Validates the input parameters for type and structure
63
63
  #
64
64
  # @param piece_placement [Object] Piece placement parameter to validate
65
- # @param games_turn [Object] Games turn parameter to validate
65
+ # @param style_turn [Object] Style turn parameter to validate
66
66
  # @param pieces_in_hand [Object] Pieces in hand parameter to validate
67
67
  # @raise [ArgumentError] If any parameter is invalid
68
68
  # @return [void]
69
- private_class_method def self.validate_inputs(piece_placement, games_turn, pieces_in_hand)
69
+ private_class_method def self.validate_inputs(piece_placement, style_turn, pieces_in_hand)
70
70
  # Validate piece_placement is an Array
71
- unless piece_placement.is_a?(Array)
71
+ unless piece_placement.is_a?(::Array)
72
72
  raise ArgumentError, format(ERRORS[:invalid_piece_placement_type], piece_placement.class)
73
73
  end
74
74
 
75
- # Validate games_turn is an Array with exactly 2 elements
76
- unless games_turn.is_a?(Array) && games_turn.size == 2
77
- raise ArgumentError, format(ERRORS[:invalid_games_turn_type], games_turn.inspect)
75
+ # Validate style_turn is an Array with exactly 2 elements
76
+ unless style_turn.is_a?(::Array) && style_turn.size == 2
77
+ raise ArgumentError, format(ERRORS[:invalid_style_turn_type], style_turn.inspect)
78
78
  end
79
79
 
80
80
  # Validate pieces_in_hand is an Array
81
- return if pieces_in_hand.is_a?(Array)
81
+ return if pieces_in_hand.is_a?(::Array)
82
82
 
83
83
  raise ArgumentError, format(ERRORS[:invalid_pieces_in_hand_type], pieces_in_hand.class)
84
84
  end
@@ -1,31 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pnn"
4
+
3
5
  module Feen
4
6
  module Parser
5
7
  # Handles parsing of the pieces in hand section of a FEEN string.
6
8
  # Pieces in hand represent pieces available for dropping onto the board.
7
- # According to FEEN specification, pieces in hand MUST be in base form only (no modifiers).
9
+ # According to FEEN v1.0.0 specification, pieces in hand MAY include PNN modifiers.
8
10
  # Format: "UPPERCASE_PIECES/LOWERCASE_PIECES"
9
11
  module PiecesInHand
10
12
  # Error messages for validation
11
- Errors = {
12
- invalid_type: "Pieces in hand must be a string, got %s",
13
- empty_string: "Pieces in hand string cannot be empty",
14
- invalid_format: "Invalid pieces in hand format: %s",
15
- missing_separator: "Pieces in hand format must contain exactly one '/' separator. Got: %s",
16
- modifiers_not_allowed: 'Pieces in hand cannot contain modifiers: "%s"'
13
+ ERRORS = {
14
+ invalid_type: "Pieces in hand must be a string, got %s",
15
+ empty_string: "Pieces in hand string cannot be empty",
16
+ invalid_format: "Invalid pieces in hand format: %s",
17
+ missing_separator: "Pieces in hand format must contain exactly one '/' separator. Got: %s",
18
+ invalid_pnn: "Invalid PNN piece notation: '%s'",
19
+ invalid_count: "Invalid count format: '%s'. Count cannot be '0' or '1'"
17
20
  }.freeze
18
21
 
19
- # Base piece pattern: single letter only (no modifiers allowed in hand)
20
- BASE_PIECE_PATTERN = /\A[a-zA-Z]\z/
21
-
22
22
  # Valid count pattern: 2-9 or any number with 2+ digits (no 0, 1, or leading zeros)
23
23
  VALID_COUNT_PATTERN = /\A(?:[2-9]|[1-9]\d+)\z/
24
24
 
25
- # Pattern for piece with optional count in pieces in hand
25
+ # Pattern for piece with optional count in pieces in hand (with full PNN support)
26
26
  PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|[1-9]\d+))?([-+]?[a-zA-Z]'?)/
27
27
 
28
- # Complete validation pattern for pieces in hand string
28
+ # Complete validation pattern for pieces in hand string (with PNN modifier support)
29
29
  VALID_FORMAT_PATTERN = %r{\A
30
30
  (?: # Uppercase section (optional)
31
31
  (?:(?:[2-9]|[1-9]\d+)?[-+]?[A-Z]'?)* # Zero or more uppercase pieces with optional counts and modifiers
@@ -39,10 +39,10 @@ module Feen
39
39
  # Parses the pieces in hand section of a FEEN string.
40
40
  #
41
41
  # @param pieces_in_hand_str [String] FEEN pieces in hand string in format "UPPERCASE/lowercase"
42
- # @return [Array<String>] Array of piece identifiers in base form only,
43
- # expanded based on their counts and sorted alphabetically.
44
- # Empty array if no pieces are in hand.
45
- # @raise [ArgumentError] If the input string is invalid or contains modifiers
42
+ # @return [Array<String>] Array of piece identifiers (may include PNN modifiers),
43
+ # expanded based on their counts. Pieces are returned in the order they appear
44
+ # in the canonical FEEN string (not sorted alphabetically).
45
+ # @raise [ArgumentError] If the input string is invalid
46
46
  #
47
47
  # @example Parse no pieces in hand
48
48
  # PiecesInHand.parse("/")
@@ -50,11 +50,11 @@ module Feen
50
50
  #
51
51
  # @example Parse pieces with case separation
52
52
  # PiecesInHand.parse("3P2B/p")
53
- # # => ["B", "B", "P", "P", "P", "p"]
53
+ # # => ["P", "P", "P", "B", "B", "p"]
54
54
  #
55
- # @example Invalid - modifiers not allowed in hand
56
- # PiecesInHand.parse("+P/p")
57
- # # => ArgumentError: Pieces in hand cannot contain modifiers: '+P'
55
+ # @example Parse pieces with PNN modifiers
56
+ # PiecesInHand.parse("2+B5BK3-P-P'3+P'9PR2SS'/bp")
57
+ # # => ["+B", "+B", "B", "B", "B", "B", "B", "K", "-P", "-P", "-P", "-P'", "+P'", "+P'", "+P'", "P", "P", "P", "P", "P", "P", "P", "P", "P", "R", "S", "S", "S'", "b", "p"]
58
58
  def self.parse(pieces_in_hand_str)
59
59
  validate_input_type(pieces_in_hand_str)
60
60
  validate_format(pieces_in_hand_str)
@@ -65,13 +65,13 @@ module Feen
65
65
  # Split by the separator to get uppercase and lowercase sections
66
66
  uppercase_section, lowercase_section = pieces_in_hand_str.split("/", 2)
67
67
 
68
- # Parse each section separately and validate no modifiers
68
+ # Parse each section separately
69
69
  uppercase_pieces = parse_pieces_section(uppercase_section || "", :uppercase)
70
70
  lowercase_pieces = parse_pieces_section(lowercase_section || "", :lowercase)
71
71
 
72
- # Combine all pieces and sort them alphabetically
73
- all_pieces = uppercase_pieces + lowercase_pieces
74
- all_pieces.sort
72
+ # Combine all pieces in order (uppercase first, then lowercase)
73
+ # Do NOT sort - preserve the canonical order from the FEEN string
74
+ uppercase_pieces + lowercase_pieces
75
75
  end
76
76
 
77
77
  # Validates that the input is a non-empty string.
@@ -80,41 +80,23 @@ module Feen
80
80
  # @raise [ArgumentError] If input is not a string or is empty
81
81
  # @return [void]
82
82
  private_class_method def self.validate_input_type(str)
83
- raise ::ArgumentError, format(Errors[:invalid_type], str.class) unless str.is_a?(::String)
84
- raise ::ArgumentError, Errors[:empty_string] if str.empty?
83
+ raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(::String)
84
+ raise ArgumentError, ERRORS[:empty_string] if str.empty?
85
85
  end
86
86
 
87
87
  # Validates that the input string matches the expected format according to FEEN specification.
88
- # Format must be: "UPPERCASE_PIECES/LOWERCASE_PIECES" with base pieces only (no modifiers).
88
+ # Format must be: "UPPERCASE_PIECES/LOWERCASE_PIECES" with optional PNN modifiers.
89
89
  #
90
90
  # @param str [String] Input string to validate
91
- # @raise [ArgumentError] If format is invalid or contains modifiers
91
+ # @raise [ArgumentError] If format is invalid
92
92
  # @return [void]
93
93
  private_class_method def self.validate_format(str)
94
94
  # Must contain exactly one "/" separator
95
95
  parts_count = str.count("/")
96
- raise ::ArgumentError, format(Errors[:missing_separator], parts_count) unless parts_count == 1
97
-
98
- # Must match the overall pattern (including potential modifiers for detection)
99
- raise ::ArgumentError, format(Errors[:invalid_format], str) unless str.match?(VALID_FORMAT_PATTERN)
96
+ raise ArgumentError, format(ERRORS[:missing_separator], parts_count) unless parts_count == 1
100
97
 
101
- # Additional validation: check for any modifiers (forbidden in hand)
102
- validate_no_modifiers(str)
103
- end
104
-
105
- # Validates that no modifiers are present in the pieces in hand string
106
- #
107
- # @param str [String] Input string to validate
108
- # @raise [ArgumentError] If modifiers are found
109
- # @return [void]
110
- private_class_method def self.validate_no_modifiers(str)
111
- # Check for any modifier characters that are forbidden in pieces in hand
112
- return unless str.match?(/[+\-']/)
113
-
114
- # Find the specific invalid piece to provide a better error message
115
- invalid_pieces = str.scan(/(?:[2-9]|[1-9]\d+)?[-+]?[a-zA-Z]'?/).grep(/[+\-']/)
116
-
117
- raise ::ArgumentError, format(Errors[:modifiers_not_allowed], invalid_pieces.first)
98
+ # Must match the overall pattern
99
+ raise ArgumentError, format(ERRORS[:invalid_format], str) unless str.match?(VALID_FORMAT_PATTERN)
118
100
  end
119
101
 
120
102
  # Parses a specific section (uppercase or lowercase) and returns expanded pieces
@@ -137,7 +119,7 @@ module Feen
137
119
  # @param section [String] FEEN pieces section string
138
120
  # @param case_type [Symbol] Either :uppercase or :lowercase
139
121
  # @return [Array<Hash>] Array of hashes with :piece and :count keys
140
- # @raise [ArgumentError] If pieces don't match the expected case or contain modifiers
122
+ # @raise [ArgumentError] If pieces contain invalid PNN notation
141
123
  private_class_method def self.extract_pieces_with_counts_from_section(section, case_type)
142
124
  result = []
143
125
  position = 0
@@ -149,28 +131,27 @@ module Feen
149
131
  count_str, piece_with_modifiers = match.captures
150
132
  count = count_str ? count_str.to_i : 1
151
133
 
152
- # Extract just the base piece (remove any modifiers)
153
- base_piece = extract_base_piece(piece_with_modifiers)
154
-
155
- # Validate piece is base form only (single letter)
156
- unless base_piece.match?(BASE_PIECE_PATTERN)
157
- raise ::ArgumentError, "Pieces in hand must be base form only: '#{base_piece}'"
134
+ # Validate PNN format using the PNN gem
135
+ # @see https://rubygems.org/gems/pnn
136
+ unless ::Pnn.valid?(piece_with_modifiers)
137
+ raise ::ArgumentError, format(ERRORS[:invalid_pnn], piece_with_modifiers)
158
138
  end
159
139
 
160
140
  # Validate count format
161
141
  if count_str && !count_str.match?(VALID_COUNT_PATTERN)
162
- raise ::ArgumentError, "Invalid count format: '#{count_str}'. Count cannot be '0' or '1', use the piece without count instead"
142
+ raise ::ArgumentError, format(ERRORS[:invalid_count], count_str)
163
143
  end
164
144
 
165
- # Validate that the piece matches the expected case
166
- piece_case = base_piece.match?(/[A-Z]/) ? :uppercase : :lowercase
145
+ # Validate that the piece matches the expected case (based on base letter)
146
+ base_letter = extract_base_letter(piece_with_modifiers)
147
+ piece_case = base_letter.match?(/[A-Z]/) ? :uppercase : :lowercase
167
148
  unless piece_case == case_type
168
149
  case_name = case_type == :uppercase ? "uppercase" : "lowercase"
169
- raise ::ArgumentError, "Piece '#{base_piece}' has wrong case for #{case_name} section"
150
+ raise ::ArgumentError, "Piece '#{piece_with_modifiers}' has wrong case for #{case_name} section"
170
151
  end
171
152
 
172
153
  # Add to our result with piece type and count
173
- result << { piece: base_piece, count: count }
154
+ result << { piece: piece_with_modifiers, count: count }
174
155
 
175
156
  # Move position forward
176
157
  position += match[0].length
@@ -179,25 +160,21 @@ module Feen
179
160
  result
180
161
  end
181
162
 
182
- # Extracts the base piece from a piece string that may contain modifiers
163
+ # Extracts the base letter from a PNN piece identifier
183
164
  #
184
- # @param piece_str [String] Piece string potentially with modifiers
185
- # @return [String] Base piece without modifiers
186
- private_class_method def self.extract_base_piece(piece_str)
187
- # Remove prefix modifiers (+ or -)
188
- without_prefix = piece_str.gsub(/^[-+]/, "")
189
-
190
- # Remove suffix modifiers (')
191
- without_prefix.gsub(/'$/, "")
165
+ # @param piece [String] PNN piece identifier (e.g., "+P'", "-R", "K")
166
+ # @return [String] Base letter (e.g., "P", "R", "K")
167
+ private_class_method def self.extract_base_letter(piece)
168
+ piece.match(/[a-zA-Z]/)[0]
192
169
  end
193
170
 
194
171
  # Expands the pieces based on their counts into an array.
195
172
  #
196
173
  # @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
197
- # @return [Array<String>] Array of expanded pieces
174
+ # @return [Array<String>] Array of expanded pieces preserving order
198
175
  private_class_method def self.expand_pieces(pieces_with_counts)
199
176
  pieces_with_counts.flat_map do |item|
200
- Array.new(item[:count], item[:piece])
177
+ ::Array.new(item[:count], item[:piece])
201
178
  end
202
179
  end
203
180
  end