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.
- checksums.yaml +4 -4
- data/README.md +264 -76
- data/lib/feen/dumper/pieces_in_hand.rb +101 -40
- data/lib/feen/dumper/style_turn.rb +71 -0
- data/lib/feen/dumper.rb +17 -17
- data/lib/feen/parser/pieces_in_hand.rb +49 -72
- data/lib/feen/parser/style_turn.rb +101 -0
- data/lib/feen/parser.rb +8 -8
- data/lib/feen.rb +27 -27
- metadata +33 -7
- data/lib/feen/dumper/games_turn.rb +0 -70
- data/lib/feen/parser/games_turn.rb +0 -78
@@ -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:
|
10
|
-
|
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
|
17
|
-
#
|
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
|
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
|
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
|
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.
|
59
|
-
lowercase_pieces = pieces.
|
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., "
|
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
|
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
|
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
|
86
|
-
|
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
|
-
#
|
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
|
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
|
111
|
-
#
|
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
|
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
|
126
|
-
|
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", "
|
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
|
-
|
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
|
-
#
|
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
|
-
#
|
45
|
-
# @param
|
46
|
-
# active player's
|
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:,
|
50
|
+
def self.dump(piece_placement:, pieces_in_hand:, style_turn:)
|
51
51
|
# Validate input types
|
52
|
-
validate_inputs(piece_placement,
|
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
|
-
|
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
|
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,
|
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
|
76
|
-
unless
|
77
|
-
raise ArgumentError, format(ERRORS[:
|
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
|
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
|
-
|
12
|
-
invalid_type:
|
13
|
-
empty_string:
|
14
|
-
invalid_format:
|
15
|
-
missing_separator:
|
16
|
-
|
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
|
43
|
-
# expanded based on their counts
|
44
|
-
#
|
45
|
-
# @raise [ArgumentError] If the input string is invalid
|
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
|
-
# # => ["
|
53
|
+
# # => ["P", "P", "P", "B", "B", "p"]
|
54
54
|
#
|
55
|
-
# @example
|
56
|
-
# PiecesInHand.parse("+P/
|
57
|
-
# # =>
|
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
|
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
|
73
|
-
|
74
|
-
|
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
|
84
|
-
raise
|
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
|
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
|
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
|
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
|
-
#
|
102
|
-
|
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
|
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
|
-
#
|
153
|
-
|
154
|
-
|
155
|
-
|
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,
|
142
|
+
raise ::ArgumentError, format(ERRORS[:invalid_count], count_str)
|
163
143
|
end
|
164
144
|
|
165
|
-
# Validate that the piece matches the expected case
|
166
|
-
|
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 '#{
|
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:
|
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
|
163
|
+
# Extracts the base letter from a PNN piece identifier
|
183
164
|
#
|
184
|
-
# @param
|
185
|
-
# @return [String] Base
|
186
|
-
private_class_method def self.
|
187
|
-
|
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
|