feen 5.0.0.beta6 → 5.0.0.beta8

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,57 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("pieces_in_hand", "errors")
4
- require_relative File.join("pieces_in_hand", "no_pieces")
5
- require_relative File.join("pieces_in_hand", "pnn_patterns")
6
- require_relative File.join("pieces_in_hand", "canonical_sorter")
7
-
8
3
  module Feen
9
4
  module Parser
10
5
  # Handles parsing of the pieces in hand section of a FEEN string.
11
6
  # Pieces in hand represent pieces available for dropping onto the board.
12
- # This implementation supports full PNN notation including prefixes and suffixes.
7
+ # According to FEEN specification, pieces in hand MUST be in base form only (no modifiers).
8
+ # Format: "UPPERCASE_PIECES/LOWERCASE_PIECES"
13
9
  module PiecesInHand
10
+ # 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
+ }.freeze
17
+
18
+ # Base piece pattern: single letter only (no modifiers allowed in hand)
19
+ BASE_PIECE_PATTERN = /\A[a-zA-Z]\z/
20
+
21
+ # Valid count pattern: 2-9 or any number with 2+ digits (no 0, 1, or leading zeros)
22
+ VALID_COUNT_PATTERN = /\A(?:[2-9]|[1-9]\d+)\z/
23
+
24
+ # Pattern for piece with optional count in pieces in hand
25
+ PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|[1-9]\d+))?([a-zA-Z])/
26
+
27
+ # Complete validation pattern for pieces in hand string
28
+ VALID_FORMAT_PATTERN = %r{\A
29
+ (?: # Uppercase section (optional)
30
+ (?:(?:[2-9]|[1-9]\d+)?[A-Z])* # Zero or more uppercase pieces with optional counts
31
+ )
32
+ / # Mandatory separator
33
+ (?: # Lowercase section (optional)
34
+ (?:(?:[2-9]|[1-9]\d+)?[a-z])* # Zero or more lowercase pieces with optional counts
35
+ )
36
+ \z}x
37
+
14
38
  # Parses the pieces in hand section of a FEEN string.
15
39
  #
16
- # @param pieces_in_hand_str [String] FEEN pieces in hand string
17
- # @return [Array<String>] Array of piece identifiers in full PNN format,
18
- # expanded based on their counts and sorted according to FEEN specification:
19
- # 1. By quantity (descending)
20
- # 2. By complete PNN representation (alphabetically ascending)
40
+ # @param pieces_in_hand_str [String] FEEN pieces in hand string in format "UPPERCASE/lowercase"
41
+ # @return [Array<String>] Array of piece identifiers in base form only,
42
+ # expanded based on their counts and sorted alphabetically.
21
43
  # Empty array if no pieces are in hand.
22
- # @raise [ArgumentError] If the input string is invalid
44
+ # @raise [ArgumentError] If the input string is invalid or contains modifiers
23
45
  #
24
46
  # @example Parse no pieces in hand
25
- # PiecesInHand.parse("-")
47
+ # PiecesInHand.parse("/")
26
48
  # # => []
27
49
  #
28
- # @example Parse pieces with modifiers
29
- # PiecesInHand.parse("3+P2B'Pn")
30
- # # => ["+P", "+P", "+P", "B'", "B'", "P", "n"]
50
+ # @example Parse pieces with case separation
51
+ # PiecesInHand.parse("3P2B/p")
52
+ # # => ["B", "B", "P", "P", "P", "p"]
31
53
  #
32
- # @example Parse complex pieces with counts and modifiers
33
- # PiecesInHand.parse("10P5K3B2p'+P-pBRbq")
34
- # # => ["P", "P", "P", "P", "P", "P", "P", "P", "P", "P",
35
- # # "K", "K", "K", "K", "K",
36
- # # "B", "B", "B",
37
- # # "p'", "p'",
38
- # # "+P", "-p", "B", "R", "b", "q"]
54
+ # @example Invalid - modifiers not allowed in hand
55
+ # PiecesInHand.parse("+P/p")
56
+ # # => ArgumentError: Pieces in hand cannot contain modifiers: '+P'
39
57
  def self.parse(pieces_in_hand_str)
40
- # Validate input
41
58
  validate_input_type(pieces_in_hand_str)
42
59
  validate_format(pieces_in_hand_str)
43
60
 
44
61
  # Handle the no-pieces case early
45
- return [] if pieces_in_hand_str == NoPieces
62
+ return [] if pieces_in_hand_str == "/"
46
63
 
47
- # Extract pieces with their counts and validate the format
48
- pieces_with_counts = extract_pieces_with_counts(pieces_in_hand_str)
64
+ # Split by the separator to get uppercase and lowercase sections
65
+ uppercase_section, lowercase_section = pieces_in_hand_str.split("/", 2)
49
66
 
50
- # Validate canonical ordering according to FEEN specification
51
- validate_canonical_order(pieces_with_counts)
67
+ # Parse each section separately and validate no modifiers
68
+ uppercase_pieces = parse_pieces_section(uppercase_section || "", :uppercase)
69
+ lowercase_pieces = parse_pieces_section(lowercase_section || "", :lowercase)
52
70
 
53
- # Expand the pieces into an array maintaining the canonical order
54
- expand_pieces(pieces_with_counts)
71
+ # Combine all pieces and sort them alphabetically
72
+ all_pieces = uppercase_pieces + lowercase_pieces
73
+ all_pieces.sort
55
74
  end
56
75
 
57
76
  # Validates that the input is a non-empty string.
@@ -65,126 +84,87 @@ module Feen
65
84
  end
66
85
 
67
86
  # Validates that the input string matches the expected format according to FEEN specification.
68
- # This includes validation of individual PNN pieces and overall structure.
87
+ # Format must be: "UPPERCASE_PIECES/LOWERCASE_PIECES" with base pieces only (no modifiers).
69
88
  #
70
89
  # @param str [String] Input string to validate
71
- # @raise [ArgumentError] If format is invalid
90
+ # @raise [ArgumentError] If format is invalid or contains modifiers
72
91
  # @return [void]
73
92
  private_class_method def self.validate_format(str)
74
- return if str == NoPieces
93
+ # Must contain exactly one "/" separator
94
+ parts_count = str.count("/")
95
+ raise ::ArgumentError, format(Errors[:missing_separator], parts_count) unless parts_count == 1
75
96
 
76
- # First, validate overall structure using the updated pattern
77
- raise ::ArgumentError, format(Errors[:invalid_format], str) unless str.match?(PnnPatterns::VALID_FORMAT_PATTERN)
97
+ # Must match the overall pattern
98
+ raise ::ArgumentError, format(Errors[:invalid_format], str) unless str.match?(VALID_FORMAT_PATTERN)
78
99
 
79
- # Additional validation: ensure each piece component is valid PNN
80
- # This catches cases like "++P" that might pass the overall pattern
81
- validate_individual_pieces(str)
100
+ # Additional validation: check for any modifiers (forbidden in hand)
101
+ validate_no_modifiers(str)
82
102
  end
83
103
 
84
- # Validates each individual piece in the string for PNN compliance
104
+ # Validates that no modifiers are present in the pieces in hand string
85
105
  #
86
- # @param str [String] FEEN pieces in hand string
87
- # @raise [ArgumentError] If any piece is invalid PNN format
106
+ # @param str [String] Input string to validate
107
+ # @raise [ArgumentError] If modifiers are found
88
108
  # @return [void]
89
- private_class_method def self.validate_individual_pieces(str)
90
- original_string = str
91
- position = 0
92
-
93
- while position < str.length
94
- match = str[position..].match(PnnPatterns::PIECE_WITH_COUNT_PATTERN)
95
-
96
- unless match
97
- # Find the problematic part
98
- remaining = str[position..]
99
- raise ::ArgumentError, format(Errors[:invalid_format], remaining)
100
- end
101
-
102
- count_str, piece = match.captures
103
-
104
- # Skip empty matches (shouldn't happen with our pattern, but safety check)
105
- if piece.nil? || piece.empty?
106
- position += 1
107
- next
108
- end
109
-
110
- # Validate the piece follows PNN specification
111
- unless piece.match?(PnnPatterns::PNN_PIECE_PATTERN)
112
- raise ::ArgumentError, format(Errors[:invalid_pnn_piece], piece)
113
- end
109
+ private_class_method def self.validate_no_modifiers(str)
110
+ # Check for any modifier characters that are forbidden in pieces in hand
111
+ return unless str.match?(/[+\-']/)
114
112
 
115
- # Validate count format (no "0" or "1" prefixes allowed)
116
- if count_str && !count_str.match?(PnnPatterns::VALID_COUNT_PATTERN)
117
- raise ::ArgumentError, format(Errors[:invalid_count], count_str)
118
- end
119
-
120
- position += match[0].length
121
- end
113
+ # Find the specific invalid piece to provide a better error message
114
+ invalid_pieces = str.scan(/(?:[2-9]|[1-9]\d+)?[+\-']?[a-zA-Z]'?/).grep(/[+\-']/)
122
115
 
123
- # Final check: verify that we can reconstruct the string correctly
124
- # by re-extracting all pieces and comparing with original
125
- reconstructed_pieces = extract_pieces_with_counts(original_string)
126
- reconstructed = reconstructed_pieces.map do |item|
127
- count = item[:count]
128
- piece = item[:piece]
129
- count == 1 ? piece : "#{count}#{piece}"
130
- end.join
131
-
132
- # If reconstruction doesn't match original, there's an invalid format
133
- return if reconstructed == original_string
134
- # Find the first discrepancy to provide better error message
135
- # This will catch cases like "++P" where we extract "+P" but original has extra "+"
136
- unless original_string.length > reconstructed.length
137
- raise ::ArgumentError, format(Errors[:invalid_format], original_string)
138
- end
139
-
140
- # There are extra characters - find what's invalid
141
- original_string.sub(reconstructed, "")
142
- # Try to identify the problematic piece
143
- problematic_part = find_problematic_piece(original_string, reconstructed)
144
- raise ::ArgumentError, format(Errors[:invalid_pnn_piece], problematic_part)
116
+ raise ::ArgumentError, "Pieces in hand cannot contain modifiers: '#{invalid_pieces.first}'"
145
117
  end
146
118
 
147
- # Finds the problematic piece in the original string by comparing with reconstruction
119
+ # Parses a specific section (uppercase or lowercase) and returns expanded pieces
148
120
  #
149
- # @param original [String] Original input string
150
- # @param reconstructed [String] Reconstructed string from extracted pieces
151
- # @return [String] The problematic piece or sequence
152
- private_class_method def self.find_problematic_piece(original, reconstructed)
153
- # Simple heuristic: find the first part that doesn't match
154
- min_length = [original.length, reconstructed.length].min
155
-
156
- # Find first difference
157
- diff_pos = 0
158
- diff_pos += 1 while diff_pos < min_length && original[diff_pos] == reconstructed[diff_pos]
159
-
160
- # If difference is at start, likely extra prefix
161
- # Look for a sequence that starts with invalid pattern like "++"
162
- if (diff_pos == 0) && original.match?(/\A\+\+/)
163
- return "++P" # Common case
164
- end
121
+ # @param section [String] The section string to parse
122
+ # @param case_type [Symbol] Either :uppercase or :lowercase (for validation)
123
+ # @return [Array<String>] Array of expanded pieces from this section
124
+ private_class_method def self.parse_pieces_section(section, case_type)
125
+ return [] if section.empty?
165
126
 
166
- # Extract a reasonable chunk around the problematic area
167
- start_pos = [0, diff_pos - 2].max
168
- end_pos = [original.length, diff_pos + 4].min
169
- original[start_pos...end_pos]
127
+ # Extract pieces with their counts
128
+ pieces_with_counts = extract_pieces_with_counts_from_section(section, case_type)
129
+
130
+ # Expand the pieces into an array
131
+ expand_pieces(pieces_with_counts)
170
132
  end
171
133
 
172
- # Extracts pieces with their counts from the FEEN string.
173
- # Supports full PNN notation including prefixes and suffixes.
134
+ # Extracts pieces with their counts from a section string.
174
135
  #
175
- # @param str [String] FEEN pieces in hand string
136
+ # @param section [String] FEEN pieces section string
137
+ # @param case_type [Symbol] Either :uppercase or :lowercase
176
138
  # @return [Array<Hash>] Array of hashes with :piece and :count keys
177
- private_class_method def self.extract_pieces_with_counts(str)
139
+ # @raise [ArgumentError] If pieces don't match the expected case or contain modifiers
140
+ private_class_method def self.extract_pieces_with_counts_from_section(section, case_type)
178
141
  result = []
179
142
  position = 0
180
143
 
181
- while position < str.length
182
- match = str[position..].match(PnnPatterns::PIECE_WITH_COUNT_PATTERN)
144
+ while position < section.length
145
+ match = section[position..].match(PIECE_WITH_COUNT_PATTERN)
183
146
  break unless match
184
147
 
185
148
  count_str, piece = match.captures
186
149
  count = count_str ? count_str.to_i : 1
187
150
 
151
+ # Validate piece is base form only (single letter)
152
+ unless piece.match?(BASE_PIECE_PATTERN)
153
+ raise ::ArgumentError, "Pieces in hand must be base form only: '#{piece}'"
154
+ end
155
+
156
+ # Validate count format
157
+ if count_str && !count_str.match?(VALID_COUNT_PATTERN)
158
+ raise ::ArgumentError, "Invalid count format: '#{count_str}'. Count cannot be '0' or '1', use the piece without count instead"
159
+ end
160
+
161
+ # Validate that the piece matches the expected case
162
+ piece_case = piece.match?(/[A-Z]/) ? :uppercase : :lowercase
163
+ unless piece_case == case_type
164
+ case_name = case_type == :uppercase ? "uppercase" : "lowercase"
165
+ raise ::ArgumentError, "Piece '#{piece}' has wrong case for #{case_name} section"
166
+ end
167
+
188
168
  # Add to our result with piece type and count
189
169
  result << { piece: piece, count: count }
190
170
 
@@ -195,24 +175,10 @@ module Feen
195
175
  result
196
176
  end
197
177
 
198
- # Validates that pieces are in canonical order according to FEEN specification:
199
- # 1. By quantity (descending)
200
- # 2. By complete PNN representation (alphabetically ascending)
201
- #
202
- # @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
203
- # @raise [ArgumentError] If pieces are not in canonical order
204
- # @return [void]
205
- private_class_method def self.validate_canonical_order(pieces_with_counts)
206
- return if pieces_with_counts.size <= 1
207
-
208
- CanonicalSorter.validate_order(pieces_with_counts)
209
- end
210
-
211
178
  # Expands the pieces based on their counts into an array.
212
- # Maintains the canonical ordering from the input.
213
179
  #
214
180
  # @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
215
- # @return [Array<String>] Array of expanded pieces in canonical order
181
+ # @return [Array<String>] Array of expanded pieces
216
182
  private_class_method def self.expand_pieces(pieces_with_counts)
217
183
  pieces_with_counts.flat_map do |item|
218
184
  Array.new(item[:count], item[:piece])
data/lib/feen/parser.rb CHANGED
@@ -21,12 +21,12 @@ module Feen
21
21
  # @param feen_string [String] Complete FEEN notation string
22
22
  # @return [Hash] Hash containing the parsed position data with the following keys:
23
23
  # - :piece_placement [Array] - Hierarchical array structure representing the board
24
- # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board
24
+ # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (base form only)
25
25
  # - :games_turn [Array<String>] - A two-element array with [active_variant, inactive_variant]
26
26
  # @raise [ArgumentError] If the FEEN string is invalid
27
27
  #
28
28
  # @example Parsing a standard chess initial position
29
- # feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
29
+ # feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
30
30
  # result = Feen::Parser.parse(feen)
31
31
  # # => {
32
32
  # # piece_placement: [
@@ -76,7 +76,7 @@ module Feen
76
76
  # @return [Hash, nil] Hash containing the parsed position data or nil if parsing fails
77
77
  #
78
78
  # @example Parsing a valid FEEN string
79
- # feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
79
+ # feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
80
80
  # result = Feen::Parser.safe_parse(feen)
81
81
  # # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
82
82
  #
data/lib/feen.rb CHANGED
@@ -16,11 +16,12 @@ module Feen
16
16
  #
17
17
  # @param piece_placement [Array] Board position data structure representing the spatial
18
18
  # distribution of pieces across the board
19
- # @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board
19
+ # @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board.
20
+ # MUST be in base form only (no modifiers allowed)
20
21
  # @param games_turn [Array<String>] A two-element array where the first element is the
21
22
  # active player's variant and the second is the inactive player's variant
22
23
  # @return [String] FEEN notation string
23
- # @raise [ArgumentError] If any parameter is invalid
24
+ # @raise [ArgumentError] If any parameter is invalid or pieces_in_hand contains modifiers
24
25
  # @example
25
26
  # piece_placement = [
26
27
  # ["r", "n", "b", "q", "k", "b", "n", "r"],
@@ -37,7 +38,7 @@ module Feen
37
38
  # pieces_in_hand: [],
38
39
  # games_turn: ["CHESS", "chess"]
39
40
  # )
40
- # # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
41
+ # # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
41
42
  def self.dump(piece_placement:, pieces_in_hand:, games_turn:)
42
43
  Dumper.dump(piece_placement:, pieces_in_hand:, games_turn:)
43
44
  end
@@ -47,11 +48,11 @@ module Feen
47
48
  # @param feen_string [String] Complete FEEN notation string
48
49
  # @return [Hash] Hash containing the parsed position data with the following keys:
49
50
  # - :piece_placement [Array] - Hierarchical array structure representing the board
50
- # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board
51
+ # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (base form only)
51
52
  # - :games_turn [Array<String>] - A two-element array with [active_variant, inactive_variant]
52
53
  # @raise [ArgumentError] If the FEEN string is invalid
53
54
  # @example
54
- # feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
55
+ # feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
55
56
  # Feen.parse(feen_string)
56
57
  # # => {
57
58
  # # piece_placement: [
@@ -80,7 +81,7 @@ module Feen
80
81
  # @return [Hash, nil] Hash containing the parsed position data or nil if parsing fails
81
82
  # @example
82
83
  # # Valid FEEN string
83
- # Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess")
84
+ # Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess")
84
85
  # # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
85
86
  #
86
87
  # # Invalid FEEN string
@@ -100,17 +101,27 @@ module Feen
100
101
  # This approach guarantees that the string not only follows FEEN syntax
101
102
  # but is also in its most compact, canonical representation.
102
103
  #
104
+ # According to FEEN specification:
105
+ # - Pieces in hand must be in base form only (no modifiers like +, -, ')
106
+ # - Pieces in hand must be sorted canonically within each case section:
107
+ # 1. By quantity (descending)
108
+ # 2. By piece letter (alphabetically ascending)
109
+ # - Case separation is enforced (uppercase/lowercase)
110
+ #
103
111
  # @param feen_string [String] FEEN string to validate
104
112
  # @return [Boolean] True if the string is a valid and canonical FEEN string
105
113
  # @example
106
114
  # # Canonical form
107
- # Feen.valid?("lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL 2g2s5PNln SHOGI/shogi") # => true
115
+ # Feen.valid?("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL / SHOGI/shogi") # => true
108
116
  #
109
117
  # # Invalid syntax
110
118
  # Feen.valid?("invalid feen string") # => false
111
119
  #
112
- # # Valid syntax but non-canonical form (pieces in hand not in lexicographic order)
113
- # Feen.valid?("lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL N5P2gn2sl SHOGI/shogi") # => false
120
+ # # Valid syntax but non-canonical form (pieces in hand with modifiers)
121
+ # Feen.valid?("8/8/8/8/8/8/8/8 +P/ FOO/bar") # => false
122
+ #
123
+ # # Valid syntax but non-canonical form (wrong ordering in pieces in hand)
124
+ # Feen.valid?("8/8/8/8/8/8/8/8 P3K/ FOO/bar") # => false
114
125
  def self.valid?(feen_string)
115
126
  # First check: Basic syntax validation
116
127
  begin
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feen
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0.beta6
4
+ version: 5.0.0.beta8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
@@ -25,20 +25,10 @@ files:
25
25
  - lib/feen/dumper/games_turn.rb
26
26
  - lib/feen/dumper/piece_placement.rb
27
27
  - lib/feen/dumper/pieces_in_hand.rb
28
- - lib/feen/dumper/pieces_in_hand/errors.rb
29
- - lib/feen/dumper/pieces_in_hand/no_pieces.rb
30
28
  - lib/feen/parser.rb
31
29
  - lib/feen/parser/games_turn.rb
32
- - lib/feen/parser/games_turn/errors.rb
33
- - lib/feen/parser/games_turn/valid_games_turn_pattern.rb
34
30
  - lib/feen/parser/piece_placement.rb
35
31
  - lib/feen/parser/pieces_in_hand.rb
36
- - lib/feen/parser/pieces_in_hand/canonical_sorter.rb
37
- - lib/feen/parser/pieces_in_hand/errors.rb
38
- - lib/feen/parser/pieces_in_hand/no_pieces.rb
39
- - lib/feen/parser/pieces_in_hand/piece_count_pattern.rb
40
- - lib/feen/parser/pieces_in_hand/pnn_patterns.rb
41
- - lib/feen/parser/pieces_in_hand/valid_format_pattern.rb
42
32
  homepage: https://github.com/sashite/feen.rb
43
33
  licenses:
44
34
  - MIT
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Dumper
5
- module PiecesInHand
6
- Errors = {
7
- invalid_type: "Piece at index: %<index>s must be a String, got type: %<type>s",
8
- invalid_format: "Piece at index: %<index>s has an invalid PNN format: '%<value>s'. Expected format: [prefix]letter[suffix] where prefix is + or -, suffix is ', and letter is a-z or A-Z"
9
- }.freeze
10
- end
11
- end
12
- end
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Dumper
5
- module PiecesInHand
6
- # Character used to represent no pieces in hand
7
- NoPieces = "-"
8
- end
9
- end
10
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Parser
5
- module GamesTurn
6
- # Error messages for games turn parsing
7
- Errors = {
8
- invalid_type: "Games turn must be a string, got %s",
9
- empty_string: "Games turn string cannot be empty",
10
- invalid_format: "Invalid games turn format. Expected format: UPPERCASE/lowercase or lowercase/UPPERCASE"
11
- }.freeze
12
- end
13
- end
14
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Parser
5
- module GamesTurn
6
- # Complete pattern matching the BNF specification with named groups
7
- # <games-turn> ::= <game-id-uppercase> "/" <game-id-lowercase>
8
- # | <game-id-lowercase> "/" <game-id-uppercase>
9
- ValidGamesTurnPattern = %r{
10
- \A # Start of string
11
- (?: # Non-capturing group for alternatives
12
- (?<uppercase_first>[A-Z]+) # Named group: uppercase identifier first
13
- / # Separator
14
- (?<lowercase_second>[a-z]+) # Named group: lowercase identifier second
15
- | # OR
16
- (?<lowercase_first>[a-z]+) # Named group: lowercase identifier first
17
- / # Separator
18
- (?<uppercase_second>[A-Z]+) # Named group: uppercase identifier second
19
- )
20
- \z # End of string
21
- }x
22
- end
23
- end
24
- end
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Parser
5
- module PiecesInHand
6
- # Handles canonical ordering validation for pieces in hand according to FEEN specification
7
- module CanonicalSorter
8
- # Validates that pieces are in canonical order according to FEEN specification:
9
- # 1. By quantity (descending)
10
- # 2. By complete PNN representation (alphabetically ascending)
11
- #
12
- # @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
13
- # @raise [ArgumentError] If pieces are not in canonical order
14
- # @return [void]
15
- def self.validate_order(pieces_with_counts)
16
- return if pieces_with_counts.size <= 1
17
-
18
- # Create the expected canonical order
19
- canonical_order = sort_canonically(pieces_with_counts)
20
-
21
- # Compare with actual order
22
- pieces_with_counts.each_with_index do |piece_data, index|
23
- canonical_piece = canonical_order[index]
24
-
25
- next if piece_data[:piece] == canonical_piece[:piece] &&
26
- piece_data[:count] == canonical_piece[:count]
27
-
28
- raise ::ArgumentError, format(
29
- Errors[:canonical_order_violation],
30
- actual: format_pieces_sequence(pieces_with_counts),
31
- expected: format_pieces_sequence(canonical_order)
32
- )
33
- end
34
- end
35
-
36
- # Sorts pieces according to canonical FEEN order
37
- #
38
- # @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
39
- # @return [Array<Hash>] Canonically sorted array
40
- def self.sort_canonically(pieces_with_counts)
41
- pieces_with_counts.sort do |a, b|
42
- # Primary sort: by quantity (descending)
43
- count_comparison = b[:count] <=> a[:count]
44
- next count_comparison unless count_comparison.zero?
45
-
46
- # Secondary sort: by complete PNN representation (alphabetically ascending)
47
- a[:piece] <=> b[:piece]
48
- end
49
- end
50
-
51
- # Formats a pieces sequence for error messages
52
- #
53
- # @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
54
- # @return [String] Formatted string representation
55
- private_class_method def self.format_pieces_sequence(pieces_with_counts)
56
- pieces_with_counts.map do |item|
57
- count = item[:count]
58
- piece = item[:piece]
59
-
60
- if count == 1
61
- piece
62
- else
63
- "#{count}#{piece}"
64
- end
65
- end.join
66
- end
67
- end
68
- end
69
- end
70
- end
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Parser
5
- module PiecesInHand
6
- # Error messages for validation
7
- Errors = {
8
- invalid_type: "Pieces in hand must be a string, got %s",
9
- empty_string: "Pieces in hand string cannot be empty",
10
- invalid_format: "Invalid pieces in hand format: %s",
11
- invalid_pnn_piece: "Invalid PNN piece format: '%s'. Expected format: [prefix]letter[suffix] where prefix is + or -, suffix is ', and letter is a-z or A-Z",
12
- invalid_count: "Invalid count format: '%s'. Count cannot be '0' or '1', use the piece without count instead",
13
- canonical_order_violation: "Pieces in hand must be in canonical order (by quantity descending, then alphabetically). Got: '%<actual>s', expected: '%<expected>s'"
14
- }.freeze
15
- end
16
- end
17
- end
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Parser
5
- module PiecesInHand
6
- # Character used to represent no pieces in hand
7
- NoPieces = "-"
8
- end
9
- end
10
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Parser
5
- module PiecesInHand
6
- # Regex to extract piece counts from pieces in hand string
7
- # Matches either:
8
- # - A single piece character with no count (e.g., "P")
9
- # - A count followed by a piece character (e.g., "5P")
10
- PieceCountPattern = /(?:([2-9]|\d{2,}))?([A-Za-z])/
11
- end
12
- end
13
- end