sashite-feen 0.1.0 → 0.3.0
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 +412 -137
- data/lib/sashite/feen/dumper/piece_placement.rb +144 -64
- data/lib/sashite/feen/dumper/pieces_in_hand.rb +124 -34
- data/lib/sashite/feen/dumper/style_turn.rb +29 -45
- data/lib/sashite/feen/dumper.rb +40 -30
- data/lib/sashite/feen/error.rb +72 -29
- data/lib/sashite/feen/hands.rb +62 -20
- data/lib/sashite/feen/parser/piece_placement.rb +324 -118
- data/lib/sashite/feen/parser/pieces_in_hand.rb +210 -44
- data/lib/sashite/feen/parser/style_turn.rb +81 -41
- data/lib/sashite/feen/parser.rb +60 -15
- data/lib/sashite/feen/placement.rb +295 -19
- data/lib/sashite/feen/position.rb +64 -13
- data/lib/sashite/feen/styles.rb +54 -57
- data/lib/sashite/feen.rb +57 -96
- metadata +1 -2
- data/lib/sashite/feen/ordering.rb +0 -16
|
@@ -1,76 +1,242 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../error"
|
|
4
|
+
require_relative "../hands"
|
|
5
|
+
|
|
6
|
+
require "sashite/epin"
|
|
7
|
+
|
|
3
8
|
module Sashite
|
|
4
9
|
module Feen
|
|
5
10
|
module Parser
|
|
11
|
+
# Parser for the pieces-in-hand field (second field of FEEN).
|
|
12
|
+
#
|
|
13
|
+
# Converts a FEEN pieces-in-hand string into a Hands object,
|
|
14
|
+
# decoding captured pieces held by each player with optional
|
|
15
|
+
# count prefixes.
|
|
16
|
+
#
|
|
17
|
+
# @see https://sashite.dev/specs/feen/1.0.0/
|
|
6
18
|
module PiecesInHand
|
|
7
|
-
|
|
19
|
+
# Player separator in pieces-in-hand field.
|
|
20
|
+
PLAYER_SEPARATOR = "/"
|
|
21
|
+
|
|
22
|
+
# Pattern to match EPIN pieces (optional state prefix, letter, optional derivation suffix).
|
|
23
|
+
EPIN_PATTERN = /\A[-+]?[A-Za-z]'?\z/
|
|
24
|
+
|
|
25
|
+
# Parse a FEEN pieces-in-hand string into a Hands object.
|
|
26
|
+
#
|
|
27
|
+
# @param string [String] FEEN pieces-in-hand field string
|
|
28
|
+
# @return [Hands] Parsed hands object
|
|
29
|
+
# @raise [Error::Syntax] If hands format is invalid
|
|
30
|
+
# @raise [Error::Piece] If EPIN notation is invalid
|
|
31
|
+
# @raise [Error::Count] If piece counts are invalid
|
|
32
|
+
#
|
|
33
|
+
# @example No pieces in hand
|
|
34
|
+
# parse("/") # => Hands.new([], [])
|
|
35
|
+
#
|
|
36
|
+
# @example First player has pieces
|
|
37
|
+
# parse("2P/p") # => Hands.new([P, P], [p])
|
|
38
|
+
#
|
|
39
|
+
# @example Both players have pieces
|
|
40
|
+
# parse("RBN/2p") # => Hands.new([R, B, N], [p, p])
|
|
41
|
+
def self.parse(string)
|
|
42
|
+
first_str, second_str = split_players(string)
|
|
43
|
+
|
|
44
|
+
first_player_pieces = parse_player_pieces(first_str)
|
|
45
|
+
second_player_pieces = parse_player_pieces(second_str)
|
|
46
|
+
|
|
47
|
+
Hands.new(first_player_pieces, second_player_pieces)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Split pieces-in-hand string into first and second player parts.
|
|
51
|
+
#
|
|
52
|
+
# @param string [String] Pieces-in-hand field string
|
|
53
|
+
# @return [Array(String, String)] First and second player strings
|
|
54
|
+
# @raise [Error::Syntax] If separator is missing
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# split_players("2P/p") # => ["2P", "p"]
|
|
58
|
+
# split_players("/") # => ["", ""]
|
|
59
|
+
private_class_method def self.split_players(string)
|
|
60
|
+
parts = string.split(PLAYER_SEPARATOR, 2)
|
|
61
|
+
|
|
62
|
+
raise Error::Syntax, "pieces-in-hand must contain '#{PLAYER_SEPARATOR}' separator" unless parts.size == 2
|
|
63
|
+
|
|
64
|
+
parts
|
|
65
|
+
end
|
|
8
66
|
|
|
9
|
-
# Parse
|
|
67
|
+
# Parse pieces for a single player.
|
|
10
68
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# - "" => invalid (raises Error::Syntax)
|
|
69
|
+
# Extracts pieces with optional count prefixes and expands them
|
|
70
|
+
# into individual piece objects.
|
|
14
71
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
72
|
+
# @param string [String] Player's pieces string (e.g., "2PRB")
|
|
73
|
+
# @return [Array] Array of piece objects
|
|
74
|
+
# @raise [Error::Syntax] If format is invalid
|
|
75
|
+
# @raise [Error::Piece] If EPIN notation is invalid
|
|
76
|
+
# @raise [Error::Count] If counts are invalid
|
|
19
77
|
#
|
|
20
|
-
# @
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
78
|
+
# @example Single pieces
|
|
79
|
+
# parse_player_pieces("RBN") # => [R, B, N]
|
|
80
|
+
#
|
|
81
|
+
# @example With counts
|
|
82
|
+
# parse_player_pieces("3P2R") # => [P, P, P, R, R]
|
|
83
|
+
#
|
|
84
|
+
# @example Empty
|
|
85
|
+
# parse_player_pieces("") # => []
|
|
86
|
+
private_class_method def self.parse_player_pieces(string)
|
|
87
|
+
return [] if string.empty?
|
|
26
88
|
|
|
27
|
-
|
|
28
|
-
|
|
89
|
+
pieces = []
|
|
90
|
+
chars = string.chars
|
|
91
|
+
i = 0
|
|
29
92
|
|
|
30
|
-
|
|
93
|
+
while i < chars.size
|
|
94
|
+
count, epin_str, consumed = extract_piece_with_count(chars, i)
|
|
95
|
+
piece = parse_piece(epin_str)
|
|
31
96
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
epin_id = _parse_epin(epin_token, idx)
|
|
35
|
-
counts[epin_id] += qty
|
|
97
|
+
count.times { pieces << piece }
|
|
98
|
+
i += consumed
|
|
36
99
|
end
|
|
37
100
|
|
|
38
|
-
|
|
39
|
-
|
|
101
|
+
pieces
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Extract a piece with optional count prefix from character array.
|
|
105
|
+
#
|
|
106
|
+
# Handles multi-digit counts and EPIN notation extraction.
|
|
107
|
+
#
|
|
108
|
+
# @param chars [Array<String>] Array of characters
|
|
109
|
+
# @param start_index [Integer] Starting index
|
|
110
|
+
# @return [Array(Integer, String, Integer)] Count, EPIN string, and chars consumed
|
|
111
|
+
# @raise [Error::Syntax] If format is invalid
|
|
112
|
+
# @raise [Error::Count] If count is invalid
|
|
113
|
+
#
|
|
114
|
+
# @example Single piece
|
|
115
|
+
# extract_piece_with_count(['K', 'Q'], 0) # => [1, "K", 1]
|
|
116
|
+
#
|
|
117
|
+
# @example Multiple pieces
|
|
118
|
+
# extract_piece_with_count(['3', 'P', 'R'], 0) # => [3, "P", 2]
|
|
119
|
+
#
|
|
120
|
+
# @example Large count
|
|
121
|
+
# extract_piece_with_count(['1', '2', 'P'], 0) # => [12, "P", 3]
|
|
122
|
+
private_class_method def self.extract_piece_with_count(chars, start_index)
|
|
123
|
+
i = start_index
|
|
40
124
|
|
|
41
|
-
|
|
125
|
+
# Extract optional count (may be multi-digit)
|
|
126
|
+
count_chars = []
|
|
127
|
+
while i < chars.size && digit?(chars[i])
|
|
128
|
+
count_chars << chars[i]
|
|
129
|
+
i += 1
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
count = if count_chars.empty?
|
|
133
|
+
1
|
|
134
|
+
else
|
|
135
|
+
count_str = count_chars.join
|
|
136
|
+
count_val = count_str.to_i
|
|
137
|
+
validate_count(count_val, count_str)
|
|
138
|
+
count_val
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Extract EPIN piece
|
|
142
|
+
raise Error::Syntax, "expected piece after count at position #{start_index}" if i >= chars.size
|
|
143
|
+
|
|
144
|
+
epin_str, epin_consumed = extract_epin(chars, i)
|
|
145
|
+
i += epin_consumed
|
|
146
|
+
|
|
147
|
+
consumed = i - start_index
|
|
148
|
+
[count, epin_str, consumed]
|
|
42
149
|
end
|
|
43
150
|
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
151
|
+
# Extract EPIN notation from character array.
|
|
152
|
+
#
|
|
153
|
+
# Handles state prefixes (+/-), base letter, and derivation suffix (').
|
|
154
|
+
#
|
|
155
|
+
# @param chars [Array<String>] Array of characters
|
|
156
|
+
# @param start_index [Integer] Starting index
|
|
157
|
+
# @return [Array(String, Integer)] EPIN string and number of characters consumed
|
|
158
|
+
# @raise [Error::Syntax] If EPIN format is incomplete
|
|
159
|
+
#
|
|
160
|
+
# @example Simple piece
|
|
161
|
+
# extract_epin(['K', 'Q'], 0) # => ["K", 1]
|
|
162
|
+
#
|
|
163
|
+
# @example Enhanced piece with derivation
|
|
164
|
+
# extract_epin(['+', 'R', "'", 'B'], 0) # => ["+R'", 3]
|
|
165
|
+
private_class_method def self.extract_epin(chars, start_index)
|
|
166
|
+
i = start_index
|
|
167
|
+
piece_chars = []
|
|
47
168
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
169
|
+
# Optional state prefix
|
|
170
|
+
if i < chars.size && ["+", "-"].include?(chars[i])
|
|
171
|
+
piece_chars << chars[i]
|
|
172
|
+
i += 1
|
|
173
|
+
end
|
|
51
174
|
|
|
52
|
-
|
|
175
|
+
# Base letter (required)
|
|
176
|
+
if i >= chars.size || !letter?(chars[i])
|
|
177
|
+
raise Error::Syntax, "expected letter in EPIN notation at position #{start_index}"
|
|
53
178
|
end
|
|
54
179
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
raise Error::Count, "hand count must be >= 1, got #{n}" if n <= 0
|
|
180
|
+
piece_chars << chars[i]
|
|
181
|
+
i += 1
|
|
58
182
|
|
|
59
|
-
|
|
183
|
+
# Optional derivation suffix
|
|
184
|
+
if i < chars.size && chars[i] == "'"
|
|
185
|
+
piece_chars << chars[i]
|
|
186
|
+
i += 1
|
|
60
187
|
end
|
|
61
188
|
|
|
62
|
-
|
|
63
|
-
|
|
189
|
+
piece_str = piece_chars.join
|
|
190
|
+
consumed = i - start_index
|
|
191
|
+
|
|
192
|
+
[piece_str, consumed]
|
|
64
193
|
end
|
|
65
|
-
module_function :_parse_hand_entry
|
|
66
|
-
private_class_method :_parse_hand_entry
|
|
67
194
|
|
|
68
|
-
|
|
69
|
-
|
|
195
|
+
# Check if character is a digit.
|
|
196
|
+
#
|
|
197
|
+
# @param char [String] Single character
|
|
198
|
+
# @return [Boolean] True if character is 0-9
|
|
199
|
+
private_class_method def self.digit?(char)
|
|
200
|
+
char >= "0" && char <= "9"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Check if character is a letter.
|
|
204
|
+
#
|
|
205
|
+
# @param char [String] Single character
|
|
206
|
+
# @return [Boolean] True if character is A-Z or a-z
|
|
207
|
+
private_class_method def self.letter?(char)
|
|
208
|
+
(char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Validate piece count.
|
|
212
|
+
#
|
|
213
|
+
# @param count [Integer] Piece count
|
|
214
|
+
# @param count_str [String] Original count string for error messages
|
|
215
|
+
# @raise [Error::Count] If count is invalid
|
|
216
|
+
private_class_method def self.validate_count(count, count_str)
|
|
217
|
+
raise Error::Count, "piece count must be at least 1, got #{count_str}" if count < 1
|
|
218
|
+
|
|
219
|
+
return unless count > 999
|
|
220
|
+
|
|
221
|
+
raise Error::Count, "piece count too large: #{count_str}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Parse EPIN string into a piece object.
|
|
225
|
+
#
|
|
226
|
+
# @param epin_str [String] EPIN notation string
|
|
227
|
+
# @return [Object] Piece identifier object
|
|
228
|
+
# @raise [Error::Piece] If EPIN is invalid
|
|
229
|
+
#
|
|
230
|
+
# @example
|
|
231
|
+
# parse_piece("K") # => Epin::Identifier
|
|
232
|
+
# parse_piece("+R'") # => Epin::Identifier
|
|
233
|
+
private_class_method def self.parse_piece(epin_str)
|
|
234
|
+
raise Error::Piece, "invalid EPIN notation: #{epin_str}" unless EPIN_PATTERN.match?(epin_str)
|
|
235
|
+
|
|
236
|
+
Sashite::Epin.parse(epin_str)
|
|
70
237
|
rescue StandardError => e
|
|
71
|
-
raise Error::Piece, "
|
|
238
|
+
raise Error::Piece, "failed to parse EPIN '#{epin_str}': #{e.message}"
|
|
72
239
|
end
|
|
73
|
-
private_class_method :_parse_epin
|
|
74
240
|
end
|
|
75
241
|
end
|
|
76
242
|
end
|
|
@@ -1,62 +1,102 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../error"
|
|
4
|
+
require_relative "../styles"
|
|
5
|
+
|
|
6
|
+
require "sashite/sin"
|
|
7
|
+
|
|
3
8
|
module Sashite
|
|
4
9
|
module Feen
|
|
5
10
|
module Parser
|
|
11
|
+
# Parser for the style-turn field (third field of FEEN).
|
|
12
|
+
#
|
|
13
|
+
# Converts a FEEN style-turn string into a Styles object,
|
|
14
|
+
# decoding game style identifiers and the active player indicator.
|
|
15
|
+
#
|
|
16
|
+
# @see https://sashite.dev/specs/feen/1.0.0/
|
|
17
|
+
# @see https://sashite.dev/specs/sin/1.0.0/
|
|
6
18
|
module StyleTurn
|
|
7
|
-
|
|
19
|
+
# Style separator in style-turn field.
|
|
20
|
+
STYLE_SEPARATOR = "/"
|
|
8
21
|
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
22
|
+
# Parse a FEEN style-turn string into a Styles object.
|
|
23
|
+
#
|
|
24
|
+
# @param string [String] FEEN style-turn field string
|
|
25
|
+
# @return [Styles] Parsed styles object
|
|
26
|
+
# @raise [Error::Syntax] If style-turn format is invalid
|
|
27
|
+
# @raise [Error::Style] If SIN notation is invalid
|
|
15
28
|
#
|
|
16
|
-
#
|
|
17
|
-
# "C/c"
|
|
18
|
-
# "c/C" -> second to move
|
|
19
|
-
# "S/o" -> first to move, first_style="S" (Shogi), second_style="O" (Ōgi)
|
|
29
|
+
# @example Chess game, white to move
|
|
30
|
+
# parse("C/c") # => Styles.new(sin_C, sin_c)
|
|
20
31
|
#
|
|
21
|
-
#
|
|
22
|
-
# "
|
|
32
|
+
# @example Chess game, black to move
|
|
33
|
+
# parse("c/C") # => Styles.new(sin_c, sin_C)
|
|
23
34
|
#
|
|
24
|
-
# @
|
|
25
|
-
#
|
|
26
|
-
def parse(
|
|
27
|
-
|
|
28
|
-
raise Error::Syntax, "empty style/turn field" if s.empty?
|
|
29
|
-
raise Error::Syntax, "whitespace not allowed in style/turn" if s.match?(/\s/)
|
|
35
|
+
# @example Cross-style game, first player to move
|
|
36
|
+
# parse("C/m") # => Styles.new(sin_C, sin_m)
|
|
37
|
+
def self.parse(string)
|
|
38
|
+
active_str, inactive_str = split_styles(string)
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
active = parse_style(active_str)
|
|
41
|
+
inactive = parse_style(inactive_str)
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
a_is_up = a_raw.between?("A", "Z")
|
|
37
|
-
b_is_up = b_raw.between?("A", "Z")
|
|
43
|
+
Styles.new(active, inactive)
|
|
44
|
+
end
|
|
38
45
|
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
# Split style-turn string into active and inactive parts.
|
|
47
|
+
#
|
|
48
|
+
# @param string [String] Style-turn field string
|
|
49
|
+
# @return [Array(String, String)] Active and inactive style strings
|
|
50
|
+
# @raise [Error::Syntax] If separator is missing or format is invalid
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# split_styles("C/c") # => ["C", "c"]
|
|
54
|
+
# split_styles("S/m") # => ["S", "m"]
|
|
55
|
+
private_class_method def self.split_styles(string)
|
|
56
|
+
parts = string.split(STYLE_SEPARATOR, 2)
|
|
41
57
|
|
|
42
|
-
|
|
43
|
-
a_tok = a_raw.upcase
|
|
44
|
-
b_tok = b_raw.upcase
|
|
58
|
+
raise Error::Syntax, "style-turn must contain '#{STYLE_SEPARATOR}' separator" unless parts.size == 2
|
|
45
59
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
raise Error::Syntax, "active style cannot be empty" if parts[0].empty?
|
|
61
|
+
raise Error::Syntax, "inactive style cannot be empty" if parts[1].empty?
|
|
62
|
+
|
|
63
|
+
parts
|
|
64
|
+
end
|
|
51
65
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
# Parse a SIN string into a style identifier object.
|
|
67
|
+
#
|
|
68
|
+
# @param sin_str [String] SIN notation string (single letter)
|
|
69
|
+
# @return [Object] Style identifier object
|
|
70
|
+
# @raise [Error::Style] If SIN is invalid
|
|
71
|
+
#
|
|
72
|
+
# @example
|
|
73
|
+
# parse_style("C") # => Sin::Identifier (Chess, first player)
|
|
74
|
+
# parse_style("c") # => Sin::Identifier (Chess, second player)
|
|
75
|
+
# parse_style("S") # => Sin::Identifier (Shogi, first player)
|
|
76
|
+
private_class_method def self.parse_style(sin_str)
|
|
77
|
+
unless valid_sin_format?(sin_str)
|
|
78
|
+
raise Error::Style, "invalid SIN notation: '#{sin_str}' (must be a single letter A-Z or a-z)"
|
|
56
79
|
end
|
|
57
80
|
|
|
58
|
-
|
|
59
|
-
|
|
81
|
+
Sashite::Sin.parse(sin_str)
|
|
82
|
+
rescue ::StandardError => e
|
|
83
|
+
raise Error::Style, "failed to parse SIN '#{sin_str}': #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check if string is a valid SIN format.
|
|
87
|
+
#
|
|
88
|
+
# @param string [String] String to validate
|
|
89
|
+
# @return [Boolean] True if string is a single ASCII letter
|
|
90
|
+
private_class_method def self.valid_sin_format?(string)
|
|
91
|
+
string.length == 1 && letter?(string[0])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Check if character is a letter.
|
|
95
|
+
#
|
|
96
|
+
# @param char [String] Single character
|
|
97
|
+
# @return [Boolean] True if character is A-Z or a-z
|
|
98
|
+
private_class_method def self.letter?(char)
|
|
99
|
+
(char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
|
|
60
100
|
end
|
|
61
101
|
end
|
|
62
102
|
end
|
data/lib/sashite/feen/parser.rb
CHANGED
|
@@ -4,32 +4,77 @@ require_relative "parser/piece_placement"
|
|
|
4
4
|
require_relative "parser/pieces_in_hand"
|
|
5
5
|
require_relative "parser/style_turn"
|
|
6
6
|
|
|
7
|
+
require_relative "error"
|
|
8
|
+
require_relative "position"
|
|
9
|
+
|
|
7
10
|
module Sashite
|
|
8
11
|
module Feen
|
|
12
|
+
# Parser for FEEN (Forsyth–Edwards Enhanced Notation) strings.
|
|
13
|
+
#
|
|
14
|
+
# Parses a complete FEEN string by splitting it into three space-separated
|
|
15
|
+
# fields and delegating parsing to specialized parsers for each component.
|
|
16
|
+
#
|
|
17
|
+
# @see https://sashite.dev/specs/feen/1.0.0/
|
|
9
18
|
module Parser
|
|
10
|
-
|
|
19
|
+
# Field separator in FEEN notation.
|
|
20
|
+
FIELD_SEPARATOR = " "
|
|
21
|
+
|
|
22
|
+
# Number of required fields in a valid FEEN string.
|
|
23
|
+
FIELD_COUNT = 3
|
|
11
24
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
25
|
+
# Parse a FEEN string into an immutable Position object.
|
|
26
|
+
#
|
|
27
|
+
# Validates the overall FEEN structure, splits the string into three
|
|
28
|
+
# space-separated fields, and delegates parsing of each field to the
|
|
29
|
+
# appropriate specialized parser.
|
|
30
|
+
#
|
|
31
|
+
# @param string [String] A FEEN notation string
|
|
32
|
+
# @return [Position] Immutable position object
|
|
33
|
+
# @raise [Error::Syntax] If the FEEN structure is malformed
|
|
34
|
+
#
|
|
35
|
+
# @example Parse a complete FEEN string
|
|
36
|
+
# position = Parser.parse("+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c")
|
|
37
|
+
def self.parse(string)
|
|
38
|
+
fields = split_fields(string)
|
|
15
39
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
styles = StyleTurn.parse(c)
|
|
40
|
+
placement = Parser::PiecePlacement.parse(fields[0])
|
|
41
|
+
hands = Parser::PiecesInHand.parse(fields[1])
|
|
42
|
+
styles = Parser::StyleTurn.parse(fields[2])
|
|
20
43
|
|
|
21
44
|
Position.new(placement, hands, styles)
|
|
22
45
|
end
|
|
23
46
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
47
|
+
# Split a FEEN string into its three constituent fields.
|
|
48
|
+
#
|
|
49
|
+
# Validates that exactly three space-separated fields are present.
|
|
50
|
+
# Supports empty piece placement field (board-less positions).
|
|
51
|
+
#
|
|
52
|
+
# @param string [String] A FEEN notation string
|
|
53
|
+
# @return [Array<String>] Array of three field strings
|
|
54
|
+
# @raise [Error::Syntax] If field count is not exactly 3
|
|
55
|
+
#
|
|
56
|
+
# @example Valid FEEN string
|
|
57
|
+
# split_fields("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c")
|
|
58
|
+
# # => ["rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", "/", "C/c"]
|
|
59
|
+
#
|
|
60
|
+
# @example Empty piece placement field
|
|
61
|
+
# split_fields(" / C/c")
|
|
62
|
+
# # => ["", "/", "C/c"]
|
|
63
|
+
#
|
|
64
|
+
# @example Invalid FEEN string (too few fields)
|
|
65
|
+
# split_fields("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR /")
|
|
66
|
+
# # raises Error::Syntax
|
|
67
|
+
private_class_method def self.split_fields(string)
|
|
68
|
+
# Use regex separator to preserve empty leading fields
|
|
69
|
+
# String#split with " " treats leading spaces specially and discards them
|
|
70
|
+
fields = string.split(/ /, FIELD_COUNT)
|
|
71
|
+
|
|
72
|
+
unless fields.size == FIELD_COUNT
|
|
73
|
+
raise Error::Syntax, "FEEN must have exactly #{FIELD_COUNT} space-separated fields, got #{fields.size}"
|
|
29
74
|
end
|
|
30
|
-
|
|
75
|
+
|
|
76
|
+
fields
|
|
31
77
|
end
|
|
32
|
-
private_class_method :_split_3_fields
|
|
33
78
|
end
|
|
34
79
|
end
|
|
35
80
|
end
|