feen 5.0.0.beta8 → 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,47 +1,48 @@
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 = {
13
+ ERRORS = {
12
14
  invalid_type: "Pieces in hand must be a string, got %s",
13
15
  empty_string: "Pieces in hand string cannot be empty",
14
16
  invalid_format: "Invalid pieces in hand format: %s",
15
- missing_separator: "Pieces in hand format must contain exactly one '/' separator. Got: %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'"
16
20
  }.freeze
17
21
 
18
- # Base piece pattern: single letter only (no modifiers allowed in hand)
19
- BASE_PIECE_PATTERN = /\A[a-zA-Z]\z/
20
-
21
22
  # Valid count pattern: 2-9 or any number with 2+ digits (no 0, 1, or leading zeros)
22
23
  VALID_COUNT_PATTERN = /\A(?:[2-9]|[1-9]\d+)\z/
23
24
 
24
- # Pattern for piece with optional count in pieces in hand
25
- PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|[1-9]\d+))?([a-zA-Z])/
25
+ # Pattern for piece with optional count in pieces in hand (with full PNN support)
26
+ PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|[1-9]\d+))?([-+]?[a-zA-Z]'?)/
26
27
 
27
- # Complete validation pattern for pieces in hand string
28
+ # Complete validation pattern for pieces in hand string (with PNN modifier support)
28
29
  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
30
+ (?: # Uppercase section (optional)
31
+ (?:(?:[2-9]|[1-9]\d+)?[-+]?[A-Z]'?)* # Zero or more uppercase pieces with optional counts and modifiers
31
32
  )
32
- / # Mandatory separator
33
- (?: # Lowercase section (optional)
34
- (?:(?:[2-9]|[1-9]\d+)?[a-z])* # Zero or more lowercase pieces with optional counts
33
+ / # Mandatory separator
34
+ (?: # Lowercase section (optional)
35
+ (?:(?:[2-9]|[1-9]\d+)?[-+]?[a-z]'?)* # Zero or more lowercase pieces with optional counts and modifiers
35
36
  )
36
37
  \z}x
37
38
 
38
39
  # Parses the pieces in hand section of a FEEN string.
39
40
  #
40
41
  # @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.
43
- # Empty array if no pieces are in hand.
44
- # @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
45
46
  #
46
47
  # @example Parse no pieces in hand
47
48
  # PiecesInHand.parse("/")
@@ -49,11 +50,11 @@ module Feen
49
50
  #
50
51
  # @example Parse pieces with case separation
51
52
  # PiecesInHand.parse("3P2B/p")
52
- # # => ["B", "B", "P", "P", "P", "p"]
53
+ # # => ["P", "P", "P", "B", "B", "p"]
53
54
  #
54
- # @example Invalid - modifiers not allowed in hand
55
- # PiecesInHand.parse("+P/p")
56
- # # => 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"]
57
58
  def self.parse(pieces_in_hand_str)
58
59
  validate_input_type(pieces_in_hand_str)
59
60
  validate_format(pieces_in_hand_str)
@@ -64,13 +65,13 @@ module Feen
64
65
  # Split by the separator to get uppercase and lowercase sections
65
66
  uppercase_section, lowercase_section = pieces_in_hand_str.split("/", 2)
66
67
 
67
- # Parse each section separately and validate no modifiers
68
+ # Parse each section separately
68
69
  uppercase_pieces = parse_pieces_section(uppercase_section || "", :uppercase)
69
70
  lowercase_pieces = parse_pieces_section(lowercase_section || "", :lowercase)
70
71
 
71
- # Combine all pieces and sort them alphabetically
72
- all_pieces = uppercase_pieces + lowercase_pieces
73
- 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
74
75
  end
75
76
 
76
77
  # Validates that the input is a non-empty string.
@@ -79,41 +80,23 @@ module Feen
79
80
  # @raise [ArgumentError] If input is not a string or is empty
80
81
  # @return [void]
81
82
  private_class_method def self.validate_input_type(str)
82
- raise ::ArgumentError, format(Errors[:invalid_type], str.class) unless str.is_a?(::String)
83
- 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?
84
85
  end
85
86
 
86
87
  # Validates that the input string matches the expected format according to FEEN specification.
87
- # 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.
88
89
  #
89
90
  # @param str [String] Input string to validate
90
- # @raise [ArgumentError] If format is invalid or contains modifiers
91
+ # @raise [ArgumentError] If format is invalid
91
92
  # @return [void]
92
93
  private_class_method def self.validate_format(str)
93
94
  # Must contain exactly one "/" separator
94
95
  parts_count = str.count("/")
95
- raise ::ArgumentError, format(Errors[:missing_separator], parts_count) unless parts_count == 1
96
+ raise ArgumentError, format(ERRORS[:missing_separator], parts_count) unless parts_count == 1
96
97
 
97
98
  # Must match the overall pattern
98
- raise ::ArgumentError, format(Errors[:invalid_format], str) unless str.match?(VALID_FORMAT_PATTERN)
99
-
100
- # Additional validation: check for any modifiers (forbidden in hand)
101
- validate_no_modifiers(str)
102
- end
103
-
104
- # Validates that no modifiers are present in the pieces in hand string
105
- #
106
- # @param str [String] Input string to validate
107
- # @raise [ArgumentError] If modifiers are found
108
- # @return [void]
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?(/[+\-']/)
112
-
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(/[+\-']/)
115
-
116
- raise ::ArgumentError, "Pieces in hand cannot contain modifiers: '#{invalid_pieces.first}'"
99
+ raise ArgumentError, format(ERRORS[:invalid_format], str) unless str.match?(VALID_FORMAT_PATTERN)
117
100
  end
118
101
 
119
102
  # Parses a specific section (uppercase or lowercase) and returns expanded pieces
@@ -136,7 +119,7 @@ module Feen
136
119
  # @param section [String] FEEN pieces section string
137
120
  # @param case_type [Symbol] Either :uppercase or :lowercase
138
121
  # @return [Array<Hash>] Array of hashes with :piece and :count keys
139
- # @raise [ArgumentError] If pieces don't match the expected case or contain modifiers
122
+ # @raise [ArgumentError] If pieces contain invalid PNN notation
140
123
  private_class_method def self.extract_pieces_with_counts_from_section(section, case_type)
141
124
  result = []
142
125
  position = 0
@@ -145,28 +128,30 @@ module Feen
145
128
  match = section[position..].match(PIECE_WITH_COUNT_PATTERN)
146
129
  break unless match
147
130
 
148
- count_str, piece = match.captures
131
+ count_str, piece_with_modifiers = match.captures
149
132
  count = count_str ? count_str.to_i : 1
150
133
 
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}'"
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)
154
138
  end
155
139
 
156
140
  # Validate count format
157
141
  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"
142
+ raise ::ArgumentError, format(ERRORS[:invalid_count], count_str)
159
143
  end
160
144
 
161
- # Validate that the piece matches the expected case
162
- piece_case = 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
163
148
  unless piece_case == case_type
164
149
  case_name = case_type == :uppercase ? "uppercase" : "lowercase"
165
- raise ::ArgumentError, "Piece '#{piece}' has wrong case for #{case_name} section"
150
+ raise ::ArgumentError, "Piece '#{piece_with_modifiers}' has wrong case for #{case_name} section"
166
151
  end
167
152
 
168
153
  # Add to our result with piece type and count
169
- result << { piece: piece, count: count }
154
+ result << { piece: piece_with_modifiers, count: count }
170
155
 
171
156
  # Move position forward
172
157
  position += match[0].length
@@ -175,13 +160,21 @@ module Feen
175
160
  result
176
161
  end
177
162
 
163
+ # Extracts the base letter from a PNN piece identifier
164
+ #
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]
169
+ end
170
+
178
171
  # Expands the pieces based on their counts into an array.
179
172
  #
180
173
  # @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
181
- # @return [Array<String>] Array of expanded pieces
174
+ # @return [Array<String>] Array of expanded pieces preserving order
182
175
  private_class_method def self.expand_pieces(pieces_with_counts)
183
176
  pieces_with_counts.flat_map do |item|
184
- Array.new(item[:count], item[:piece])
177
+ ::Array.new(item[:count], item[:piece])
185
178
  end
186
179
  end
187
180
  end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sashite-snn"
4
+
5
+ module Feen
6
+ module Parser
7
+ # Handles parsing of the style turn section of a FEEN string
8
+ module StyleTurn
9
+ # Error messages for style turn parsing
10
+ ERRORS = {
11
+ invalid_type: "Style turn must be a string, got %s",
12
+ empty_string: "Style turn string cannot be empty",
13
+ invalid_format: "Invalid style turn format. Expected format: UPPERCASE/lowercase or lowercase/UPPERCASE",
14
+ invalid_snn: "Invalid SNN notation in style turn: %s"
15
+ }.freeze
16
+
17
+ # Pattern matching the FEEN specification for style turn
18
+ # <style-turn> ::= <style-id-uppercase> "/" <style-id-lowercase>
19
+ # | <style-id-lowercase> "/" <style-id-uppercase>
20
+ VALID_STYLE_TURN_PATTERN = %r{
21
+ \A # Start of string
22
+ (?: # Non-capturing group for alternatives
23
+ (?<uppercase_first>[A-Z][A-Z0-9]*) # Named group: uppercase style identifier first
24
+ / # Separator
25
+ (?<lowercase_second>[a-z][a-z0-9]*) # Named group: lowercase style identifier second
26
+ | # OR
27
+ (?<lowercase_first>[a-z][a-z0-9]*) # Named group: lowercase style identifier first
28
+ / # Separator
29
+ (?<uppercase_second>[A-Z][A-Z0-9]*) # Named group: uppercase style identifier second
30
+ )
31
+ \z # End of string
32
+ }x
33
+
34
+ # Parses the style turn section of a FEEN string
35
+ #
36
+ # @param style_turn_str [String] FEEN style turn string
37
+ # @return [Array<String>] Array containing [active_style, inactive_style]
38
+ # @raise [ArgumentError] If the input string is invalid
39
+ #
40
+ # @example Valid style turn string with uppercase first
41
+ # StyleTurn.parse("CHESS/shogi")
42
+ # # => ["CHESS", "shogi"]
43
+ #
44
+ # @example Valid style turn string with lowercase first
45
+ # StyleTurn.parse("chess/SHOGI")
46
+ # # => ["chess", "SHOGI"]
47
+ #
48
+ # @example Valid style turn with numeric identifiers
49
+ # StyleTurn.parse("CHESS960/makruk")
50
+ # # => ["CHESS960", "makruk"]
51
+ def self.parse(style_turn_str)
52
+ validate_input_type(style_turn_str)
53
+
54
+ match = VALID_STYLE_TURN_PATTERN.match(style_turn_str)
55
+ raise ::ArgumentError, ERRORS[:invalid_format] unless match
56
+
57
+ style_identifiers = extract_style_identifiers(match)
58
+ validate_snn_compliance(style_identifiers)
59
+
60
+ style_identifiers
61
+ end
62
+
63
+ # Validates that the input is a non-empty string
64
+ #
65
+ # @param str [String] Input string to validate
66
+ # @raise [ArgumentError] If input is not a string or is empty
67
+ # @return [void]
68
+ private_class_method def self.validate_input_type(str)
69
+ raise ::ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(::String)
70
+ raise ::ArgumentError, ERRORS[:empty_string] if str.empty?
71
+ end
72
+
73
+ # Extracts style identifiers from regexp match captures
74
+ #
75
+ # @param match [MatchData] Regexp match data with named captures
76
+ # @return [Array<String>] Array containing [active_style, inactive_style]
77
+ private_class_method def self.extract_style_identifiers(match)
78
+ captures = match.named_captures
79
+
80
+ if captures["uppercase_first"]
81
+ [captures["uppercase_first"], captures["lowercase_second"]]
82
+ else
83
+ [captures["lowercase_first"], captures["uppercase_second"]]
84
+ end
85
+ end
86
+
87
+ # Validates that both style identifiers comply with SNN specification
88
+ #
89
+ # @param identifiers [Array<String>] Array of style identifiers to validate
90
+ # @raise [ArgumentError] If any identifier is invalid SNN notation
91
+ # @return [void]
92
+ private_class_method def self.validate_snn_compliance(identifiers)
93
+ identifiers.each do |identifier|
94
+ # Validate using the sashite-snn gem
95
+ # @see https://rubygems.org/gems/sashite-snn
96
+ raise ::ArgumentError, format(ERRORS[:invalid_snn], identifier) unless ::Sashite::Snn.valid?(identifier)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
data/lib/feen/parser.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("parser", "games_turn")
3
+ require_relative File.join("parser", "style_turn")
4
4
  require_relative File.join("parser", "piece_placement")
5
5
  require_relative File.join("parser", "pieces_in_hand")
6
6
 
@@ -21,8 +21,8 @@ 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 (base form only)
25
- # - :games_turn [Array<String>] - A two-element array with [active_variant, inactive_variant]
24
+ # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (may include modifiers)
25
+ # - :style_turn [Array<String>] - A two-element array with [active_style, inactive_style]
26
26
  # @raise [ArgumentError] If the FEEN string is invalid
27
27
  #
28
28
  # @example Parsing a standard chess initial position
@@ -40,7 +40,7 @@ module Feen
40
40
  # # ["R", "N", "B", "Q", "K", "B", "N", "R"]
41
41
  # # ],
42
42
  # # pieces_in_hand: [],
43
- # # games_turn: ["CHESS", "chess"]
43
+ # # style_turn: ["CHESS", "chess"]
44
44
  # # }
45
45
  def self.parse(feen_string)
46
46
  feen_string = String(feen_string)
@@ -52,18 +52,18 @@ module Feen
52
52
  raise ::ArgumentError, INVALID_FORMAT_ERROR unless match
53
53
 
54
54
  # Capture the three distinct parts
55
- piece_placement_string, pieces_in_hand_string, games_turn_string = match.captures
55
+ piece_placement_string, pieces_in_hand_string, style_turn_string = match.captures
56
56
 
57
57
  # Parse each field using the appropriate submodule
58
58
  piece_placement = PiecePlacement.parse(piece_placement_string)
59
59
  pieces_in_hand = PiecesInHand.parse(pieces_in_hand_string)
60
- games_turn = GamesTurn.parse(games_turn_string)
60
+ style_turn = StyleTurn.parse(style_turn_string)
61
61
 
62
62
  # Create a structured representation of the position
63
63
  {
64
64
  piece_placement:,
65
65
  pieces_in_hand:,
66
- games_turn:
66
+ style_turn:
67
67
  }
68
68
  end
69
69
 
@@ -78,7 +78,7 @@ module Feen
78
78
  # @example Parsing a valid FEEN string
79
79
  # feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
80
80
  # result = Feen::Parser.safe_parse(feen)
81
- # # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
81
+ # # => {piece_placement: [...], pieces_in_hand: [...], style_turn: [...]}
82
82
  #
83
83
  # @example Parsing an invalid FEEN string
84
84
  # feen = "invalid feen string"
data/lib/feen.rb CHANGED
@@ -17,11 +17,11 @@ module Feen
17
17
  # @param piece_placement [Array] Board position data structure representing the spatial
18
18
  # distribution of pieces across the board
19
19
  # @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board.
20
- # MUST be in base form only (no modifiers allowed)
21
- # @param games_turn [Array<String>] A two-element array where the first element is the
22
- # active player's variant and the second is the inactive player's variant
20
+ # May include PNN modifiers (per FEEN v1.0.0 specification)
21
+ # @param style_turn [Array<String>] A two-element array where the first element is the
22
+ # active player's style and the second is the inactive player's style
23
23
  # @return [String] FEEN notation string
24
- # @raise [ArgumentError] If any parameter is invalid or pieces_in_hand contains modifiers
24
+ # @raise [ArgumentError] If any parameter is invalid
25
25
  # @example
26
26
  # piece_placement = [
27
27
  # ["r", "n", "b", "q", "k", "b", "n", "r"],
@@ -36,11 +36,11 @@ module Feen
36
36
  # Feen.dump(
37
37
  # piece_placement: piece_placement,
38
38
  # pieces_in_hand: [],
39
- # games_turn: ["CHESS", "chess"]
39
+ # style_turn: ["CHESS", "chess"]
40
40
  # )
41
41
  # # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
42
- def self.dump(piece_placement:, pieces_in_hand:, games_turn:)
43
- Dumper.dump(piece_placement:, pieces_in_hand:, games_turn:)
42
+ def self.dump(piece_placement:, pieces_in_hand:, style_turn:)
43
+ Dumper.dump(piece_placement:, pieces_in_hand:, style_turn:)
44
44
  end
45
45
 
46
46
  # Parses a FEEN string into position components.
@@ -48,8 +48,8 @@ module Feen
48
48
  # @param feen_string [String] Complete FEEN notation string
49
49
  # @return [Hash] Hash containing the parsed position data with the following keys:
50
50
  # - :piece_placement [Array] - Hierarchical array structure representing the board
51
- # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (base form only)
52
- # - :games_turn [Array<String>] - A two-element array with [active_variant, inactive_variant]
51
+ # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (may include modifiers)
52
+ # - :style_turn [Array<String>] - A two-element array with [active_style, inactive_style]
53
53
  # @raise [ArgumentError] If the FEEN string is invalid
54
54
  # @example
55
55
  # feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
@@ -66,7 +66,7 @@ module Feen
66
66
  # # ["R", "N", "B", "Q", "K", "B", "N", "R"]
67
67
  # # ],
68
68
  # # pieces_in_hand: [],
69
- # # games_turn: ["CHESS", "chess"]
69
+ # # style_turn: ["CHESS", "chess"]
70
70
  # # }
71
71
  def self.parse(feen_string)
72
72
  Parser.parse(feen_string)
@@ -82,7 +82,7 @@ module Feen
82
82
  # @example
83
83
  # # Valid FEEN string
84
84
  # Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess")
85
- # # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
85
+ # # => {piece_placement: [...], pieces_in_hand: [...], style_turn: [...]}
86
86
  #
87
87
  # # Invalid FEEN string
88
88
  # Feen.safe_parse("invalid feen string")
@@ -101,12 +101,15 @@ module Feen
101
101
  # This approach guarantees that the string not only follows FEEN syntax
102
102
  # but is also in its most compact, canonical representation.
103
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)
104
+ # According to FEEN v1.0.0 specification:
105
+ # - Pieces in hand may include PNN modifiers (prefixes and suffixes)
106
+ # - Pieces in hand must be sorted canonically according to the sorting algorithm:
107
+ # 1. By player (uppercase/lowercase separated by '/')
108
+ # 2. By quantity (descending)
109
+ # 3. By base letter (ascending)
110
+ # 4. By prefix (specific order: '-', '+', then no prefix)
111
+ # 5. By suffix (specific order: no suffix, then "'")
112
+ # - Style identifiers must follow SNN specification with semantic casing
110
113
  #
111
114
  # @param feen_string [String] FEEN string to validate
112
115
  # @return [Boolean] True if the string is a valid and canonical FEEN string
@@ -117,24 +120,21 @@ module Feen
117
120
  # # Invalid syntax
118
121
  # Feen.valid?("invalid feen string") # => false
119
122
  #
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
123
  # # Valid syntax but non-canonical form (wrong ordering in pieces in hand)
124
124
  # Feen.valid?("8/8/8/8/8/8/8/8 P3K/ FOO/bar") # => false
125
+ #
126
+ # # Canonical form with modifiers in pieces in hand
127
+ # Feen.valid?("8/8/8/8/8/8/8/8 2+B5BK3-P-P'3+P'9PR2SS'/ FOO/bar") # => true
125
128
  def self.valid?(feen_string)
126
129
  # First check: Basic syntax validation
127
- begin
128
- parsed_data = parse(feen_string)
129
- rescue ::ArgumentError
130
- return false
131
- end
130
+ parsed_data = safe_parse(feen_string)
131
+ return false if parsed_data.nil?
132
132
 
133
133
  # Second check: Canonicity validation through round-trip conversion
134
134
  # Generate a fresh FEEN string from the parsed data
135
135
  canonical_feen = dump(**parsed_data)
136
136
 
137
- # Compare the original string with the canonical form
138
- feen_string == canonical_feen
137
+ # Compare the canonical form with the original string
138
+ canonical_feen == feen_string
139
139
  end
140
140
  end
metadata CHANGED
@@ -1,17 +1,45 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feen
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0.beta8
4
+ version: 5.0.0.beta10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: pnn
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 2.0.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 2.0.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: sashite-snn
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 1.0.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 1.0.0
12
40
  description: A Ruby interface for data serialization and deserialization in FEEN format.
13
41
  FEEN is a compact, canonical, and rule-agnostic textual format for representing
14
- static board positions in two-player piece-placement games like Chess, Shogi, Xiangqi,
42
+ static board positions in two-player piece-placement games like Chess, Shōgi, Xiangqi,
15
43
  and others.
16
44
  email: contact@cyril.email
17
45
  executables: []
@@ -22,13 +50,13 @@ files:
22
50
  - README.md
23
51
  - lib/feen.rb
24
52
  - lib/feen/dumper.rb
25
- - lib/feen/dumper/games_turn.rb
26
53
  - lib/feen/dumper/piece_placement.rb
27
54
  - lib/feen/dumper/pieces_in_hand.rb
55
+ - lib/feen/dumper/style_turn.rb
28
56
  - lib/feen/parser.rb
29
- - lib/feen/parser/games_turn.rb
30
57
  - lib/feen/parser/piece_placement.rb
31
58
  - lib/feen/parser/pieces_in_hand.rb
59
+ - lib/feen/parser/style_turn.rb
32
60
  homepage: https://github.com/sashite/feen.rb
33
61
  licenses:
34
62
  - MIT
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Dumper
5
- # Handles conversion of games turn data to FEEN notation string
6
- module GamesTurn
7
- # Error messages for validation
8
- ERRORS = {
9
- invalid_type: "%s must be a String, got %s",
10
- empty_string: "%s cannot be empty",
11
- invalid_chars: "%s must contain only alphabetic characters (a-z, A-Z)",
12
- mixed_case: "%s has mixed case: %s",
13
- same_casing: "One variant must be uppercase and the other lowercase"
14
- }.freeze
15
-
16
- # Converts the active and inactive variant identifiers to a FEEN-formatted games turn string
17
- #
18
- # @param active_variant [String] Identifier for the player to move and their game variant
19
- # @param inactive_variant [String] Identifier for the opponent and their game variant
20
- # @return [String] FEEN-formatted games turn string
21
- # @raise [ArgumentError] If the variant identifiers are invalid
22
- #
23
- # @example Valid games turn
24
- # GamesTurn.dump("CHESS", "chess")
25
- # # => "CHESS/chess"
26
- #
27
- # @example Invalid - same casing
28
- # GamesTurn.dump("CHESS", "SHOGI")
29
- # # => ArgumentError: One variant must be uppercase and the other lowercase
30
- def self.dump(active_variant, inactive_variant)
31
- validate_variants(active_variant, inactive_variant)
32
- "#{active_variant}/#{inactive_variant}"
33
- end
34
-
35
- # Validates the game variant identifiers
36
- #
37
- # @param active [String] The active player's variant identifier
38
- # @param inactive [String] The inactive player's variant identifier
39
- # @raise [ArgumentError] If the variant identifiers are invalid
40
- # @return [void]
41
- private_class_method def self.validate_variants(active, inactive)
42
- # Validate basic type, presence and format
43
- [["Active variant", active], ["Inactive variant", inactive]].each do |name, variant|
44
- # Type validation
45
- raise ArgumentError, format(ERRORS[:invalid_type], name, variant.class) unless variant.is_a?(String)
46
-
47
- # Empty validation
48
- raise ArgumentError, format(ERRORS[:empty_string], name) if variant.empty?
49
-
50
- # Character validation
51
- raise ArgumentError, format(ERRORS[:invalid_chars], name) unless variant.match?(/\A[a-zA-Z]+\z/)
52
-
53
- # Mixed case validation
54
- unless variant == variant.upcase || variant == variant.downcase
55
- raise ArgumentError, format(ERRORS[:mixed_case], name, variant)
56
- end
57
- end
58
-
59
- # Casing difference validation
60
- active_is_uppercase = active == active.upcase
61
- inactive_is_uppercase = inactive == inactive.upcase
62
-
63
- # Both variants must have different casing
64
- return unless active_is_uppercase == inactive_is_uppercase
65
-
66
- raise ArgumentError, ERRORS[:same_casing]
67
- end
68
- end
69
- end
70
- end