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.
- checksums.yaml +4 -4
- data/README.md +284 -96
- 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/piece_placement.rb +169 -9
- data/lib/feen/parser/pieces_in_hand.rb +55 -62
- data/lib/feen/parser/style_turn.rb +101 -0
- data/lib/feen/parser.rb +8 -8
- data/lib/feen.rb +27 -27
- metadata +33 -5
- 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
|
@@ -3,6 +3,14 @@
|
|
3
3
|
module Feen
|
4
4
|
module Parser
|
5
5
|
# Handles parsing of the piece placement section of a FEEN string
|
6
|
+
#
|
7
|
+
# This module is responsible for converting the first field of a FEEN string
|
8
|
+
# (piece placement) into a hierarchical array structure representing the board.
|
9
|
+
# It supports arbitrary dimensions and handles both pieces (with optional PNN modifiers)
|
10
|
+
# and empty squares (represented by numbers).
|
11
|
+
#
|
12
|
+
# @see https://sashite.dev/documents/feen/1.0.0/ FEEN Specification
|
13
|
+
# @see https://sashite.dev/documents/pnn/1.0.0/ PNN Specification
|
6
14
|
module PiecePlacement
|
7
15
|
# Simplified error messages
|
8
16
|
ERRORS = {
|
@@ -16,11 +24,78 @@ module Feen
|
|
16
24
|
|
17
25
|
# Parses the piece placement section of a FEEN string
|
18
26
|
#
|
27
|
+
# Converts a FEEN piece placement string into a hierarchical array structure
|
28
|
+
# representing the board where empty squares are represented by empty strings
|
29
|
+
# and pieces are represented by strings containing their PNN identifier and
|
30
|
+
# optional modifiers.
|
31
|
+
#
|
19
32
|
# @param piece_placement_str [String] FEEN piece placement string
|
20
33
|
# @return [Array] Hierarchical array structure representing the board where:
|
21
34
|
# - Empty squares are represented by empty strings ("")
|
22
35
|
# - Pieces are represented by strings containing their identifier and optional modifiers
|
23
36
|
# @raise [ArgumentError] If the input string is invalid
|
37
|
+
#
|
38
|
+
# @example Parse a simple 2D chess position (initial position)
|
39
|
+
# PiecePlacement.parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR")
|
40
|
+
# # => [
|
41
|
+
# # ["r", "n", "b", "q", "k", "b", "n", "r"],
|
42
|
+
# # ["p", "p", "p", "p", "p", "p", "p", "p"],
|
43
|
+
# # ["", "", "", "", "", "", "", ""],
|
44
|
+
# # ["", "", "", "", "", "", "", ""],
|
45
|
+
# # ["", "", "", "", "", "", "", ""],
|
46
|
+
# # ["", "", "", "", "", "", "", ""],
|
47
|
+
# # ["P", "P", "P", "P", "P", "P", "P", "P"],
|
48
|
+
# # ["R", "N", "B", "Q", "K", "B", "N", "R"]
|
49
|
+
# # ]
|
50
|
+
#
|
51
|
+
# @example Parse a single rank with mixed pieces and empty squares
|
52
|
+
# PiecePlacement.parse("r2k1r")
|
53
|
+
# # => ["r", "", "", "k", "", "r"]
|
54
|
+
#
|
55
|
+
# @example Parse pieces with PNN modifiers (promoted pieces in Shogi)
|
56
|
+
# PiecePlacement.parse("+P+Bk")
|
57
|
+
# # => ["+P", "+B", "k"]
|
58
|
+
#
|
59
|
+
# @example Parse a 3D board structure (2 planes of 2x2)
|
60
|
+
# PiecePlacement.parse("rn/pp//RN/PP")
|
61
|
+
# # => [
|
62
|
+
# # [["r", "n"], ["p", "p"]],
|
63
|
+
# # [["R", "N"], ["P", "P"]]
|
64
|
+
# # ]
|
65
|
+
#
|
66
|
+
# @example Parse complex Shogi position with promoted pieces
|
67
|
+
# PiecePlacement.parse("9/9/9/9/4+P4/9/5+B3/9/9")
|
68
|
+
# # => [
|
69
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
70
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
71
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
72
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
73
|
+
# # ["", "", "", "", "+P", "", "", "", ""],
|
74
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
75
|
+
# # ["", "", "", "", "", "+B", "", "", ""],
|
76
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
77
|
+
# # ["", "", "", "", "", "", "", "", ""]
|
78
|
+
# # ]
|
79
|
+
#
|
80
|
+
# @example Parse irregular board shapes (different rank sizes)
|
81
|
+
# PiecePlacement.parse("rnbqkbnr/ppppppp/8")
|
82
|
+
# # => [
|
83
|
+
# # ["r", "n", "b", "q", "k", "b", "n", "r"], # 8 cells
|
84
|
+
# # ["p", "p", "p", "p", "p", "p", "p"], # 7 cells
|
85
|
+
# # ["", "", "", "", "", "", "", ""] # 8 cells
|
86
|
+
# # ]
|
87
|
+
#
|
88
|
+
# @example Parse large numbers of empty squares
|
89
|
+
# PiecePlacement.parse("15")
|
90
|
+
# # => ["", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
|
91
|
+
#
|
92
|
+
# @example Parse pieces with all PNN modifier types
|
93
|
+
# PiecePlacement.parse("+P'-R'k")
|
94
|
+
# # => ["+P'", "-R'", "k"]
|
95
|
+
# # Where:
|
96
|
+
# # - "+P'" = enhanced state with intermediate suffix
|
97
|
+
# # - "-R'" = diminished state with intermediate suffix
|
98
|
+
# # - "k" = base piece without modifiers
|
24
99
|
def self.parse(piece_placement_str)
|
25
100
|
validate_input(piece_placement_str)
|
26
101
|
parse_structure(piece_placement_str)
|
@@ -28,9 +103,24 @@ module Feen
|
|
28
103
|
|
29
104
|
# Validates the input string for basic requirements
|
30
105
|
#
|
106
|
+
# Ensures the input is a non-empty string containing only valid FEEN characters.
|
107
|
+
# Valid characters include: letters (a-z, A-Z), digits (0-9), and modifiers (+, -, ').
|
108
|
+
#
|
31
109
|
# @param str [String] Input string to validate
|
32
110
|
# @raise [ArgumentError] If the string is invalid
|
33
111
|
# @return [void]
|
112
|
+
#
|
113
|
+
# @example Valid input
|
114
|
+
# validate_input("rnbqkbnr/pppppppp/8/8")
|
115
|
+
# # => (no error)
|
116
|
+
#
|
117
|
+
# @example Invalid input (empty string)
|
118
|
+
# validate_input("")
|
119
|
+
# # => ArgumentError: Piece placement string cannot be empty
|
120
|
+
#
|
121
|
+
# @example Invalid input (wrong type)
|
122
|
+
# validate_input(123)
|
123
|
+
# # => ArgumentError: Piece placement must be a string, got Integer
|
34
124
|
def self.validate_input(str)
|
35
125
|
raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
|
36
126
|
raise ArgumentError, ERRORS[:empty_string] if str.empty?
|
@@ -43,9 +133,25 @@ module Feen
|
|
43
133
|
|
44
134
|
# Parses the structure recursively
|
45
135
|
#
|
136
|
+
# Determines the dimensionality of the board by analyzing separator patterns
|
137
|
+
# and recursively parses nested structures. Uses the longest separator sequence
|
138
|
+
# to determine the highest dimension level.
|
139
|
+
#
|
46
140
|
# @param str [String] FEEN piece placement string
|
47
|
-
# @return [Array] Parsed structure
|
48
|
-
|
141
|
+
# @return [Array] Parsed structure (1D array for ranks, nested arrays for higher dimensions)
|
142
|
+
#
|
143
|
+
# @example 1D structure (single rank)
|
144
|
+
# parse_structure("rnbq")
|
145
|
+
# # => ["r", "n", "b", "q"]
|
146
|
+
#
|
147
|
+
# @example 2D structure (multiple ranks)
|
148
|
+
# parse_structure("rn/pq")
|
149
|
+
# # => [["r", "n"], ["p", "q"]]
|
150
|
+
#
|
151
|
+
# @example 3D structure (multiple planes)
|
152
|
+
# parse_structure("r/p//R/P")
|
153
|
+
# # => [[["r"], ["p"]], [["R"], ["P"]]]
|
154
|
+
private_class_method def self.parse_structure(str)
|
49
155
|
# Handle trailing separators
|
50
156
|
raise ArgumentError, ERRORS[:invalid_format] if str.end_with?(DIMENSION_SEPARATOR)
|
51
157
|
|
@@ -64,10 +170,22 @@ module Feen
|
|
64
170
|
|
65
171
|
# Splits string by separator while preserving shorter separators
|
66
172
|
#
|
173
|
+
# Intelligently splits a string by a specific separator pattern while
|
174
|
+
# ensuring that shorter separator patterns within the string are preserved
|
175
|
+
# for recursive parsing of nested dimensions.
|
176
|
+
#
|
67
177
|
# @param str [String] String to split
|
68
|
-
# @param separator [String] Separator to split by
|
69
|
-
# @return [Array<String>] Split parts
|
70
|
-
|
178
|
+
# @param separator [String] Separator to split by (e.g., "/", "//", "///")
|
179
|
+
# @return [Array<String>] Split parts, with empty parts removed
|
180
|
+
#
|
181
|
+
# @example Split by single separator
|
182
|
+
# smart_split("a/b/c", "/")
|
183
|
+
# # => ["a", "b", "c"]
|
184
|
+
#
|
185
|
+
# @example Split by double separator, preserving single separators
|
186
|
+
# smart_split("a/b//c/d", "//")
|
187
|
+
# # => ["a/b", "c/d"]
|
188
|
+
private_class_method def self.smart_split(str, separator)
|
71
189
|
return [str] unless str.include?(separator)
|
72
190
|
|
73
191
|
parts = str.split(separator)
|
@@ -76,9 +194,29 @@ module Feen
|
|
76
194
|
|
77
195
|
# Parses a rank (sequence of cells)
|
78
196
|
#
|
197
|
+
# Processes a 1D sequence of cells, expanding numeric values to empty squares
|
198
|
+
# and extracting pieces with their PNN modifiers. Numbers represent consecutive
|
199
|
+
# empty squares, while letters (with optional modifiers) represent pieces.
|
200
|
+
#
|
79
201
|
# @param str [String] FEEN rank string
|
80
|
-
# @return [Array] Array of cells
|
81
|
-
|
202
|
+
# @return [Array<String>] Array of cells (empty strings for empty squares, piece strings for pieces)
|
203
|
+
#
|
204
|
+
# @example Simple pieces
|
205
|
+
# parse_rank("rnbq")
|
206
|
+
# # => ["r", "n", "b", "q"]
|
207
|
+
#
|
208
|
+
# @example Mixed pieces and empty squares
|
209
|
+
# parse_rank("r2k1r")
|
210
|
+
# # => ["r", "", "", "k", "", "r"]
|
211
|
+
#
|
212
|
+
# @example All empty squares
|
213
|
+
# parse_rank("8")
|
214
|
+
# # => ["", "", "", "", "", "", "", ""]
|
215
|
+
#
|
216
|
+
# @example Pieces with modifiers
|
217
|
+
# parse_rank("+P-R'")
|
218
|
+
# # => ["+P", "-R'"]
|
219
|
+
private_class_method def self.parse_rank(str)
|
82
220
|
return [] if str.empty?
|
83
221
|
|
84
222
|
cells = []
|
@@ -114,10 +252,32 @@ module Feen
|
|
114
252
|
|
115
253
|
# Extracts a piece starting at given position
|
116
254
|
#
|
255
|
+
# Parses a piece identifier with optional PNN modifiers starting at the specified
|
256
|
+
# position in the string. Handles prefix modifiers (+, -), the required letter,
|
257
|
+
# and suffix modifiers (').
|
258
|
+
#
|
117
259
|
# @param str [String] String to parse
|
118
|
-
# @param start_index [Integer] Starting position
|
260
|
+
# @param start_index [Integer] Starting position in the string
|
119
261
|
# @return [Hash] Hash with :piece and :next_index keys
|
120
|
-
|
262
|
+
# - :piece [String] The complete piece identifier with modifiers
|
263
|
+
# - :next_index [Integer] Position after the piece in the string
|
264
|
+
#
|
265
|
+
# @example Extract simple piece
|
266
|
+
# extract_piece("Kqr", 0)
|
267
|
+
# # => { piece: "K", next_index: 1 }
|
268
|
+
#
|
269
|
+
# @example Extract piece with prefix modifier
|
270
|
+
# extract_piece("+Pqr", 0)
|
271
|
+
# # => { piece: "+P", next_index: 2 }
|
272
|
+
#
|
273
|
+
# @example Extract piece with suffix modifier
|
274
|
+
# extract_piece("K'qr", 0)
|
275
|
+
# # => { piece: "K'", next_index: 2 }
|
276
|
+
#
|
277
|
+
# @example Extract piece with both prefix and suffix modifiers
|
278
|
+
# extract_piece("+P'qr", 0)
|
279
|
+
# # => { piece: "+P'", next_index: 3 }
|
280
|
+
private_class_method def self.extract_piece(str, start_index)
|
121
281
|
piece = ""
|
122
282
|
i = start_index
|
123
283
|
|