feen 5.0.0.beta6 → 5.0.0.beta7
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 +74 -36
- data/lib/feen/dumper/pieces_in_hand.rb +93 -42
- data/lib/feen/parser/pieces_in_hand/errors.rb +4 -1
- data/lib/feen/parser/pieces_in_hand/pnn_patterns.rb +54 -12
- data/lib/feen/parser/pieces_in_hand.rb +94 -102
- metadata +1 -6
- data/lib/feen/dumper/pieces_in_hand/no_pieces.rb +0 -10
- data/lib/feen/parser/pieces_in_hand/canonical_sorter.rb +0 -70
- data/lib/feen/parser/pieces_in_hand/no_pieces.rb +0 -10
- data/lib/feen/parser/pieces_in_hand/piece_count_pattern.rb +0 -13
- data/lib/feen/parser/pieces_in_hand/valid_format_pattern.rb +0 -29
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0f302aa1f4504f94ed909dcb5f6759c52a8895c85e4bd4cc0c0cbff42ef6fdd4
|
4
|
+
data.tar.gz: eb77d9e6e1d1bae1c13cc0b4997e1b509e06b631001fbc8c60027da234f50494
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 331d90ea031b33aa88292680f98b5c847c467827ad2cd64f39e261c8d052a3cfefdf1f4fe734befc51ffffe7667fbe3b713a08d6952adc86869f3401726f12c5
|
7
|
+
data.tar.gz: 574801b36461caf3e971540430962344442a43027e100d76527df73345374ab624a6a2d7bd80fa9cb3917a85a2e16d4ececbca67325776c134a33d7d400ee5c7
|
data/README.md
CHANGED
@@ -30,14 +30,14 @@ A FEEN record consists of three space-separated fields:
|
|
30
30
|
### Field Details
|
31
31
|
|
32
32
|
1. **Piece Placement**: Spatial distribution of pieces on the board using [PNN notation](https://sashite.dev/documents/pnn/1.0.0/)
|
33
|
-
2. **Pieces in Hand**: Off-board pieces available for placement, sorted canonically
|
33
|
+
2. **Pieces in Hand**: Off-board pieces available for placement, formatted as `"UPPERCASE/lowercase"` and sorted canonically within each section
|
34
34
|
3. **Games Turn**: Game identifiers and active player indication
|
35
35
|
|
36
36
|
## Installation
|
37
37
|
|
38
38
|
```ruby
|
39
39
|
# In your Gemfile
|
40
|
-
gem "feen", ">= 5.0.0.
|
40
|
+
gem "feen", ">= 5.0.0.beta7"
|
41
41
|
```
|
42
42
|
|
43
43
|
Or install manually:
|
@@ -55,7 +55,7 @@ Convert a FEEN string into a structured Ruby object:
|
|
55
55
|
```ruby
|
56
56
|
require "feen"
|
57
57
|
|
58
|
-
feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR
|
58
|
+
feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
59
59
|
position = Feen.parse(feen_string)
|
60
60
|
|
61
61
|
# Result is a hash:
|
@@ -83,7 +83,7 @@ Parse a FEEN string without raising exceptions:
|
|
83
83
|
require "feen"
|
84
84
|
|
85
85
|
# Valid FEEN string
|
86
|
-
result = Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR
|
86
|
+
result = Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess")
|
87
87
|
# => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
|
88
88
|
|
89
89
|
# Invalid FEEN string
|
@@ -115,7 +115,7 @@ result = Feen.dump(
|
|
115
115
|
games_turn: %w[CHESS chess],
|
116
116
|
pieces_in_hand: []
|
117
117
|
)
|
118
|
-
# => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR
|
118
|
+
# => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
119
119
|
```
|
120
120
|
|
121
121
|
### Validation
|
@@ -126,7 +126,7 @@ Check if a string is valid FEEN notation and in canonical form:
|
|
126
126
|
require "feen"
|
127
127
|
|
128
128
|
# Canonical form
|
129
|
-
Feen.valid?("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL
|
129
|
+
Feen.valid?("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL / SHOGI/shogi")
|
130
130
|
# => true
|
131
131
|
|
132
132
|
# Invalid syntax
|
@@ -134,8 +134,8 @@ Feen.valid?("invalid feen string")
|
|
134
134
|
# => false
|
135
135
|
|
136
136
|
# Valid syntax but non-canonical form (pieces in hand not in canonical order)
|
137
|
-
Feen.valid?("8/8/8/8/8/8/8/8 P3K CHESS/chess")
|
138
|
-
# => false (wrong quantity sorting)
|
137
|
+
Feen.valid?("8/8/8/8/8/8/8/8 P3K/ CHESS/chess")
|
138
|
+
# => false (wrong quantity sorting in uppercase section)
|
139
139
|
```
|
140
140
|
|
141
141
|
The `valid?` method performs two levels of validation:
|
@@ -150,7 +150,7 @@ As FEEN is rule-agnostic, it can represent positions from various board games. H
|
|
150
150
|
### International Chess
|
151
151
|
|
152
152
|
```ruby
|
153
|
-
feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR
|
153
|
+
feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
154
154
|
```
|
155
155
|
|
156
156
|
In this initial chess position, the third field `CHESS/chess` indicates it's the player with uppercase pieces' turn to move.
|
@@ -158,36 +158,58 @@ In this initial chess position, the third field `CHESS/chess` indicates it's the
|
|
158
158
|
### Shogi (Japanese Chess)
|
159
159
|
|
160
160
|
```ruby
|
161
|
-
feen_string = "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL
|
161
|
+
feen_string = "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL / SHOGI/shogi"
|
162
162
|
```
|
163
163
|
|
164
164
|
**With pieces in hand and promotions:**
|
165
165
|
|
166
166
|
```ruby
|
167
|
-
feen_string = "lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL
|
167
|
+
feen_string = "lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL 5P2G2L/2gln2s SHOGI/shogi"
|
168
168
|
```
|
169
169
|
|
170
170
|
In this shogi position:
|
171
|
+
|
171
172
|
- The format supports promotions with the `+` prefix (e.g., `+P` for a promoted pawn)
|
172
|
-
-
|
173
|
-
- **
|
174
|
-
- **
|
173
|
+
- Pieces in hand are separated by case: `5P2G2L/2gln2s`
|
174
|
+
- **Uppercase section** (Sente): 5 Pawns, 2 Golds, 2 Lances
|
175
|
+
- **Lowercase section** (Gote): 2 golds, lance, knight, 2 silvers
|
176
|
+
- Each section is sorted by quantity (descending) then alphabetically
|
175
177
|
- `SHOGI/shogi` indicates it's Sente's (Black's, uppercase) turn to move
|
176
178
|
|
177
179
|
### Makruk (Thai Chess)
|
178
180
|
|
179
181
|
```ruby
|
180
|
-
feen_string = "rnbqkbnr/8/pppppppp/8/8/PPPPPPPP/8/RNBKQBNR
|
182
|
+
feen_string = "rnbqkbnr/8/pppppppp/8/8/PPPPPPPP/8/RNBKQBNR / MAKRUK/makruk"
|
181
183
|
```
|
182
184
|
|
183
185
|
### Xiangqi (Chinese Chess)
|
184
186
|
|
185
187
|
```ruby
|
186
|
-
feen_string = "rheagaehr/9/1c5c1/s1s1s1s1s/9/9/S1S1S1S1S/1C5C1/9/RHEAGAEHR
|
188
|
+
feen_string = "rheagaehr/9/1c5c1/s1s1s1s1s/9/9/S1S1S1S1S/1C5C1/9/RHEAGAEHR / XIANGQI/xiangqi"
|
187
189
|
```
|
188
190
|
|
189
191
|
## Advanced Features
|
190
192
|
|
193
|
+
### Pieces in Hand with Case Separation
|
194
|
+
|
195
|
+
FEEN uses case separation for pieces in hand to distinguish between players using the format `"UPPERCASE_PIECES/LOWERCASE_PIECES"`:
|
196
|
+
|
197
|
+
```ruby
|
198
|
+
require "feen"
|
199
|
+
|
200
|
+
# Parse pieces in hand with case separation
|
201
|
+
pieces_in_hand = Feen.parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR 3P2B/2pn CHESS/chess")[:pieces_in_hand]
|
202
|
+
# => ["B", "B", "P", "P", "P", "n", "p", "p"] # Sorted alphabetically
|
203
|
+
|
204
|
+
# Create FEEN with pieces in hand
|
205
|
+
result = Feen.dump(
|
206
|
+
piece_placement: [["k"], ["K"]],
|
207
|
+
pieces_in_hand: ["P", "P", "B", "p", "n"],
|
208
|
+
games_turn: ["TEST", "test"]
|
209
|
+
)
|
210
|
+
# => "k/K 2BP/np TEST/test"
|
211
|
+
```
|
212
|
+
|
191
213
|
### Piece Name Notation (PNN) Support
|
192
214
|
|
193
215
|
FEEN supports the complete [PNN specification](https://sashite.dev/documents/pnn/1.0.0/) for representing pieces with state modifiers:
|
@@ -207,7 +229,7 @@ piece_placement = [
|
|
207
229
|
# ... other ranks
|
208
230
|
]
|
209
231
|
|
210
|
-
# Pieces in hand with PNN modifiers
|
232
|
+
# Pieces in hand with PNN modifiers - case separated
|
211
233
|
pieces_in_hand = ["+P", "+P", "+P", "B'", "B'", "-p", "P"]
|
212
234
|
|
213
235
|
result = Feen.dump(
|
@@ -215,34 +237,38 @@ result = Feen.dump(
|
|
215
237
|
pieces_in_hand: pieces_in_hand,
|
216
238
|
games_turn: %w[SHOGI shogi]
|
217
239
|
)
|
218
|
-
# => "8/8/8/8/4+P4/8/8/8/8 3+P2B'
|
240
|
+
# => "8/8/8/8/4+P4/8/8/8/8 3+P2B'P/-p SHOGI/shogi"
|
219
241
|
```
|
220
242
|
|
221
243
|
### Canonical Pieces in Hand Sorting
|
222
244
|
|
223
|
-
FEEN enforces canonical ordering of pieces in hand according to the specification:
|
245
|
+
FEEN enforces canonical ordering of pieces in hand within each case section according to the specification:
|
224
246
|
|
225
247
|
1. **By quantity (descending)**
|
226
248
|
2. **By complete PNN representation (alphabetically ascending)**
|
227
249
|
|
250
|
+
The dumper organizes pieces by case first, then applies canonical sorting within each section:
|
251
|
+
|
228
252
|
```ruby
|
229
253
|
# Input pieces in any order
|
230
|
-
pieces = ["P", "
|
254
|
+
pieces = ["P", "b", "P", "+P", "B", "p", "+P", "+P"]
|
231
255
|
|
232
|
-
result = Feen
|
233
|
-
|
234
|
-
|
256
|
+
result = Feen.dump(
|
257
|
+
piece_placement: [["k"], ["K"]],
|
258
|
+
pieces_in_hand: pieces,
|
259
|
+
games_turn: %w[GAME game]
|
260
|
+
)
|
261
|
+
# => "k/K 3+P2PB/bp GAME/game"
|
262
|
+
# Breakdown:
|
263
|
+
# - Uppercase: 3×+P (most frequent), 2×P, 1×B (alphabetical within same quantity)
|
264
|
+
# - Lowercase: 1×b, 1×p (alphabetical)
|
235
265
|
```
|
236
266
|
|
237
|
-
|
267
|
+
The parser returns pieces in simple alphabetical order for easy handling:
|
238
268
|
|
239
269
|
```ruby
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
result = Feen::Dumper::PiecesInHand.dump(*pieces)
|
244
|
-
# => "10P5K3B2p'+P-pRbq"
|
245
|
-
# Sorted by: quantity desc (10,5,3,2,1...), then alphabetical (+P,-p,R,b,q)
|
270
|
+
pieces_in_hand = Feen.parse("k/K 3+P2PB/bp GAME/game")[:pieces_in_hand]
|
271
|
+
# => ["+P", "+P", "+P", "B", "P", "P", "b", "p"] # Alphabetically sorted
|
246
272
|
```
|
247
273
|
|
248
274
|
### Multi-dimensional Boards
|
@@ -269,7 +295,7 @@ result = Feen.dump(
|
|
269
295
|
games_turn: %w[FOO bar],
|
270
296
|
pieces_in_hand: []
|
271
297
|
)
|
272
|
-
# => "rnb/qkp//PR1/1KQ
|
298
|
+
# => "rnb/qkp//PR1/1KQ / FOO/bar"
|
273
299
|
```
|
274
300
|
|
275
301
|
### Hybrid Games
|
@@ -278,20 +304,22 @@ FEEN supports hybrid games mixing different piece sets:
|
|
278
304
|
|
279
305
|
```ruby
|
280
306
|
# Chess-Shogi hybrid position
|
281
|
-
feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR 3+P2B'
|
307
|
+
feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR 3+P2B'/p CHESS/shogi"
|
282
308
|
```
|
283
309
|
|
284
310
|
This represents a position where:
|
311
|
+
|
285
312
|
- The board uses chess-style pieces
|
286
|
-
- Pieces in hand use shogi-style promotion (`+P`) and intermediate states (`B'
|
313
|
+
- Pieces in hand use shogi-style promotion (`+P`) and intermediate states (`B'`)
|
287
314
|
- Chess player to move, against shogi player
|
315
|
+
- Case separation shows which player has which pieces
|
288
316
|
|
289
317
|
## Round-trip Consistency
|
290
318
|
|
291
319
|
FEEN.rb guarantees round-trip consistency - parsing and dumping produces identical canonical strings:
|
292
320
|
|
293
321
|
```ruby
|
294
|
-
original = "lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL
|
322
|
+
original = "lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL 5P2G2L/2gln2s SHOGI/shogi"
|
295
323
|
parsed = Feen.parse(original)
|
296
324
|
dumped = Feen.dump(**parsed)
|
297
325
|
|
@@ -304,8 +332,12 @@ original == dumped # => true (guaranteed canonical form)
|
|
304
332
|
|
305
333
|
```ruby
|
306
334
|
# Invalid PNN format
|
307
|
-
Feen
|
308
|
-
|
335
|
+
Feen.dump(
|
336
|
+
piece_placement: [["k"]],
|
337
|
+
pieces_in_hand: ["++P"], # Invalid: double prefix
|
338
|
+
games_turn: %w[GAME game]
|
339
|
+
)
|
340
|
+
# => ArgumentError: Invalid format at index: 0, value: '++P'
|
309
341
|
|
310
342
|
# Invalid games turn
|
311
343
|
Feen.dump(
|
@@ -314,6 +346,10 @@ Feen.dump(
|
|
314
346
|
games_turn: %w[BOTH_UPPERCASE ALSO_UPPERCASE] # Both same case
|
315
347
|
)
|
316
348
|
# => ArgumentError: One variant must be uppercase and the other lowercase
|
349
|
+
|
350
|
+
# Invalid pieces in hand format (parsing)
|
351
|
+
Feen.parse("8/8/8/8/8/8/8/8 NoSeparator CHESS/chess")
|
352
|
+
# => ArgumentError: Invalid pieces in hand format: NoSeparator
|
317
353
|
```
|
318
354
|
|
319
355
|
### Safe Operations
|
@@ -325,6 +361,7 @@ position = Feen.safe_parse(user_input)
|
|
325
361
|
|
326
362
|
if position
|
327
363
|
puts "Valid FEEN position!"
|
364
|
+
puts "Pieces in hand: #{position[:pieces_in_hand]}"
|
328
365
|
else
|
329
366
|
puts "Invalid FEEN format"
|
330
367
|
end
|
@@ -333,6 +370,7 @@ end
|
|
333
370
|
## Performance Considerations
|
334
371
|
|
335
372
|
- **Parsing**: Optimized recursive descent parser with O(n) complexity
|
373
|
+
- **Case separation**: Efficient single-pass processing for pieces in hand
|
336
374
|
- **Validation**: Round-trip validation ensures canonical form
|
337
375
|
- **Memory**: Efficient array-based representation for large boards
|
338
376
|
- **Sorting**: In-place canonical sorting for pieces in hand
|
@@ -1,6 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative File.join("pieces_in_hand", "no_pieces")
|
4
3
|
require_relative File.join("pieces_in_hand", "errors")
|
5
4
|
|
6
5
|
module Feen
|
@@ -9,40 +8,105 @@ module Feen
|
|
9
8
|
module PiecesInHand
|
10
9
|
# Converts an array of piece identifiers to a FEEN-formatted pieces in hand string
|
11
10
|
#
|
12
|
-
# @param piece_chars [Array<String>] Array of piece identifiers
|
13
|
-
# @return [String] FEEN-formatted pieces in hand string
|
14
|
-
#
|
15
|
-
#
|
11
|
+
# @param piece_chars [Array<String>] Array of piece identifiers (e.g., ["P", "p", "B", "B", "p", "+P"])
|
12
|
+
# @return [String] FEEN-formatted pieces in hand string following the format:
|
13
|
+
# - Groups pieces by case: uppercase first, then lowercase, separated by "/"
|
14
|
+
# - Within each group, sorts by quantity (descending), then alphabetically (ascending)
|
15
|
+
# - Uses count notation for quantities > 1 (e.g., "3P" instead of "PPP")
|
16
16
|
# @raise [ArgumentError] If any piece identifier is invalid
|
17
17
|
# @example
|
18
|
-
# PiecesInHand.dump("P", "P", "P", "B", "B", "
|
19
|
-
# # => "3P2B
|
18
|
+
# PiecesInHand.dump("P", "P", "P", "B", "B", "p", "p", "p", "p", "p")
|
19
|
+
# # => "3P2B/5p"
|
20
|
+
#
|
21
|
+
# PiecesInHand.dump("p", "P", "B")
|
22
|
+
# # => "BP/p"
|
20
23
|
#
|
21
24
|
# PiecesInHand.dump
|
22
|
-
# # => "
|
25
|
+
# # => "/"
|
23
26
|
def self.dump(*piece_chars)
|
24
|
-
#
|
25
|
-
return NoPieces if piece_chars.empty?
|
26
|
-
|
27
|
-
# Validate each piece character according to the FEEN specification (full PNN support)
|
27
|
+
# Validate each piece character according to the FEEN specification
|
28
28
|
validated_chars = validate_piece_chars(piece_chars)
|
29
29
|
|
30
|
+
# Group pieces by case
|
31
|
+
uppercase_pieces, lowercase_pieces = group_pieces_by_case(validated_chars)
|
32
|
+
|
33
|
+
# Format each group according to FEEN specification
|
34
|
+
uppercase_formatted = format_pieces_group(uppercase_pieces)
|
35
|
+
lowercase_formatted = format_pieces_group(lowercase_pieces)
|
36
|
+
|
37
|
+
# Combine with separator
|
38
|
+
"#{uppercase_formatted}/#{lowercase_formatted}"
|
39
|
+
end
|
40
|
+
|
41
|
+
# Groups pieces by case (uppercase vs lowercase)
|
42
|
+
#
|
43
|
+
# @param pieces [Array<String>] Array of validated piece identifiers
|
44
|
+
# @return [Array<Array<String>, Array<String>>] Two arrays: [uppercase_pieces, lowercase_pieces]
|
45
|
+
private_class_method def self.group_pieces_by_case(pieces)
|
46
|
+
uppercase_pieces = pieces.select { |piece| piece_is_uppercase?(piece) }
|
47
|
+
lowercase_pieces = pieces.select { |piece| piece_is_lowercase?(piece) }
|
48
|
+
|
49
|
+
[uppercase_pieces, lowercase_pieces]
|
50
|
+
end
|
51
|
+
|
52
|
+
# Determines if a piece belongs to the uppercase group
|
53
|
+
# A piece is considered uppercase if its main letter is uppercase (ignoring prefixes/suffixes)
|
54
|
+
#
|
55
|
+
# @param piece [String] Piece identifier (e.g., "P", "+P", "P'", "+P'")
|
56
|
+
# @return [Boolean] True if the piece's main letter is uppercase
|
57
|
+
private_class_method def self.piece_is_uppercase?(piece)
|
58
|
+
# Extract the main letter (skip prefixes like + or -)
|
59
|
+
main_letter = piece.gsub(/\A[+-]/, "").gsub(/'\z/, "")
|
60
|
+
main_letter.match?(/[A-Z]/)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Determines if a piece belongs to the lowercase group
|
64
|
+
# A piece is considered lowercase if its main letter is lowercase (ignoring prefixes/suffixes)
|
65
|
+
#
|
66
|
+
# @param piece [String] Piece identifier (e.g., "p", "+p", "p'", "+p'")
|
67
|
+
# @return [Boolean] True if the piece's main letter is lowercase
|
68
|
+
private_class_method def self.piece_is_lowercase?(piece)
|
69
|
+
# Extract the main letter (skip prefixes like + or -)
|
70
|
+
main_letter = piece.gsub(/\A[+-]/, "").gsub(/'\z/, "")
|
71
|
+
main_letter.match?(/[a-z]/)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Formats a group of pieces according to FEEN specification
|
75
|
+
#
|
76
|
+
# @param pieces [Array<String>] Array of pieces from the same case group
|
77
|
+
# @return [String] Formatted string for this group (e.g., "3P2B", "5pq")
|
78
|
+
private_class_method def self.format_pieces_group(pieces)
|
79
|
+
return "" if pieces.empty?
|
80
|
+
|
30
81
|
# Count occurrences of each piece type
|
31
|
-
piece_counts =
|
82
|
+
piece_counts = pieces.each_with_object(Hash.new(0)) do |piece, counts|
|
83
|
+
counts[piece] += 1
|
84
|
+
end
|
32
85
|
|
33
|
-
# Sort
|
34
|
-
# 1. By quantity (descending)
|
35
|
-
# 2. By complete PNN representation (alphabetically ascending)
|
86
|
+
# Sort by count (descending) then alphabetically (ascending)
|
36
87
|
sorted_pieces = piece_counts.sort do |a, b|
|
37
|
-
|
38
|
-
|
88
|
+
piece_a, count_a = a
|
89
|
+
piece_b, count_b = b
|
90
|
+
|
91
|
+
# Primary sort: by count (descending)
|
92
|
+
count_comparison = count_b <=> count_a
|
93
|
+
next count_comparison unless count_comparison.zero?
|
94
|
+
|
95
|
+
# Secondary sort: by piece name (ascending)
|
96
|
+
piece_a <=> piece_b
|
39
97
|
end
|
40
98
|
|
41
|
-
# Format
|
42
|
-
|
99
|
+
# Format each piece with its count
|
100
|
+
sorted_pieces.map do |piece, count|
|
101
|
+
if count == 1
|
102
|
+
piece
|
103
|
+
else
|
104
|
+
"#{count}#{piece}"
|
105
|
+
end
|
106
|
+
end.join
|
43
107
|
end
|
44
108
|
|
45
|
-
# Validates all piece characters according to FEEN specification
|
109
|
+
# Validates all piece characters according to FEEN specification
|
46
110
|
#
|
47
111
|
# @param piece_chars [Array<Object>] Array of piece character candidates
|
48
112
|
# @return [Array<String>] Array of validated piece characters
|
@@ -53,7 +117,11 @@ module Feen
|
|
53
117
|
end
|
54
118
|
end
|
55
119
|
|
56
|
-
# Validates a single piece character according to FEEN specification
|
120
|
+
# Validates a single piece character according to FEEN specification
|
121
|
+
# Supports full PNN notation: [prefix]letter[suffix] where:
|
122
|
+
# - prefix can be "+" or "-"
|
123
|
+
# - letter must be a-z or A-Z
|
124
|
+
# - suffix can be "'"
|
57
125
|
#
|
58
126
|
# @param char [Object] Piece character candidate
|
59
127
|
# @param index [Integer] Index of the character in the original array
|
@@ -69,12 +137,9 @@ module Feen
|
|
69
137
|
)
|
70
138
|
end
|
71
139
|
|
72
|
-
# Validate format
|
73
|
-
#
|
74
|
-
|
75
|
-
# <suffix> ::= "'"
|
76
|
-
# <letter> ::= [a-zA-Z]
|
77
|
-
unless char.match?(/\A[-+]?[a-zA-Z]'?\z/)
|
140
|
+
# Validate format using PNN pattern: [prefix]letter[suffix]
|
141
|
+
# where prefix is +/-, letter is a-zA-Z, suffix is '
|
142
|
+
unless char.match?(/\A[+-]?[a-zA-Z]'?\z/)
|
78
143
|
raise ::ArgumentError, format(
|
79
144
|
Errors[:invalid_format],
|
80
145
|
index: index,
|
@@ -84,20 +149,6 @@ module Feen
|
|
84
149
|
|
85
150
|
char
|
86
151
|
end
|
87
|
-
|
88
|
-
# Formats the pieces sequence with proper count prefixes according to FEEN specification
|
89
|
-
#
|
90
|
-
# @param sorted_pieces [Array<Array>] Array of [piece, count] pairs sorted according to FEEN rules
|
91
|
-
# @return [String] Formatted pieces sequence
|
92
|
-
private_class_method def self.format_pieces_sequence(sorted_pieces)
|
93
|
-
sorted_pieces.map do |piece, count|
|
94
|
-
if count == 1
|
95
|
-
piece
|
96
|
-
else
|
97
|
-
"#{count}#{piece}"
|
98
|
-
end
|
99
|
-
end.join
|
100
|
-
end
|
101
152
|
end
|
102
153
|
end
|
103
154
|
end
|
@@ -10,7 +10,10 @@ module Feen
|
|
10
10
|
invalid_format: "Invalid pieces in hand format: %s",
|
11
11
|
invalid_pnn_piece: "Invalid PNN piece format: '%s'. Expected format: [prefix]letter[suffix] where prefix is + or -, suffix is ', and letter is a-z or A-Z",
|
12
12
|
invalid_count: "Invalid count format: '%s'. Count cannot be '0' or '1', use the piece without count instead",
|
13
|
-
canonical_order_violation: "Pieces in hand must be in canonical order (by quantity descending, then alphabetically). Got: '%<actual>s', expected: '%<expected>s'"
|
13
|
+
canonical_order_violation: "Pieces in hand must be in canonical order (by quantity descending, then alphabetically). Got: '%<actual>s', expected: '%<expected>s'",
|
14
|
+
missing_separator: "Pieces in hand format must contain exactly one '/' separator. Got: %s",
|
15
|
+
wrong_case_in_section: "Piece '%<piece>s' has wrong case for %<section>s section",
|
16
|
+
invalid_section_format: "Invalid format in %<section>s section: %<content>s"
|
14
17
|
}.freeze
|
15
18
|
end
|
16
19
|
end
|
@@ -23,24 +23,66 @@ module Feen
|
|
23
23
|
# Note: We need to handle the full PNN piece including modifiers
|
24
24
|
PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|\d{2,}))?([-+]?[a-zA-Z]'?)/
|
25
25
|
|
26
|
-
#
|
26
|
+
# Pattern for uppercase pieces only (used for uppercase section validation)
|
27
|
+
UPPERCASE_PIECE_PATTERN = /[-+]?[A-Z]'?/
|
28
|
+
|
29
|
+
# Pattern for lowercase pieces only (used for lowercase section validation)
|
30
|
+
LOWERCASE_PIECE_PATTERN = /[-+]?[a-z]'?/
|
31
|
+
|
32
|
+
# Pattern for uppercase section: sequence of uppercase pieces with optional counts
|
33
|
+
# Format: [count]piece[count]piece... where pieces are uppercase
|
34
|
+
UPPERCASE_SECTION_PATTERN = /\A
|
35
|
+
(?:
|
36
|
+
(?:[2-9]|\d{2,})? # Optional count (2-9 or 10+)
|
37
|
+
[-+]? # Optional single prefix (+ or -)
|
38
|
+
[A-Z] # Required uppercase letter
|
39
|
+
'? # Optional single suffix (')
|
40
|
+
)+ # One or more uppercase pieces
|
41
|
+
\z/x
|
42
|
+
|
43
|
+
# Pattern for lowercase section: sequence of lowercase pieces with optional counts
|
44
|
+
# Format: [count]piece[count]piece... where pieces are lowercase
|
45
|
+
LOWERCASE_SECTION_PATTERN = /\A
|
46
|
+
(?:
|
47
|
+
(?:[2-9]|\d{2,})? # Optional count (2-9 or 10+)
|
48
|
+
[-+]? # Optional single prefix (+ or -)
|
49
|
+
[a-z] # Required lowercase letter
|
50
|
+
'? # Optional single suffix (')
|
51
|
+
)+ # One or more lowercase pieces
|
52
|
+
\z/x
|
53
|
+
|
54
|
+
# Complete validation pattern for pieces in hand string with case separation
|
27
55
|
# Based on the FEEN BNF specification with PNN support
|
28
|
-
#
|
29
|
-
#
|
30
|
-
VALID_FORMAT_PATTERN =
|
56
|
+
# Format: "UPPERCASE_PIECES/LOWERCASE_PIECES"
|
57
|
+
# Either section can be empty, but the "/" separator is mandatory
|
58
|
+
VALID_FORMAT_PATTERN = %r{\A
|
31
59
|
(?:
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
[
|
36
|
-
|
37
|
-
|
38
|
-
)+ # One or more pieces
|
60
|
+
(?: # Uppercase section (optional)
|
61
|
+
(?:[2-9]|\d{2,})? # Optional count (2-9 or 10+)
|
62
|
+
[-+]? # Optional single prefix (+ or -)
|
63
|
+
[A-Z] # Required uppercase letter
|
64
|
+
'? # Optional single suffix (')
|
65
|
+
)* # Zero or more uppercase pieces
|
39
66
|
)
|
40
|
-
|
67
|
+
/ # Mandatory separator
|
68
|
+
(?:
|
69
|
+
(?: # Lowercase section (optional)
|
70
|
+
(?:[2-9]|\d{2,})? # Optional count (2-9 or 10+)
|
71
|
+
[-+]? # Optional single prefix (+ or -)
|
72
|
+
[a-z] # Required lowercase letter
|
73
|
+
'? # Optional single suffix (')
|
74
|
+
)* # Zero or more lowercase pieces
|
75
|
+
)
|
76
|
+
\z}x
|
41
77
|
|
42
78
|
# Pattern for extracting all pieces globally (used for comprehensive validation)
|
43
79
|
GLOBAL_PIECE_EXTRACTION_PATTERN = /(?:([2-9]|\d{2,}))?([-+]?[a-zA-Z]'?)/
|
80
|
+
|
81
|
+
# Pattern specifically for uppercase pieces with counts (for section parsing)
|
82
|
+
UPPERCASE_PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|\d{2,}))?([-+]?[A-Z]'?)/
|
83
|
+
|
84
|
+
# Pattern specifically for lowercase pieces with counts (for section parsing)
|
85
|
+
LOWERCASE_PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|\d{2,}))?([-+]?[a-z]'?)/
|
44
86
|
end
|
45
87
|
end
|
46
88
|
end
|
@@ -1,57 +1,54 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative File.join("pieces_in_hand", "errors")
|
4
|
-
require_relative File.join("pieces_in_hand", "no_pieces")
|
5
4
|
require_relative File.join("pieces_in_hand", "pnn_patterns")
|
6
|
-
require_relative File.join("pieces_in_hand", "canonical_sorter")
|
7
5
|
|
8
6
|
module Feen
|
9
7
|
module Parser
|
10
8
|
# Handles parsing of the pieces in hand section of a FEEN string.
|
11
9
|
# Pieces in hand represent pieces available for dropping onto the board.
|
12
10
|
# This implementation supports full PNN notation including prefixes and suffixes.
|
11
|
+
# Format: "UPPERCASE_PIECES/LOWERCASE_PIECES"
|
13
12
|
module PiecesInHand
|
14
13
|
# Parses the pieces in hand section of a FEEN string.
|
15
14
|
#
|
16
|
-
# @param pieces_in_hand_str [String] FEEN pieces in hand string
|
15
|
+
# @param pieces_in_hand_str [String] FEEN pieces in hand string in format "UPPERCASE/lowercase"
|
17
16
|
# @return [Array<String>] Array of piece identifiers in full PNN format,
|
18
|
-
# expanded based on their counts and sorted
|
19
|
-
# 1. By quantity (descending)
|
20
|
-
# 2. By complete PNN representation (alphabetically ascending)
|
17
|
+
# expanded based on their counts and sorted alphabetically.
|
21
18
|
# Empty array if no pieces are in hand.
|
22
19
|
# @raise [ArgumentError] If the input string is invalid
|
23
20
|
#
|
24
21
|
# @example Parse no pieces in hand
|
25
|
-
# PiecesInHand.parse("
|
22
|
+
# PiecesInHand.parse("/")
|
26
23
|
# # => []
|
27
24
|
#
|
28
|
-
# @example Parse pieces with
|
29
|
-
# PiecesInHand.parse("
|
30
|
-
# # => ["
|
25
|
+
# @example Parse pieces with case separation
|
26
|
+
# PiecesInHand.parse("3P2B/p")
|
27
|
+
# # => ["B", "B", "P", "P", "P", "p"]
|
31
28
|
#
|
32
29
|
# @example Parse complex pieces with counts and modifiers
|
33
|
-
# PiecesInHand.parse("
|
34
|
-
# # => ["
|
35
|
-
# # "
|
36
|
-
# # "
|
37
|
-
# # "p'", "p'",
|
38
|
-
# # "+P", "-p", "B", "R", "b", "q"]
|
30
|
+
# PiecesInHand.parse("10P5K3B/2p'+p-pbq")
|
31
|
+
# # => ["+p", "-p", "B", "B", "B", "K", "K", "K", "K", "K",
|
32
|
+
# # "P", "P", "P", "P", "P", "P", "P", "P", "P", "P",
|
33
|
+
# # "b", "p'", "p'", "q"]
|
39
34
|
def self.parse(pieces_in_hand_str)
|
40
35
|
# Validate input
|
41
36
|
validate_input_type(pieces_in_hand_str)
|
42
37
|
validate_format(pieces_in_hand_str)
|
43
38
|
|
44
39
|
# Handle the no-pieces case early
|
45
|
-
return [] if pieces_in_hand_str ==
|
40
|
+
return [] if pieces_in_hand_str == "/"
|
46
41
|
|
47
|
-
#
|
48
|
-
|
42
|
+
# Split by the separator to get uppercase and lowercase sections
|
43
|
+
uppercase_section, lowercase_section = pieces_in_hand_str.split("/", 2)
|
49
44
|
|
50
|
-
#
|
51
|
-
|
45
|
+
# Parse each section separately
|
46
|
+
uppercase_pieces = parse_pieces_section(uppercase_section || "", :uppercase)
|
47
|
+
lowercase_pieces = parse_pieces_section(lowercase_section || "", :lowercase)
|
52
48
|
|
53
|
-
#
|
54
|
-
|
49
|
+
# Combine all pieces and sort them alphabetically
|
50
|
+
all_pieces = uppercase_pieces + lowercase_pieces
|
51
|
+
all_pieces.sort
|
55
52
|
end
|
56
53
|
|
57
54
|
# Validates that the input is a non-empty string.
|
@@ -65,37 +62,63 @@ module Feen
|
|
65
62
|
end
|
66
63
|
|
67
64
|
# Validates that the input string matches the expected format according to FEEN specification.
|
68
|
-
#
|
65
|
+
# Format must be: "UPPERCASE_PIECES/LOWERCASE_PIECES"
|
69
66
|
#
|
70
67
|
# @param str [String] Input string to validate
|
71
68
|
# @raise [ArgumentError] If format is invalid
|
72
69
|
# @return [void]
|
73
70
|
private_class_method def self.validate_format(str)
|
74
|
-
|
71
|
+
# Must contain exactly one "/" separator
|
72
|
+
parts_count = str.count("/")
|
73
|
+
raise ::ArgumentError, format(Errors[:invalid_format], str) unless parts_count == 1
|
74
|
+
|
75
|
+
uppercase_section, lowercase_section = str.split("/", 2)
|
75
76
|
|
76
|
-
#
|
77
|
-
|
77
|
+
# Each section can be empty, but if not empty, must follow PNN patterns
|
78
|
+
validate_section_format(uppercase_section, :uppercase) unless uppercase_section.empty?
|
79
|
+
validate_section_format(lowercase_section, :lowercase) unless lowercase_section.empty?
|
80
|
+
end
|
78
81
|
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
+
# Validates the format of a specific section (uppercase or lowercase)
|
83
|
+
#
|
84
|
+
# @param section [String] The section to validate
|
85
|
+
# @param case_type [Symbol] Either :uppercase or :lowercase
|
86
|
+
# @raise [ArgumentError] If the section format is invalid
|
87
|
+
# @return [void]
|
88
|
+
private_class_method def self.validate_section_format(section, case_type)
|
89
|
+
return if section.empty?
|
90
|
+
|
91
|
+
# Build the appropriate pattern based on case type
|
92
|
+
case_pattern = case case_type
|
93
|
+
when :uppercase
|
94
|
+
PnnPatterns::UPPERCASE_SECTION_PATTERN
|
95
|
+
when :lowercase
|
96
|
+
PnnPatterns::LOWERCASE_SECTION_PATTERN
|
97
|
+
else
|
98
|
+
raise ArgumentError, "Invalid case type: #{case_type}"
|
99
|
+
end
|
100
|
+
|
101
|
+
# Validate overall section pattern
|
102
|
+
raise ::ArgumentError, format(Errors[:invalid_format], section) unless section.match?(case_pattern)
|
103
|
+
|
104
|
+
# Validate individual pieces in the section
|
105
|
+
validate_individual_pieces_in_section(section, case_type)
|
82
106
|
end
|
83
107
|
|
84
|
-
# Validates each individual piece in
|
108
|
+
# Validates each individual piece in a section for PNN compliance
|
85
109
|
#
|
86
|
-
# @param
|
110
|
+
# @param section [String] FEEN pieces section string
|
111
|
+
# @param case_type [Symbol] Either :uppercase or :lowercase
|
87
112
|
# @raise [ArgumentError] If any piece is invalid PNN format
|
88
113
|
# @return [void]
|
89
|
-
private_class_method def self.
|
90
|
-
original_string = str
|
114
|
+
private_class_method def self.validate_individual_pieces_in_section(section, case_type)
|
91
115
|
position = 0
|
92
116
|
|
93
|
-
while position <
|
94
|
-
match =
|
117
|
+
while position < section.length
|
118
|
+
match = section[position..].match(PnnPatterns::PIECE_WITH_COUNT_PATTERN)
|
95
119
|
|
96
120
|
unless match
|
97
|
-
|
98
|
-
remaining = str[position..]
|
121
|
+
remaining = section[position..]
|
99
122
|
raise ::ArgumentError, format(Errors[:invalid_format], remaining)
|
100
123
|
end
|
101
124
|
|
@@ -117,69 +140,52 @@ module Feen
|
|
117
140
|
raise ::ArgumentError, format(Errors[:invalid_count], count_str)
|
118
141
|
end
|
119
142
|
|
120
|
-
|
121
|
-
|
143
|
+
# Validate that the piece matches the expected case
|
144
|
+
piece_case = piece_is_uppercase?(piece) ? :uppercase : :lowercase
|
145
|
+
unless piece_case == case_type
|
146
|
+
case_name = case_type == :uppercase ? "uppercase" : "lowercase"
|
147
|
+
raise ::ArgumentError, "#{case_name.capitalize} section contains #{piece_case} piece: '#{piece}'"
|
148
|
+
end
|
122
149
|
|
123
|
-
|
124
|
-
# by re-extracting all pieces and comparing with original
|
125
|
-
reconstructed_pieces = extract_pieces_with_counts(original_string)
|
126
|
-
reconstructed = reconstructed_pieces.map do |item|
|
127
|
-
count = item[:count]
|
128
|
-
piece = item[:piece]
|
129
|
-
count == 1 ? piece : "#{count}#{piece}"
|
130
|
-
end.join
|
131
|
-
|
132
|
-
# If reconstruction doesn't match original, there's an invalid format
|
133
|
-
return if reconstructed == original_string
|
134
|
-
# Find the first discrepancy to provide better error message
|
135
|
-
# This will catch cases like "++P" where we extract "+P" but original has extra "+"
|
136
|
-
unless original_string.length > reconstructed.length
|
137
|
-
raise ::ArgumentError, format(Errors[:invalid_format], original_string)
|
150
|
+
position += match[0].length
|
138
151
|
end
|
152
|
+
end
|
139
153
|
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
154
|
+
# Determines if a piece belongs to the uppercase group
|
155
|
+
#
|
156
|
+
# @param piece [String] Piece identifier (e.g., "P", "+P", "P'", "+P'")
|
157
|
+
# @return [Boolean] True if the piece's main letter is uppercase
|
158
|
+
private_class_method def self.piece_is_uppercase?(piece)
|
159
|
+
# Extract the main letter (skip prefixes like + or -)
|
160
|
+
main_letter = piece.gsub(/\A[+-]/, "").gsub(/'\z/, "")
|
161
|
+
main_letter.match?(/[A-Z]/)
|
145
162
|
end
|
146
163
|
|
147
|
-
#
|
164
|
+
# Parses a specific section (uppercase or lowercase) and returns expanded pieces
|
148
165
|
#
|
149
|
-
# @param
|
150
|
-
# @param
|
151
|
-
# @return [String]
|
152
|
-
private_class_method def self.
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
diff_pos = 0
|
158
|
-
diff_pos += 1 while diff_pos < min_length && original[diff_pos] == reconstructed[diff_pos]
|
159
|
-
|
160
|
-
# If difference is at start, likely extra prefix
|
161
|
-
# Look for a sequence that starts with invalid pattern like "++"
|
162
|
-
if (diff_pos == 0) && original.match?(/\A\+\+/)
|
163
|
-
return "++P" # Common case
|
164
|
-
end
|
166
|
+
# @param section [String] The section string to parse
|
167
|
+
# @param case_type [Symbol] Either :uppercase or :lowercase (for validation)
|
168
|
+
# @return [Array<String>] Array of expanded pieces from this section
|
169
|
+
private_class_method def self.parse_pieces_section(section, _case_type)
|
170
|
+
return [] if section.empty?
|
171
|
+
|
172
|
+
# Extract pieces with their counts
|
173
|
+
pieces_with_counts = extract_pieces_with_counts_from_section(section)
|
165
174
|
|
166
|
-
#
|
167
|
-
|
168
|
-
end_pos = [original.length, diff_pos + 4].min
|
169
|
-
original[start_pos...end_pos]
|
175
|
+
# Expand the pieces into an array (no canonical order validation needed)
|
176
|
+
expand_pieces(pieces_with_counts)
|
170
177
|
end
|
171
178
|
|
172
|
-
# Extracts pieces with their counts from
|
173
|
-
# Supports full PNN notation including prefixes and suffixes.
|
179
|
+
# Extracts pieces with their counts from a section string.
|
174
180
|
#
|
175
|
-
# @param
|
181
|
+
# @param section [String] FEEN pieces section string
|
176
182
|
# @return [Array<Hash>] Array of hashes with :piece and :count keys
|
177
|
-
private_class_method def self.
|
183
|
+
private_class_method def self.extract_pieces_with_counts_from_section(section)
|
178
184
|
result = []
|
179
185
|
position = 0
|
180
186
|
|
181
|
-
while position <
|
182
|
-
match =
|
187
|
+
while position < section.length
|
188
|
+
match = section[position..].match(PnnPatterns::PIECE_WITH_COUNT_PATTERN)
|
183
189
|
break unless match
|
184
190
|
|
185
191
|
count_str, piece = match.captures
|
@@ -195,24 +201,10 @@ module Feen
|
|
195
201
|
result
|
196
202
|
end
|
197
203
|
|
198
|
-
# Validates that pieces are in canonical order according to FEEN specification:
|
199
|
-
# 1. By quantity (descending)
|
200
|
-
# 2. By complete PNN representation (alphabetically ascending)
|
201
|
-
#
|
202
|
-
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
203
|
-
# @raise [ArgumentError] If pieces are not in canonical order
|
204
|
-
# @return [void]
|
205
|
-
private_class_method def self.validate_canonical_order(pieces_with_counts)
|
206
|
-
return if pieces_with_counts.size <= 1
|
207
|
-
|
208
|
-
CanonicalSorter.validate_order(pieces_with_counts)
|
209
|
-
end
|
210
|
-
|
211
204
|
# Expands the pieces based on their counts into an array.
|
212
|
-
# Maintains the canonical ordering from the input.
|
213
205
|
#
|
214
206
|
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
215
|
-
# @return [Array<String>] Array of expanded pieces
|
207
|
+
# @return [Array<String>] Array of expanded pieces
|
216
208
|
private_class_method def self.expand_pieces(pieces_with_counts)
|
217
209
|
pieces_with_counts.flat_map do |item|
|
218
210
|
Array.new(item[:count], item[:piece])
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: feen
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 5.0.0.
|
4
|
+
version: 5.0.0.beta7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Cyril Kato
|
@@ -26,19 +26,14 @@ files:
|
|
26
26
|
- lib/feen/dumper/piece_placement.rb
|
27
27
|
- lib/feen/dumper/pieces_in_hand.rb
|
28
28
|
- lib/feen/dumper/pieces_in_hand/errors.rb
|
29
|
-
- lib/feen/dumper/pieces_in_hand/no_pieces.rb
|
30
29
|
- lib/feen/parser.rb
|
31
30
|
- lib/feen/parser/games_turn.rb
|
32
31
|
- lib/feen/parser/games_turn/errors.rb
|
33
32
|
- lib/feen/parser/games_turn/valid_games_turn_pattern.rb
|
34
33
|
- lib/feen/parser/piece_placement.rb
|
35
34
|
- lib/feen/parser/pieces_in_hand.rb
|
36
|
-
- lib/feen/parser/pieces_in_hand/canonical_sorter.rb
|
37
35
|
- lib/feen/parser/pieces_in_hand/errors.rb
|
38
|
-
- lib/feen/parser/pieces_in_hand/no_pieces.rb
|
39
|
-
- lib/feen/parser/pieces_in_hand/piece_count_pattern.rb
|
40
36
|
- lib/feen/parser/pieces_in_hand/pnn_patterns.rb
|
41
|
-
- lib/feen/parser/pieces_in_hand/valid_format_pattern.rb
|
42
37
|
homepage: https://github.com/sashite/feen.rb
|
43
38
|
licenses:
|
44
39
|
- MIT
|
@@ -1,70 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Feen
|
4
|
-
module Parser
|
5
|
-
module PiecesInHand
|
6
|
-
# Handles canonical ordering validation for pieces in hand according to FEEN specification
|
7
|
-
module CanonicalSorter
|
8
|
-
# Validates that pieces are in canonical order according to FEEN specification:
|
9
|
-
# 1. By quantity (descending)
|
10
|
-
# 2. By complete PNN representation (alphabetically ascending)
|
11
|
-
#
|
12
|
-
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
13
|
-
# @raise [ArgumentError] If pieces are not in canonical order
|
14
|
-
# @return [void]
|
15
|
-
def self.validate_order(pieces_with_counts)
|
16
|
-
return if pieces_with_counts.size <= 1
|
17
|
-
|
18
|
-
# Create the expected canonical order
|
19
|
-
canonical_order = sort_canonically(pieces_with_counts)
|
20
|
-
|
21
|
-
# Compare with actual order
|
22
|
-
pieces_with_counts.each_with_index do |piece_data, index|
|
23
|
-
canonical_piece = canonical_order[index]
|
24
|
-
|
25
|
-
next if piece_data[:piece] == canonical_piece[:piece] &&
|
26
|
-
piece_data[:count] == canonical_piece[:count]
|
27
|
-
|
28
|
-
raise ::ArgumentError, format(
|
29
|
-
Errors[:canonical_order_violation],
|
30
|
-
actual: format_pieces_sequence(pieces_with_counts),
|
31
|
-
expected: format_pieces_sequence(canonical_order)
|
32
|
-
)
|
33
|
-
end
|
34
|
-
end
|
35
|
-
|
36
|
-
# Sorts pieces according to canonical FEEN order
|
37
|
-
#
|
38
|
-
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
39
|
-
# @return [Array<Hash>] Canonically sorted array
|
40
|
-
def self.sort_canonically(pieces_with_counts)
|
41
|
-
pieces_with_counts.sort do |a, b|
|
42
|
-
# Primary sort: by quantity (descending)
|
43
|
-
count_comparison = b[:count] <=> a[:count]
|
44
|
-
next count_comparison unless count_comparison.zero?
|
45
|
-
|
46
|
-
# Secondary sort: by complete PNN representation (alphabetically ascending)
|
47
|
-
a[:piece] <=> b[:piece]
|
48
|
-
end
|
49
|
-
end
|
50
|
-
|
51
|
-
# Formats a pieces sequence for error messages
|
52
|
-
#
|
53
|
-
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
54
|
-
# @return [String] Formatted string representation
|
55
|
-
private_class_method def self.format_pieces_sequence(pieces_with_counts)
|
56
|
-
pieces_with_counts.map do |item|
|
57
|
-
count = item[:count]
|
58
|
-
piece = item[:piece]
|
59
|
-
|
60
|
-
if count == 1
|
61
|
-
piece
|
62
|
-
else
|
63
|
-
"#{count}#{piece}"
|
64
|
-
end
|
65
|
-
end.join
|
66
|
-
end
|
67
|
-
end
|
68
|
-
end
|
69
|
-
end
|
70
|
-
end
|
@@ -1,13 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Feen
|
4
|
-
module Parser
|
5
|
-
module PiecesInHand
|
6
|
-
# Regex to extract piece counts from pieces in hand string
|
7
|
-
# Matches either:
|
8
|
-
# - A single piece character with no count (e.g., "P")
|
9
|
-
# - A count followed by a piece character (e.g., "5P")
|
10
|
-
PieceCountPattern = /(?:([2-9]|\d{2,}))?([A-Za-z])/
|
11
|
-
end
|
12
|
-
end
|
13
|
-
end
|
@@ -1,29 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Feen
|
4
|
-
module Parser
|
5
|
-
module PiecesInHand
|
6
|
-
# Valid pattern for pieces in hand based on BNF specification.
|
7
|
-
#
|
8
|
-
# The FEEN format specifies these rules for numeric prefixes:
|
9
|
-
# - Cannot start with "0"
|
10
|
-
# - Cannot be exactly "1" (use the letter without prefix instead)
|
11
|
-
# - Can be 2-9 or any number with 2+ digits (10, 11, etc.)
|
12
|
-
#
|
13
|
-
# The pattern matches either:
|
14
|
-
# - A single digit from 2-9
|
15
|
-
# - OR any number with two or more digits (10, 11, 27, 103, etc.)
|
16
|
-
#
|
17
|
-
# @return [Regexp] Regular expression for validating pieces in hand format
|
18
|
-
ValidFormatPattern = /\A
|
19
|
-
(?:
|
20
|
-
-| # No pieces in hand
|
21
|
-
(?: # Or sequence of pieces
|
22
|
-
(?:(?:[2-9]|\d{2,})?[A-Z])* # Uppercase pieces (optional)
|
23
|
-
(?:(?:[2-9]|\d{2,})?[a-z])* # Lowercase pieces (optional)
|
24
|
-
)
|
25
|
-
)
|
26
|
-
\z/x
|
27
|
-
end
|
28
|
-
end
|
29
|
-
end
|