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,152 +1,344 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../error"
|
|
4
|
+
require_relative "../placement"
|
|
5
|
+
|
|
6
|
+
require "sashite/epin"
|
|
7
|
+
|
|
3
8
|
module Sashite
|
|
4
9
|
module Feen
|
|
5
10
|
module Parser
|
|
11
|
+
# Parser for the piece placement field (first field of FEEN).
|
|
12
|
+
#
|
|
13
|
+
# Converts a FEEN piece placement string into a Placement object,
|
|
14
|
+
# decoding board configuration from EPIN notation with empty square
|
|
15
|
+
# compression and multi-dimensional separator support.
|
|
16
|
+
#
|
|
17
|
+
# @see https://sashite.dev/specs/feen/1.0.0/
|
|
6
18
|
module PiecePlacement
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
# placement := rank ( '/' rank | newline rank )*
|
|
13
|
-
# rank := ( int | '.' | epin | sep )*
|
|
14
|
-
# int := [1-9][0-9]* # run-length of empty cells
|
|
15
|
-
# sep := (',' | whitespace)*
|
|
16
|
-
# epin := bracketed_epin | bare_epin
|
|
17
|
-
# bracketed_epin := '[' ... ']' # balanced brackets
|
|
18
|
-
# bare_epin := /[A-Za-z0-9:+\-^~@']+/
|
|
19
|
-
#
|
|
20
|
-
# @param field [String]
|
|
21
|
-
# @return [Sashite::Feen::Placement]
|
|
22
|
-
def parse(field)
|
|
23
|
-
src = String(field)
|
|
24
|
-
raise Error::Syntax, "empty piece placement field" if src.strip.empty?
|
|
25
|
-
|
|
26
|
-
ranks = src.split(%r{(?:/|\R)}).map(&:strip)
|
|
27
|
-
raise Error::Syntax, "no ranks in piece placement" if ranks.empty?
|
|
28
|
-
|
|
29
|
-
grid = []
|
|
30
|
-
width = nil
|
|
31
|
-
|
|
32
|
-
ranks.each_with_index do |rank, r_idx|
|
|
33
|
-
row = _parse_rank(rank, r_idx)
|
|
34
|
-
width ||= row.length
|
|
35
|
-
raise Error::Bounds, "rank #{r_idx + 1} has zero width" if width.zero?
|
|
36
|
-
|
|
37
|
-
if row.length != width
|
|
38
|
-
raise Error::Bounds,
|
|
39
|
-
"inconsistent rank width at rank #{r_idx + 1} (expected #{width}, got #{row.length})"
|
|
40
|
-
end
|
|
41
|
-
grid << row.freeze
|
|
42
|
-
end
|
|
19
|
+
# Rank separator for 2D boards.
|
|
20
|
+
RANK_SEPARATOR = "/"
|
|
21
|
+
|
|
22
|
+
# Pattern to match EPIN pieces (optional state prefix, letter, optional derivation suffix).
|
|
23
|
+
EPIN_PATTERN = /\A[-+]?[A-Za-z]'?\z/
|
|
43
24
|
|
|
44
|
-
|
|
25
|
+
# Parse a FEEN piece placement string into a Placement object.
|
|
26
|
+
#
|
|
27
|
+
# @param string [String] FEEN piece placement field string
|
|
28
|
+
# @return [Placement] Parsed placement object
|
|
29
|
+
# @raise [Error::Syntax] If placement format is invalid
|
|
30
|
+
# @raise [Error::Piece] If EPIN notation is invalid
|
|
31
|
+
#
|
|
32
|
+
# @example Chess starting position
|
|
33
|
+
# 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")
|
|
34
|
+
#
|
|
35
|
+
# @example Empty 8x8 board
|
|
36
|
+
# parse("8/8/8/8/8/8/8/8")
|
|
37
|
+
def self.parse(string)
|
|
38
|
+
dimension = detect_dimension(string)
|
|
39
|
+
rank_strings, section_sizes = split_ranks(string, dimension)
|
|
40
|
+
ranks = rank_strings.map { |rank_str| parse_rank(rank_str) }
|
|
41
|
+
|
|
42
|
+
Placement.new(ranks, dimension, section_sizes)
|
|
43
|
+
end
|
|
45
44
|
|
|
46
|
-
|
|
45
|
+
# Detect board dimensionality from separator patterns.
|
|
46
|
+
#
|
|
47
|
+
# Counts consecutive separators to determine dimension:
|
|
48
|
+
# - "/" = 2D
|
|
49
|
+
# - "//" = 3D
|
|
50
|
+
# - "///" = 4D
|
|
51
|
+
#
|
|
52
|
+
# @param string [String] Piece placement string
|
|
53
|
+
# @return [Integer] Board dimension (minimum 2)
|
|
54
|
+
#
|
|
55
|
+
# @example 2D board
|
|
56
|
+
# detect_dimension("8/8") # => 2
|
|
57
|
+
#
|
|
58
|
+
# @example 3D board
|
|
59
|
+
# detect_dimension("5/5/5//5/5/5") # => 3
|
|
60
|
+
private_class_method def self.detect_dimension(string)
|
|
61
|
+
max_consecutive = string.scan(/\/+/).map(&:length).max || 0
|
|
62
|
+
max_consecutive + 1
|
|
47
63
|
end
|
|
48
64
|
|
|
49
|
-
#
|
|
65
|
+
# Split placement string into rank strings based on dimension.
|
|
66
|
+
#
|
|
67
|
+
# @param string [String] Piece placement string
|
|
68
|
+
# @param dimension [Integer] Board dimensionality
|
|
69
|
+
# @return [Array<String>, Array<Integer>] Array of rank strings and section sizes
|
|
70
|
+
#
|
|
71
|
+
# @example 1D board
|
|
72
|
+
# split_ranks("k+p4+PK", 1) # => [["k+p4+PK"], nil]
|
|
73
|
+
#
|
|
74
|
+
# @example 2D board
|
|
75
|
+
# split_ranks("8/8/8", 2) # => [["8", "8", "8"], nil]
|
|
76
|
+
#
|
|
77
|
+
# @example 3D board
|
|
78
|
+
# split_ranks("5/5//5/5", 3) # => [["5", "5", "5", "5"], [2, 2]]
|
|
79
|
+
private_class_method def self.split_ranks(string, dimension)
|
|
80
|
+
if dimension == 1
|
|
81
|
+
# 1D board: single rank, no separators
|
|
82
|
+
[[string], nil]
|
|
83
|
+
elsif dimension == 2
|
|
84
|
+
# 2D board: split by single separator
|
|
85
|
+
[string.split(RANK_SEPARATOR), nil]
|
|
86
|
+
else
|
|
87
|
+
# Multi-dimensional: split by dimension separator, track section sizes
|
|
88
|
+
dimension_separator = RANK_SEPARATOR * (dimension - 1)
|
|
89
|
+
sections = string.split(dimension_separator)
|
|
90
|
+
|
|
91
|
+
# Each section contains ranks separated by single "/"
|
|
92
|
+
section_sizes = []
|
|
93
|
+
all_ranks = sections.flat_map do |section|
|
|
94
|
+
ranks = section.split(RANK_SEPARATOR)
|
|
95
|
+
section_sizes << ranks.size
|
|
96
|
+
ranks
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
[all_ranks, section_sizes]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
50
102
|
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
103
|
+
# Parse a single rank string into an array of pieces and nils.
|
|
104
|
+
#
|
|
105
|
+
# @param rank_str [String] Single rank string (e.g., "rnbqkbnr" or "4p3")
|
|
106
|
+
# @return [Array] Array containing piece objects and nils
|
|
107
|
+
# @raise [Error::Syntax] If rank format is invalid
|
|
108
|
+
# @raise [Error::Piece] If EPIN notation is invalid
|
|
109
|
+
#
|
|
110
|
+
# @example Rank with pieces
|
|
111
|
+
# parse_rank("rnbqkbnr") # => [piece, piece, ..., piece]
|
|
112
|
+
#
|
|
113
|
+
# @example Rank with empty squares
|
|
114
|
+
# parse_rank("4p3") # => [nil, nil, nil, nil, piece, nil, nil, nil]
|
|
115
|
+
#
|
|
116
|
+
# @example Rank with large empty count
|
|
117
|
+
# parse_rank("100") # => [nil, nil, ..., nil] (100 times)
|
|
118
|
+
private_class_method def self.parse_rank(rank_str)
|
|
119
|
+
result = []
|
|
120
|
+
chars = rank_str.chars
|
|
58
121
|
i = 0
|
|
59
|
-
n = rank_str.length
|
|
60
|
-
cells = []
|
|
61
122
|
|
|
62
|
-
while i <
|
|
63
|
-
|
|
123
|
+
while i < chars.size
|
|
124
|
+
char = chars[i]
|
|
64
125
|
|
|
65
|
-
# Skip separators
|
|
66
|
-
if
|
|
126
|
+
# Skip whitespace and separators (commas)
|
|
127
|
+
if char =~ /\s/ || char == ","
|
|
67
128
|
i += 1
|
|
68
129
|
next
|
|
69
130
|
end
|
|
70
131
|
|
|
71
|
-
# Dot
|
|
72
|
-
if
|
|
73
|
-
|
|
132
|
+
# Dot represents single empty square (legacy support)
|
|
133
|
+
if char == "."
|
|
134
|
+
result << nil
|
|
74
135
|
i += 1
|
|
75
136
|
next
|
|
76
137
|
end
|
|
77
138
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
count = rank_str[i...j].to_i
|
|
83
|
-
raise Error::Count, "empty run must be >= 1 at rank #{r_idx + 1}" if count <= 0
|
|
139
|
+
if first_digit?(char)
|
|
140
|
+
# Empty squares - extract all consecutive digits
|
|
141
|
+
count_str, consumed = extract_number(chars, i)
|
|
142
|
+
count = count_str.to_i
|
|
84
143
|
|
|
85
|
-
|
|
86
|
-
i = j
|
|
87
|
-
next
|
|
88
|
-
end
|
|
144
|
+
raise ::Sashite::Feen::Error::Syntax, "invalid empty square count: #{count_str}" if count < 1
|
|
89
145
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
146
|
+
count.times { result << nil }
|
|
147
|
+
i += consumed
|
|
148
|
+
elsif char == "[" || letter?(char) || char == "+" || char == "-"
|
|
149
|
+
# EPIN piece notation (bare or bracketed)
|
|
150
|
+
piece_str, consumed = extract_epin(chars, i)
|
|
151
|
+
piece = parse_piece(piece_str)
|
|
152
|
+
result << piece
|
|
153
|
+
i += consumed
|
|
154
|
+
else
|
|
155
|
+
# Invalid character
|
|
156
|
+
raise ::Sashite::Feen::Error::Syntax,
|
|
157
|
+
"unexpected character #{char.inspect} at position #{i} in rank"
|
|
96
158
|
end
|
|
159
|
+
end
|
|
97
160
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
161
|
+
result
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Extract a complete number (all consecutive digits) from character array.
|
|
165
|
+
#
|
|
166
|
+
# Implements greedy parsing: reads all consecutive digits until a non-digit
|
|
167
|
+
# character is encountered.
|
|
168
|
+
#
|
|
169
|
+
# @param chars [Array<String>] Array of characters
|
|
170
|
+
# @param start_index [Integer] Starting index
|
|
171
|
+
# @return [Array(String, Integer)] Number string and number of characters consumed
|
|
172
|
+
#
|
|
173
|
+
# @example Single digit
|
|
174
|
+
# extract_number(['8', 'K'], 0) # => ["8", 1]
|
|
175
|
+
#
|
|
176
|
+
# @example Multi-digit number
|
|
177
|
+
# extract_number(['1', '2', '3', 'K'], 0) # => ["123", 3]
|
|
178
|
+
#
|
|
179
|
+
# @example Large number
|
|
180
|
+
# extract_number(['1', '0', '0', '/'], 0) # => ["100", 3]
|
|
181
|
+
private_class_method def self.extract_number(chars, start_index)
|
|
182
|
+
i = start_index
|
|
183
|
+
digits = []
|
|
184
|
+
|
|
185
|
+
# First digit must be 1-9 (non-zero)
|
|
186
|
+
if i < chars.size && first_digit?(chars[i])
|
|
187
|
+
digits << chars[i]
|
|
188
|
+
i += 1
|
|
106
189
|
end
|
|
107
190
|
|
|
108
|
-
|
|
191
|
+
# Subsequent digits can be 0-9
|
|
192
|
+
while i < chars.size && any_digit?(chars[i])
|
|
193
|
+
digits << chars[i]
|
|
194
|
+
i += 1
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
number_str = digits.join
|
|
198
|
+
consumed = i - start_index
|
|
199
|
+
|
|
200
|
+
[number_str, consumed]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Check if character is a non-zero digit (1-9).
|
|
204
|
+
# Used for the first digit of a number.
|
|
205
|
+
#
|
|
206
|
+
# @param char [String] Single character
|
|
207
|
+
# @return [Boolean] True if character is 1-9
|
|
208
|
+
private_class_method def self.first_digit?(char)
|
|
209
|
+
char >= "1" && char <= "9"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Check if character is any digit (0-9).
|
|
213
|
+
# Used for subsequent digits after the first.
|
|
214
|
+
#
|
|
215
|
+
# @param char [String] Single character
|
|
216
|
+
# @return [Boolean] True if character is 0-9
|
|
217
|
+
private_class_method def self.any_digit?(char)
|
|
218
|
+
char >= "0" && char <= "9"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Extract EPIN notation from character array.
|
|
222
|
+
#
|
|
223
|
+
# Handles state prefixes (+/-), base letter, and derivation suffix (').
|
|
224
|
+
# Also supports bracketed EPIN notation for legacy compatibility.
|
|
225
|
+
#
|
|
226
|
+
# @param chars [Array<String>] Array of characters
|
|
227
|
+
# @param start_index [Integer] Starting index
|
|
228
|
+
# @return [Array(String, Integer)] EPIN string and number of characters consumed
|
|
229
|
+
# @raise [Error::Syntax] If EPIN format is incomplete
|
|
230
|
+
#
|
|
231
|
+
# @example Simple piece
|
|
232
|
+
# extract_epin(['K', 'Q'], 0) # => ["K", 1]
|
|
233
|
+
#
|
|
234
|
+
# @example Enhanced piece with derivation
|
|
235
|
+
# extract_epin(['+', 'R', "'", 'B'], 0) # => ["+R'", 3]
|
|
236
|
+
#
|
|
237
|
+
# @example Bracketed piece (legacy)
|
|
238
|
+
# extract_epin(['[', 'K', ']', 'Q'], 0) # => ["K", 3]
|
|
239
|
+
private_class_method def self.extract_epin(chars, start_index)
|
|
240
|
+
i = start_index
|
|
241
|
+
|
|
242
|
+
# Check for bracketed EPIN (legacy support)
|
|
243
|
+
if chars[i] == "["
|
|
244
|
+
return extract_bracketed_epin(chars, start_index)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
piece_chars = []
|
|
248
|
+
|
|
249
|
+
# Optional state prefix
|
|
250
|
+
if chars[i] == "+" || chars[i] == "-"
|
|
251
|
+
piece_chars << chars[i]
|
|
252
|
+
i += 1
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Base letter (required)
|
|
256
|
+
if i >= chars.size || !letter?(chars[i])
|
|
257
|
+
raise ::Sashite::Feen::Error::Syntax, "expected letter in EPIN notation at position #{start_index}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
piece_chars << chars[i]
|
|
261
|
+
i += 1
|
|
262
|
+
|
|
263
|
+
# Optional derivation suffix
|
|
264
|
+
if i < chars.size && chars[i] == "'"
|
|
265
|
+
piece_chars << chars[i]
|
|
266
|
+
i += 1
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
piece_str = piece_chars.join
|
|
270
|
+
consumed = i - start_index
|
|
271
|
+
|
|
272
|
+
[piece_str, consumed]
|
|
109
273
|
end
|
|
110
|
-
module_function :_parse_rank
|
|
111
|
-
private_class_method :_parse_rank
|
|
112
274
|
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
275
|
+
# Extract bracketed EPIN notation (legacy support).
|
|
276
|
+
#
|
|
277
|
+
# Supports balanced brackets for complex piece identifiers.
|
|
278
|
+
#
|
|
279
|
+
# @param chars [Array<String>] Array of characters
|
|
280
|
+
# @param start_index [Integer] Starting index (should point to '[')
|
|
281
|
+
# @return [Array(String, Integer)] EPIN string (without brackets) and chars consumed
|
|
282
|
+
# @raise [Error::Syntax] If brackets are unbalanced
|
|
283
|
+
#
|
|
284
|
+
# @example
|
|
285
|
+
# extract_bracketed_epin(['[', 'K', 'i', 'n', 'g', ']'], 0) # => ["King", 6]
|
|
286
|
+
private_class_method def self.extract_bracketed_epin(chars, start_index)
|
|
287
|
+
i = start_index + 1 # Skip opening '['
|
|
117
288
|
depth = 1
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
289
|
+
content_chars = []
|
|
290
|
+
|
|
291
|
+
while i < chars.size && depth.positive?
|
|
292
|
+
case chars[i]
|
|
293
|
+
when "["
|
|
294
|
+
depth += 1
|
|
295
|
+
content_chars << chars[i] if depth > 1
|
|
296
|
+
when "]"
|
|
297
|
+
depth -= 1
|
|
298
|
+
content_chars << chars[i] if depth.positive?
|
|
299
|
+
else
|
|
300
|
+
content_chars << chars[i]
|
|
122
301
|
end
|
|
123
|
-
|
|
302
|
+
i += 1
|
|
124
303
|
end
|
|
125
|
-
raise Error::Piece, "unterminated EPIN bracket at rank #{r_idx + 1}, index #{i}" unless depth.zero?
|
|
126
304
|
|
|
127
|
-
|
|
305
|
+
if depth.positive?
|
|
306
|
+
raise ::Sashite::Feen::Error::Syntax,
|
|
307
|
+
"unterminated bracket at position #{start_index}"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
content = content_chars.join
|
|
311
|
+
consumed = i - start_index
|
|
312
|
+
|
|
313
|
+
[content, consumed]
|
|
128
314
|
end
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
# Autorisés : lettres + modificateurs (+ - : ^ ~ @ ')
|
|
137
|
-
j += 1 while j < str.length && str[j] =~ /[A-Za-z:+\-^~@']/
|
|
138
|
-
[str[i...j], j]
|
|
315
|
+
|
|
316
|
+
# Check if character is a letter.
|
|
317
|
+
#
|
|
318
|
+
# @param char [String] Single character
|
|
319
|
+
# @return [Boolean] True if character is A-Z or a-z
|
|
320
|
+
private_class_method def self.letter?(char)
|
|
321
|
+
(char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
|
|
139
322
|
end
|
|
140
323
|
|
|
141
|
-
|
|
324
|
+
# Parse EPIN string into a piece object.
|
|
325
|
+
#
|
|
326
|
+
# @param epin_str [String] EPIN notation string
|
|
327
|
+
# @return [Object] Piece identifier object
|
|
328
|
+
# @raise [Error::Piece] If EPIN is invalid
|
|
329
|
+
#
|
|
330
|
+
# @example
|
|
331
|
+
# parse_piece("K") # => Epin::Identifier
|
|
332
|
+
# parse_piece("+R'") # => Epin::Identifier
|
|
333
|
+
private_class_method def self.parse_piece(epin_str)
|
|
334
|
+
unless EPIN_PATTERN.match?(epin_str)
|
|
335
|
+
raise ::Sashite::Feen::Error::Piece, "invalid EPIN notation: #{epin_str}"
|
|
336
|
+
end
|
|
142
337
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
raise Error::Piece,
|
|
147
|
-
"invalid EPIN token at (rank #{r_idx + 1}, col #{c_idx}): #{token.inspect} (#{e.message})"
|
|
338
|
+
::Sashite::Epin.parse(epin_str)
|
|
339
|
+
rescue ::StandardError => e
|
|
340
|
+
raise ::Sashite::Feen::Error::Piece, "failed to parse EPIN '#{epin_str}': #{e.message}"
|
|
148
341
|
end
|
|
149
|
-
private_class_method :_parse_epin
|
|
150
342
|
end
|
|
151
343
|
end
|
|
152
344
|
end
|