sashite-feen 0.1.0 → 0.2.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 +64 -183
- data/lib/sashite/feen/dumper/piece_placement.rb +86 -63
- data/lib/sashite/feen/dumper/pieces_in_hand.rb +124 -35
- 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 +302 -110
- data/lib/sashite/feen/parser/pieces_in_hand.rb +216 -44
- data/lib/sashite/feen/parser/style_turn.rb +81 -41
- data/lib/sashite/feen/parser.rb +52 -16
- data/lib/sashite/feen/placement.rb +67 -21
- 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,248 @@
|
|
|
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
|
|
66
|
+
|
|
67
|
+
# Parse pieces for a single player.
|
|
68
|
+
#
|
|
69
|
+
# Extracts pieces with optional count prefixes and expands them
|
|
70
|
+
# into individual piece objects.
|
|
71
|
+
#
|
|
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
|
|
77
|
+
#
|
|
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?
|
|
88
|
+
|
|
89
|
+
pieces = []
|
|
90
|
+
chars = string.chars
|
|
91
|
+
i = 0
|
|
92
|
+
|
|
93
|
+
while i < chars.size
|
|
94
|
+
count, epin_str, consumed = extract_piece_with_count(chars, i)
|
|
95
|
+
piece = parse_piece(epin_str)
|
|
96
|
+
|
|
97
|
+
count.times { pieces << piece }
|
|
98
|
+
i += consumed
|
|
99
|
+
end
|
|
8
100
|
|
|
9
|
-
|
|
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
|
|
10
113
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# - "" => invalid (raises Error::Syntax)
|
|
114
|
+
# @example Single piece
|
|
115
|
+
# extract_piece_with_count(['K', 'Q'], 0) # => [1, "K", 1]
|
|
14
116
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# entry := epin | int ("x"|"*") epin | epin ("x"|"*") int
|
|
18
|
-
# int := [1-9][0-9]*
|
|
117
|
+
# @example Multiple pieces
|
|
118
|
+
# extract_piece_with_count(['3', 'P', 'R'], 0) # => [3, "P", 2]
|
|
19
119
|
#
|
|
20
|
-
# @
|
|
21
|
-
#
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
raise Error::Syntax, "empty hands field" if src.empty?
|
|
25
|
-
return Hands.new({}.freeze) if src == "-"
|
|
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
|
|
26
124
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
29
131
|
|
|
30
|
-
|
|
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
|
|
31
140
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
counts[epin_id] += qty
|
|
141
|
+
# Extract EPIN piece
|
|
142
|
+
if i >= chars.size
|
|
143
|
+
raise Error::Syntax, "expected piece after count at position #{start_index}"
|
|
36
144
|
end
|
|
37
145
|
|
|
38
|
-
|
|
39
|
-
|
|
146
|
+
epin_str, epin_consumed = extract_epin(chars, i)
|
|
147
|
+
i += epin_consumed
|
|
40
148
|
|
|
41
|
-
|
|
149
|
+
consumed = i - start_index
|
|
150
|
+
[count, epin_str, consumed]
|
|
42
151
|
end
|
|
43
152
|
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
153
|
+
# Extract EPIN notation from character array.
|
|
154
|
+
#
|
|
155
|
+
# Handles state prefixes (+/-), base letter, and derivation suffix (').
|
|
156
|
+
#
|
|
157
|
+
# @param chars [Array<String>] Array of characters
|
|
158
|
+
# @param start_index [Integer] Starting index
|
|
159
|
+
# @return [Array(String, Integer)] EPIN string and number of characters consumed
|
|
160
|
+
# @raise [Error::Syntax] If EPIN format is incomplete
|
|
161
|
+
#
|
|
162
|
+
# @example Simple piece
|
|
163
|
+
# extract_epin(['K', 'Q'], 0) # => ["K", 1]
|
|
164
|
+
#
|
|
165
|
+
# @example Enhanced piece with derivation
|
|
166
|
+
# extract_epin(['+', 'R', "'", 'B'], 0) # => ["+R'", 3]
|
|
167
|
+
private_class_method def self.extract_epin(chars, start_index)
|
|
168
|
+
i = start_index
|
|
169
|
+
piece_chars = []
|
|
47
170
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
171
|
+
# Optional state prefix
|
|
172
|
+
if i < chars.size && (chars[i] == "+" || chars[i] == "-")
|
|
173
|
+
piece_chars << chars[i]
|
|
174
|
+
i += 1
|
|
175
|
+
end
|
|
51
176
|
|
|
52
|
-
|
|
177
|
+
# Base letter (required)
|
|
178
|
+
if i >= chars.size || !letter?(chars[i])
|
|
179
|
+
raise Error::Syntax, "expected letter in EPIN notation at position #{start_index}"
|
|
53
180
|
end
|
|
54
181
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
raise Error::Count, "hand count must be >= 1, got #{n}" if n <= 0
|
|
182
|
+
piece_chars << chars[i]
|
|
183
|
+
i += 1
|
|
58
184
|
|
|
59
|
-
|
|
185
|
+
# Optional derivation suffix
|
|
186
|
+
if i < chars.size && chars[i] == "'"
|
|
187
|
+
piece_chars << chars[i]
|
|
188
|
+
i += 1
|
|
60
189
|
end
|
|
61
190
|
|
|
62
|
-
|
|
63
|
-
|
|
191
|
+
piece_str = piece_chars.join
|
|
192
|
+
consumed = i - start_index
|
|
193
|
+
|
|
194
|
+
[piece_str, consumed]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Check if character is a digit.
|
|
198
|
+
#
|
|
199
|
+
# @param char [String] Single character
|
|
200
|
+
# @return [Boolean] True if character is 0-9
|
|
201
|
+
private_class_method def self.digit?(char)
|
|
202
|
+
char >= "0" && char <= "9"
|
|
64
203
|
end
|
|
65
|
-
module_function :_parse_hand_entry
|
|
66
|
-
private_class_method :_parse_hand_entry
|
|
67
204
|
|
|
68
|
-
|
|
69
|
-
|
|
205
|
+
# Check if character is a letter.
|
|
206
|
+
#
|
|
207
|
+
# @param char [String] Single character
|
|
208
|
+
# @return [Boolean] True if character is A-Z or a-z
|
|
209
|
+
private_class_method def self.letter?(char)
|
|
210
|
+
(char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Validate piece count.
|
|
214
|
+
#
|
|
215
|
+
# @param count [Integer] Piece count
|
|
216
|
+
# @param count_str [String] Original count string for error messages
|
|
217
|
+
# @raise [Error::Count] If count is invalid
|
|
218
|
+
private_class_method def self.validate_count(count, count_str)
|
|
219
|
+
if count < 1
|
|
220
|
+
raise Error::Count, "piece count must be at least 1, got #{count_str}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if count > 999
|
|
224
|
+
raise Error::Count, "piece count too large: #{count_str}"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Parse EPIN string into a piece object.
|
|
229
|
+
#
|
|
230
|
+
# @param epin_str [String] EPIN notation string
|
|
231
|
+
# @return [Object] Piece identifier object
|
|
232
|
+
# @raise [Error::Piece] If EPIN is invalid
|
|
233
|
+
#
|
|
234
|
+
# @example
|
|
235
|
+
# parse_piece("K") # => Epin::Identifier
|
|
236
|
+
# parse_piece("+R'") # => Epin::Identifier
|
|
237
|
+
private_class_method def self.parse_piece(epin_str)
|
|
238
|
+
unless EPIN_PATTERN.match?(epin_str)
|
|
239
|
+
raise Error::Piece, "invalid EPIN notation: #{epin_str}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
Sashite::Epin.parse(epin_str)
|
|
70
243
|
rescue StandardError => e
|
|
71
|
-
raise Error::Piece, "
|
|
244
|
+
raise Error::Piece, "failed to parse EPIN '#{epin_str}': #{e.message}"
|
|
72
245
|
end
|
|
73
|
-
private_class_method :_parse_epin
|
|
74
246
|
end
|
|
75
247
|
end
|
|
76
248
|
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,68 @@ 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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
# Split a FEEN string into its three constituent fields.
|
|
48
|
+
#
|
|
49
|
+
# Validates that exactly three space-separated fields are present.
|
|
50
|
+
#
|
|
51
|
+
# @param string [String] A FEEN notation string
|
|
52
|
+
# @return [Array<String>] Array of three field strings
|
|
53
|
+
# @raise [Error::Syntax] If field count is not exactly 3
|
|
54
|
+
#
|
|
55
|
+
# @example Valid FEEN string
|
|
56
|
+
# split_fields("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c")
|
|
57
|
+
# # => ["rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", "/", "C/c"]
|
|
58
|
+
#
|
|
59
|
+
# @example Invalid FEEN string (too few fields)
|
|
60
|
+
# split_fields("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR /")
|
|
61
|
+
# # raises Error::Syntax
|
|
62
|
+
private_class_method def self.split_fields(string)
|
|
63
|
+
fields = string.split(FIELD_SEPARATOR, FIELD_COUNT)
|
|
64
|
+
|
|
65
|
+
raise Error::Syntax, "FEEN must have exactly #{FIELD_COUNT} space-separated fields, got #{fields.size}" unless fields.size == FIELD_COUNT
|
|
66
|
+
|
|
67
|
+
fields
|
|
31
68
|
end
|
|
32
|
-
private_class_method :_split_3_fields
|
|
33
69
|
end
|
|
34
70
|
end
|
|
35
71
|
end
|
|
@@ -2,30 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
module Sashite
|
|
4
4
|
module Feen
|
|
5
|
-
# Immutable
|
|
5
|
+
# Immutable representation of board piece placement.
|
|
6
|
+
#
|
|
7
|
+
# Stores the configuration of pieces on a multi-dimensional board,
|
|
8
|
+
# where each position can contain a piece or be empty (nil).
|
|
9
|
+
#
|
|
10
|
+
# @see https://sashite.dev/specs/feen/1.0.0/
|
|
6
11
|
class Placement
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
12
|
+
# @return [Array<Array>] Array of ranks, each rank being an array of pieces/nils
|
|
13
|
+
attr_reader :ranks
|
|
14
|
+
|
|
15
|
+
# @return [Integer] Board dimensionality (2 for 2D, 3 for 3D, etc.)
|
|
16
|
+
attr_reader :dimension
|
|
17
|
+
|
|
18
|
+
# @return [Array<Integer>, nil] Section sizes for multi-dimensional boards (nil for 2D)
|
|
19
|
+
attr_reader :sections
|
|
20
|
+
|
|
21
|
+
# Create a new immutable Placement object.
|
|
22
|
+
#
|
|
23
|
+
# @param ranks [Array<Array>] Array of ranks with pieces and nils
|
|
24
|
+
# @param dimension [Integer] Board dimensionality (default: 2)
|
|
25
|
+
# @param sections [Array<Integer>, nil] Sizes of sections for dimension > 2
|
|
26
|
+
#
|
|
27
|
+
# @example Create a 2D placement
|
|
28
|
+
# ranks = [
|
|
29
|
+
# [rook, knight, bishop, queen, king, bishop, knight, rook],
|
|
30
|
+
# [pawn, pawn, pawn, pawn, pawn, pawn, pawn, pawn]
|
|
31
|
+
# ]
|
|
32
|
+
# placement = Placement.new(ranks)
|
|
33
|
+
#
|
|
34
|
+
# @example Create a 3D placement
|
|
35
|
+
# ranks = [...] # 15 ranks total
|
|
36
|
+
# placement = Placement.new(ranks, 3, [5, 5, 5]) # 3 sections of 5 ranks each
|
|
37
|
+
def initialize(ranks, dimension = 2, sections = nil)
|
|
38
|
+
@ranks = ranks.freeze
|
|
39
|
+
@dimension = dimension
|
|
40
|
+
@sections = sections&.freeze
|
|
41
|
+
|
|
27
42
|
freeze
|
|
28
43
|
end
|
|
44
|
+
|
|
45
|
+
# Convert placement to its FEEN string representation.
|
|
46
|
+
#
|
|
47
|
+
# @return [String] FEEN piece placement field
|
|
48
|
+
#
|
|
49
|
+
# @example
|
|
50
|
+
# placement.to_s
|
|
51
|
+
# # => "+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"
|
|
52
|
+
def to_s
|
|
53
|
+
Dumper::PiecePlacement.dump(self)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Compare two placements for equality.
|
|
57
|
+
#
|
|
58
|
+
# @param other [Placement] Another placement object
|
|
59
|
+
# @return [Boolean] True if ranks, dimensions, and sections are equal
|
|
60
|
+
def ==(other)
|
|
61
|
+
other.is_a?(Placement) &&
|
|
62
|
+
ranks == other.ranks &&
|
|
63
|
+
dimension == other.dimension &&
|
|
64
|
+
sections == other.sections
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
alias eql? ==
|
|
68
|
+
|
|
69
|
+
# Generate hash code for placement.
|
|
70
|
+
#
|
|
71
|
+
# @return [Integer] Hash code based on ranks, dimension, and sections
|
|
72
|
+
def hash
|
|
73
|
+
[ranks, dimension, sections].hash
|
|
74
|
+
end
|
|
29
75
|
end
|
|
30
76
|
end
|
|
31
77
|
end
|