feen 5.0.0.beta5 → 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 +209 -53
- data/lib/feen/dumper/pieces_in_hand/errors.rb +1 -1
- data/lib/feen/dumper/pieces_in_hand.rb +95 -13
- data/lib/feen/dumper.rb +3 -3
- data/lib/feen/parser/pieces_in_hand/errors.rb +9 -4
- data/lib/feen/parser/pieces_in_hand/pnn_patterns.rb +89 -0
- data/lib/feen/parser/pieces_in_hand.rb +136 -41
- data/lib/feen/parser.rb +4 -4
- data/lib/feen.rb +8 -8
- metadata +3 -6
- data/lib/feen/dumper/pieces_in_hand/no_pieces.rb +0 -10
- 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
@@ -14,16 +14,30 @@ FEEN (Forsyth–Edwards Enhanced Notation) is a compact, canonical, and rule-agn
|
|
14
14
|
This gem implements the [FEEN Specification v1.0.0](https://sashite.dev/documents/feen/1.0.0/), providing a Ruby interface for:
|
15
15
|
|
16
16
|
- Representing positions from various games without knowledge of specific rules
|
17
|
-
- Supporting boards of arbitrary dimensions
|
18
|
-
- Encoding pieces in hand (
|
17
|
+
- Supporting boards of arbitrary dimensions (2D, 3D, and beyond)
|
18
|
+
- Encoding pieces in hand with full PNN (Piece Name Notation) support
|
19
19
|
- Facilitating serialization and deserialization of positions
|
20
20
|
- Ensuring canonical representation for consistent data handling
|
21
21
|
|
22
|
+
## FEEN Format
|
23
|
+
|
24
|
+
A FEEN record consists of three space-separated fields:
|
25
|
+
|
26
|
+
```
|
27
|
+
<PIECE-PLACEMENT> <PIECES-IN-HAND> <GAMES-TURN>
|
28
|
+
```
|
29
|
+
|
30
|
+
### Field Details
|
31
|
+
|
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, formatted as `"UPPERCASE/lowercase"` and sorted canonically within each section
|
34
|
+
3. **Games Turn**: Game identifiers and active player indication
|
35
|
+
|
22
36
|
## Installation
|
23
37
|
|
24
38
|
```ruby
|
25
39
|
# In your Gemfile
|
26
|
-
gem "feen", ">= 5.0.0.
|
40
|
+
gem "feen", ">= 5.0.0.beta7"
|
27
41
|
```
|
28
42
|
|
29
43
|
Or install manually:
|
@@ -32,14 +46,6 @@ Or install manually:
|
|
32
46
|
gem install feen --pre
|
33
47
|
```
|
34
48
|
|
35
|
-
## FEEN Format
|
36
|
-
|
37
|
-
A FEEN record consists of three space-separated fields:
|
38
|
-
|
39
|
-
```
|
40
|
-
<PIECE-PLACEMENT> <PIECES-IN-HAND> <GAMES-TURN>
|
41
|
-
```
|
42
|
-
|
43
49
|
## Basic Usage
|
44
50
|
|
45
51
|
### Parsing FEEN Strings
|
@@ -49,20 +55,20 @@ Convert a FEEN string into a structured Ruby object:
|
|
49
55
|
```ruby
|
50
56
|
require "feen"
|
51
57
|
|
52
|
-
feen_string = "
|
58
|
+
feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
53
59
|
position = Feen.parse(feen_string)
|
54
60
|
|
55
61
|
# Result is a hash:
|
56
62
|
# {
|
57
63
|
# piece_placement: [
|
58
|
-
# ["r
|
64
|
+
# ["r", "n", "b", "q", "k", "b", "n", "r"],
|
59
65
|
# ["p", "p", "p", "p", "p", "p", "p", "p"],
|
60
66
|
# ["", "", "", "", "", "", "", ""],
|
61
67
|
# ["", "", "", "", "", "", "", ""],
|
62
68
|
# ["", "", "", "", "", "", "", ""],
|
63
69
|
# ["", "", "", "", "", "", "", ""],
|
64
70
|
# ["P", "P", "P", "P", "P", "P", "P", "P"],
|
65
|
-
# ["R
|
71
|
+
# ["R", "N", "B", "Q", "K", "B", "N", "R"]
|
66
72
|
# ],
|
67
73
|
# pieces_in_hand: [],
|
68
74
|
# games_turn: ["CHESS", "chess"]
|
@@ -77,7 +83,7 @@ Parse a FEEN string without raising exceptions:
|
|
77
83
|
require "feen"
|
78
84
|
|
79
85
|
# Valid FEEN string
|
80
|
-
result = Feen.safe_parse("
|
86
|
+
result = Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess")
|
81
87
|
# => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
|
82
88
|
|
83
89
|
# Invalid FEEN string
|
@@ -94,14 +100,14 @@ require "feen"
|
|
94
100
|
|
95
101
|
# Representation of a chess board in initial position
|
96
102
|
piece_placement = [
|
97
|
-
["r
|
103
|
+
["r", "n", "b", "q", "k", "b", "n", "r"],
|
98
104
|
["p", "p", "p", "p", "p", "p", "p", "p"],
|
99
105
|
["", "", "", "", "", "", "", ""],
|
100
106
|
["", "", "", "", "", "", "", ""],
|
101
107
|
["", "", "", "", "", "", "", ""],
|
102
108
|
["", "", "", "", "", "", "", ""],
|
103
109
|
["P", "P", "P", "P", "P", "P", "P", "P"],
|
104
|
-
["R
|
110
|
+
["R", "N", "B", "Q", "K", "B", "N", "R"]
|
105
111
|
]
|
106
112
|
|
107
113
|
result = Feen.dump(
|
@@ -109,7 +115,7 @@ result = Feen.dump(
|
|
109
115
|
games_turn: %w[CHESS chess],
|
110
116
|
pieces_in_hand: []
|
111
117
|
)
|
112
|
-
# => "
|
118
|
+
# => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
113
119
|
```
|
114
120
|
|
115
121
|
### Validation
|
@@ -120,16 +126,16 @@ Check if a string is valid FEEN notation and in canonical form:
|
|
120
126
|
require "feen"
|
121
127
|
|
122
128
|
# Canonical form
|
123
|
-
Feen.valid?("
|
129
|
+
Feen.valid?("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL / SHOGI/shogi")
|
124
130
|
# => true
|
125
131
|
|
126
132
|
# Invalid syntax
|
127
133
|
Feen.valid?("invalid feen string")
|
128
134
|
# => false
|
129
135
|
|
130
|
-
# Valid syntax but non-canonical form (pieces in hand not in
|
131
|
-
Feen.valid?("
|
132
|
-
# => false
|
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 in uppercase section)
|
133
139
|
```
|
134
140
|
|
135
141
|
The `valid?` method performs two levels of validation:
|
@@ -144,47 +150,126 @@ As FEEN is rule-agnostic, it can represent positions from various board games. H
|
|
144
150
|
### International Chess
|
145
151
|
|
146
152
|
```ruby
|
147
|
-
feen_string = "
|
153
|
+
feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
148
154
|
```
|
149
155
|
|
150
|
-
In this initial chess position
|
151
|
-
|
152
|
-
- The `'` suffixes on rooks indicate an intermediate state (which might represent castling rights in chess, though FEEN doesn't define this semantics)
|
153
|
-
- The third field `CHESS/chess` indicates it's the player with uppercase pieces' turn to move
|
156
|
+
In this initial chess position, the third field `CHESS/chess` indicates it's the player with uppercase pieces' turn to move.
|
154
157
|
|
155
158
|
### Shogi (Japanese Chess)
|
156
159
|
|
157
160
|
```ruby
|
158
|
-
feen_string = "
|
161
|
+
feen_string = "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL / SHOGI/shogi"
|
162
|
+
```
|
163
|
+
|
164
|
+
**With pieces in hand and promotions:**
|
165
|
+
|
166
|
+
```ruby
|
167
|
+
feen_string = "lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL 5P2G2L/2gln2s SHOGI/shogi"
|
159
168
|
```
|
160
169
|
|
161
170
|
In this shogi position:
|
162
171
|
|
163
172
|
- The format supports promotions with the `+` prefix (e.g., `+P` for a promoted pawn)
|
164
|
-
-
|
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
|
165
177
|
- `SHOGI/shogi` indicates it's Sente's (Black's, uppercase) turn to move
|
166
|
-
- `N5P2gln2s` shows the pieces in hand: Sente has a Knight (N) and 5 Pawns (5P), while Gote has 2 Golds (2g), a Lance (l), a Knight (n), and 2 Silvers (2s), all properly sorted in ASCII lexicographic order
|
167
178
|
|
168
179
|
### Makruk (Thai Chess)
|
169
180
|
|
170
181
|
```ruby
|
171
|
-
feen_string = "rnbqkbnr/8/pppppppp/8/8/PPPPPPPP/8/
|
182
|
+
feen_string = "rnbqkbnr/8/pppppppp/8/8/PPPPPPPP/8/RNBKQBNR / MAKRUK/makruk"
|
172
183
|
```
|
173
184
|
|
174
|
-
This initial Makruk position is easily represented in FEEN without needing to know the specific rules of the game.
|
175
|
-
|
176
185
|
### Xiangqi (Chinese Chess)
|
177
186
|
|
178
187
|
```ruby
|
179
|
-
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"
|
180
189
|
```
|
181
190
|
|
182
|
-
|
191
|
+
## Advanced Features
|
192
|
+
|
193
|
+
### Pieces in Hand with Case Separation
|
183
194
|
|
184
|
-
|
185
|
-
- The format naturally adapts to the presence of a "river" (empty space in the middle)
|
195
|
+
FEEN uses case separation for pieces in hand to distinguish between players using the format `"UPPERCASE_PIECES/LOWERCASE_PIECES"`:
|
186
196
|
|
187
|
-
|
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
|
+
|
213
|
+
### Piece Name Notation (PNN) Support
|
214
|
+
|
215
|
+
FEEN supports the complete [PNN specification](https://sashite.dev/documents/pnn/1.0.0/) for representing pieces with state modifiers:
|
216
|
+
|
217
|
+
#### PNN Modifiers
|
218
|
+
|
219
|
+
- **Prefix `+`**: Enhanced state (e.g., promoted pieces in shogi)
|
220
|
+
- **Prefix `-`**: Diminished state (e.g., restricted movement)
|
221
|
+
- **Suffix `'`**: Intermediate state (e.g., castling rights, en passant eligibility)
|
222
|
+
|
223
|
+
#### Examples with PNN
|
224
|
+
|
225
|
+
```ruby
|
226
|
+
# Shogi position with promoted pieces on board
|
227
|
+
piece_placement = [
|
228
|
+
["", "", "", "", "+P", "", "", "", ""] # Promoted pawn on board
|
229
|
+
# ... other ranks
|
230
|
+
]
|
231
|
+
|
232
|
+
# Pieces in hand with PNN modifiers - case separated
|
233
|
+
pieces_in_hand = ["+P", "+P", "+P", "B'", "B'", "-p", "P"]
|
234
|
+
|
235
|
+
result = Feen.dump(
|
236
|
+
piece_placement: piece_placement,
|
237
|
+
pieces_in_hand: pieces_in_hand,
|
238
|
+
games_turn: %w[SHOGI shogi]
|
239
|
+
)
|
240
|
+
# => "8/8/8/8/4+P4/8/8/8/8 3+P2B'P/-p SHOGI/shogi"
|
241
|
+
```
|
242
|
+
|
243
|
+
### Canonical Pieces in Hand Sorting
|
244
|
+
|
245
|
+
FEEN enforces canonical ordering of pieces in hand within each case section according to the specification:
|
246
|
+
|
247
|
+
1. **By quantity (descending)**
|
248
|
+
2. **By complete PNN representation (alphabetically ascending)**
|
249
|
+
|
250
|
+
The dumper organizes pieces by case first, then applies canonical sorting within each section:
|
251
|
+
|
252
|
+
```ruby
|
253
|
+
# Input pieces in any order
|
254
|
+
pieces = ["P", "b", "P", "+P", "B", "p", "+P", "+P"]
|
255
|
+
|
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)
|
265
|
+
```
|
266
|
+
|
267
|
+
The parser returns pieces in simple alphabetical order for easy handling:
|
268
|
+
|
269
|
+
```ruby
|
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
|
272
|
+
```
|
188
273
|
|
189
274
|
### Multi-dimensional Boards
|
190
275
|
|
@@ -193,7 +278,7 @@ FEEN supports arbitrary-dimensional board configurations:
|
|
193
278
|
```ruby
|
194
279
|
require "feen"
|
195
280
|
|
196
|
-
# 3D board
|
281
|
+
# 3D board (2×2×3 configuration)
|
197
282
|
piece_placement = [
|
198
283
|
[
|
199
284
|
%w[r n b],
|
@@ -210,29 +295,100 @@ result = Feen.dump(
|
|
210
295
|
games_turn: %w[FOO bar],
|
211
296
|
pieces_in_hand: []
|
212
297
|
)
|
213
|
-
# => "rnb/qkp//PR1/1KQ
|
298
|
+
# => "rnb/qkp//PR1/1KQ / FOO/bar"
|
299
|
+
```
|
300
|
+
|
301
|
+
### Hybrid Games
|
302
|
+
|
303
|
+
FEEN supports hybrid games mixing different piece sets:
|
304
|
+
|
305
|
+
```ruby
|
306
|
+
# Chess-Shogi hybrid position
|
307
|
+
feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR 3+P2B'/p CHESS/shogi"
|
308
|
+
```
|
309
|
+
|
310
|
+
This represents a position where:
|
311
|
+
|
312
|
+
- The board uses chess-style pieces
|
313
|
+
- Pieces in hand use shogi-style promotion (`+P`) and intermediate states (`B'`)
|
314
|
+
- Chess player to move, against shogi player
|
315
|
+
- Case separation shows which player has which pieces
|
316
|
+
|
317
|
+
## Round-trip Consistency
|
318
|
+
|
319
|
+
FEEN.rb guarantees round-trip consistency - parsing and dumping produces identical canonical strings:
|
320
|
+
|
321
|
+
```ruby
|
322
|
+
original = "lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL 5P2G2L/2gln2s SHOGI/shogi"
|
323
|
+
parsed = Feen.parse(original)
|
324
|
+
dumped = Feen.dump(**parsed)
|
325
|
+
|
326
|
+
original == dumped # => true (guaranteed canonical form)
|
327
|
+
```
|
328
|
+
|
329
|
+
## Error Handling
|
330
|
+
|
331
|
+
### Validation Errors
|
332
|
+
|
333
|
+
```ruby
|
334
|
+
# Invalid PNN format
|
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'
|
341
|
+
|
342
|
+
# Invalid games turn
|
343
|
+
Feen.dump(
|
344
|
+
piece_placement: [["P"]],
|
345
|
+
pieces_in_hand: [],
|
346
|
+
games_turn: %w[BOTH_UPPERCASE ALSO_UPPERCASE] # Both same case
|
347
|
+
)
|
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
|
214
353
|
```
|
215
354
|
|
216
|
-
###
|
355
|
+
### Safe Operations
|
356
|
+
|
357
|
+
```ruby
|
358
|
+
# Use safe_parse for user input
|
359
|
+
user_input = gets.chomp
|
360
|
+
position = Feen.safe_parse(user_input)
|
361
|
+
|
362
|
+
if position
|
363
|
+
puts "Valid FEEN position!"
|
364
|
+
puts "Pieces in hand: #{position[:pieces_in_hand]}"
|
365
|
+
else
|
366
|
+
puts "Invalid FEEN format"
|
367
|
+
end
|
368
|
+
```
|
217
369
|
|
218
|
-
|
370
|
+
## Performance Considerations
|
219
371
|
|
220
|
-
- **
|
221
|
-
|
372
|
+
- **Parsing**: Optimized recursive descent parser with O(n) complexity
|
373
|
+
- **Case separation**: Efficient single-pass processing for pieces in hand
|
374
|
+
- **Validation**: Round-trip validation ensures canonical form
|
375
|
+
- **Memory**: Efficient array-based representation for large boards
|
376
|
+
- **Sorting**: In-place canonical sorting for pieces in hand
|
222
377
|
|
223
|
-
|
224
|
-
- Could represent a piece with limited movement or other restrictions
|
378
|
+
## Compatibility
|
225
379
|
|
226
|
-
- **
|
227
|
-
|
228
|
-
|
380
|
+
- **Ruby version**: >= 3.2.0
|
381
|
+
- **FEEN specification**: v1.0.0 compliant
|
382
|
+
- **PNN specification**: v1.0.0 compliant
|
383
|
+
- **Thread safety**: All operations are thread-safe (no shared mutable state)
|
229
384
|
|
230
|
-
|
385
|
+
## Related Specifications
|
231
386
|
|
232
|
-
|
387
|
+
FEEN is part of a family of specifications for abstract strategy games:
|
233
388
|
|
234
|
-
- [
|
235
|
-
- [
|
389
|
+
- [FEEN Specification v1.0.0](https://sashite.dev/documents/feen/1.0.0/) - Board position notation
|
390
|
+
- [PNN Specification v1.0.0](https://sashite.dev/documents/pnn/1.0.0/) - Piece name notation
|
391
|
+
- [GAN Specification v1.0.0](https://sashite.dev/documents/gan/1.0.0/) - Game-qualified piece identifiers
|
236
392
|
|
237
393
|
## License
|
238
394
|
|
@@ -5,7 +5,7 @@ module Feen
|
|
5
5
|
module PiecesInHand
|
6
6
|
Errors = {
|
7
7
|
invalid_type: "Piece at index: %<index>s must be a String, got type: %<type>s",
|
8
|
-
invalid_format: "Piece at index: %<index>s has an invalid format: '%<value>s'"
|
8
|
+
invalid_format: "Piece at index: %<index>s has an invalid PNN format: '%<value>s'. Expected format: [prefix]letter[suffix] where prefix is + or -, suffix is ', and letter is a-z or A-Z"
|
9
9
|
}.freeze
|
10
10
|
end
|
11
11
|
end
|
@@ -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,24 +8,102 @@ 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
|
13
|
-
# @return [String] FEEN-formatted pieces in hand string
|
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")
|
14
16
|
# @raise [ArgumentError] If any piece identifier is invalid
|
15
17
|
# @example
|
16
|
-
# PiecesInHand.dump("P", "
|
17
|
-
# # => "
|
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"
|
18
23
|
#
|
19
24
|
# PiecesInHand.dump
|
20
|
-
# # => "
|
25
|
+
# # => "/"
|
21
26
|
def self.dump(*piece_chars)
|
22
|
-
# If no pieces in hand, return the standardized empty indicator
|
23
|
-
return NoPieces if piece_chars.empty?
|
24
|
-
|
25
27
|
# Validate each piece character according to the FEEN specification
|
26
28
|
validated_chars = validate_piece_chars(piece_chars)
|
27
29
|
|
28
|
-
#
|
29
|
-
validated_chars
|
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
|
+
|
81
|
+
# Count occurrences of each piece type
|
82
|
+
piece_counts = pieces.each_with_object(Hash.new(0)) do |piece, counts|
|
83
|
+
counts[piece] += 1
|
84
|
+
end
|
85
|
+
|
86
|
+
# Sort by count (descending) then alphabetically (ascending)
|
87
|
+
sorted_pieces = piece_counts.sort do |a, b|
|
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
|
97
|
+
end
|
98
|
+
|
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
|
30
107
|
end
|
31
108
|
|
32
109
|
# Validates all piece characters according to FEEN specification
|
@@ -41,6 +118,10 @@ module Feen
|
|
41
118
|
end
|
42
119
|
|
43
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 "'"
|
44
125
|
#
|
45
126
|
# @param char [Object] Piece character candidate
|
46
127
|
# @param index [Integer] Index of the character in the original array
|
@@ -56,8 +137,9 @@ module Feen
|
|
56
137
|
)
|
57
138
|
end
|
58
139
|
|
59
|
-
# Validate format
|
60
|
-
|
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/)
|
61
143
|
raise ::ArgumentError, format(
|
62
144
|
Errors[:invalid_format],
|
63
145
|
index: index,
|
data/lib/feen/dumper.rb
CHANGED
@@ -23,19 +23,19 @@ module Feen
|
|
23
23
|
# @example Creating a FEEN string for chess initial position
|
24
24
|
# Feen::Dumper.dump(
|
25
25
|
# piece_placement: [
|
26
|
-
# ["r
|
26
|
+
# ["r", "n", "b", "q", "k", "b", "n", "r"],
|
27
27
|
# ["p", "p", "p", "p", "p", "p", "p", "p"],
|
28
28
|
# ["", "", "", "", "", "", "", ""],
|
29
29
|
# ["", "", "", "", "", "", "", ""],
|
30
30
|
# ["", "", "", "", "", "", "", ""],
|
31
31
|
# ["", "", "", "", "", "", "", ""],
|
32
32
|
# ["P", "P", "P", "P", "P", "P", "P", "P"],
|
33
|
-
# ["R
|
33
|
+
# ["R", "N", "B", "Q", "K", "B", "N", "R"]
|
34
34
|
# ],
|
35
35
|
# pieces_in_hand: [],
|
36
36
|
# games_turn: ["CHESS", "chess"]
|
37
37
|
# )
|
38
|
-
# # => "
|
38
|
+
# # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
|
39
39
|
#
|
40
40
|
# @param piece_placement [Array] Board position data structure representing the spatial
|
41
41
|
# distribution of pieces across the board, where each cell
|
@@ -5,10 +5,15 @@ module Feen
|
|
5
5
|
module PiecesInHand
|
6
6
|
# Error messages for validation
|
7
7
|
Errors = {
|
8
|
-
invalid_type:
|
9
|
-
empty_string:
|
10
|
-
invalid_format:
|
11
|
-
|
8
|
+
invalid_type: "Pieces in hand must be a string, got %s",
|
9
|
+
empty_string: "Pieces in hand string cannot be empty",
|
10
|
+
invalid_format: "Invalid pieces in hand format: %s",
|
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
|
+
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'",
|
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"
|
12
17
|
}.freeze
|
13
18
|
end
|
14
19
|
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Parser
|
5
|
+
module PiecesInHand
|
6
|
+
# Patterns for PNN (Piece Name Notation) validation and parsing
|
7
|
+
module PnnPatterns
|
8
|
+
# Basic PNN piece pattern following the specification:
|
9
|
+
# <piece> ::= <letter> | <prefix> <letter> | <letter> <suffix> | <prefix> <letter> <suffix>
|
10
|
+
# <prefix> ::= "+" | "-"
|
11
|
+
# <suffix> ::= "'"
|
12
|
+
# <letter> ::= [a-zA-Z]
|
13
|
+
PNN_PIECE_PATTERN = /\A[-+]?[a-zA-Z]'?\z/
|
14
|
+
|
15
|
+
# Pattern for valid count prefixes according to FEEN specification:
|
16
|
+
# - Cannot be "0" or "1" (use piece without prefix instead)
|
17
|
+
# - Can be 2-9 or any number with 2+ digits
|
18
|
+
VALID_COUNT_PATTERN = /\A(?:[2-9]|\d{2,})\z/
|
19
|
+
|
20
|
+
# Pattern to extract piece with optional count from pieces in hand string
|
21
|
+
# Matches: optional count followed by complete PNN piece
|
22
|
+
# Groups: (count_str, piece_str)
|
23
|
+
# Note: We need to handle the full PNN piece including modifiers
|
24
|
+
PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|\d{2,}))?([-+]?[a-zA-Z]'?)/
|
25
|
+
|
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
|
55
|
+
# Based on the FEEN BNF specification with PNN support
|
56
|
+
# Format: "UPPERCASE_PIECES/LOWERCASE_PIECES"
|
57
|
+
# Either section can be empty, but the "/" separator is mandatory
|
58
|
+
VALID_FORMAT_PATTERN = %r{\A
|
59
|
+
(?:
|
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
|
66
|
+
)
|
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
|
77
|
+
|
78
|
+
# Pattern for extracting all pieces globally (used for comprehensive validation)
|
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]'?)/
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -1,49 +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", "
|
5
|
-
require_relative File.join("pieces_in_hand", "piece_count_pattern")
|
6
|
-
require_relative File.join("pieces_in_hand", "valid_format_pattern")
|
4
|
+
require_relative File.join("pieces_in_hand", "pnn_patterns")
|
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.
|
10
|
+
# This implementation supports full PNN notation including prefixes and suffixes.
|
11
|
+
# Format: "UPPERCASE_PIECES/LOWERCASE_PIECES"
|
12
12
|
module PiecesInHand
|
13
13
|
# Parses the pieces in hand section of a FEEN string.
|
14
14
|
#
|
15
|
-
# @param pieces_in_hand_str [String] FEEN pieces in hand string
|
16
|
-
# @return [Array<String>] Array of
|
17
|
-
#
|
18
|
-
# based on their counts and sorted in ASCII lexicographic order.
|
15
|
+
# @param pieces_in_hand_str [String] FEEN pieces in hand string in format "UPPERCASE/lowercase"
|
16
|
+
# @return [Array<String>] Array of piece identifiers in full PNN format,
|
17
|
+
# expanded based on their counts and sorted alphabetically.
|
19
18
|
# Empty array if no pieces are in hand.
|
20
19
|
# @raise [ArgumentError] If the input string is invalid
|
21
20
|
#
|
22
21
|
# @example Parse no pieces in hand
|
23
|
-
# PiecesInHand.parse("
|
22
|
+
# PiecesInHand.parse("/")
|
24
23
|
# # => []
|
25
24
|
#
|
26
|
-
# @example Parse
|
27
|
-
# PiecesInHand.parse("
|
28
|
-
# # => ["B", "
|
25
|
+
# @example Parse pieces with case separation
|
26
|
+
# PiecesInHand.parse("3P2B/p")
|
27
|
+
# # => ["B", "B", "P", "P", "P", "p"]
|
29
28
|
#
|
30
|
-
# @example Parse pieces with counts
|
31
|
-
# PiecesInHand.parse("
|
32
|
-
# # => ["
|
29
|
+
# @example Parse complex pieces with counts and modifiers
|
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"]
|
33
34
|
def self.parse(pieces_in_hand_str)
|
34
35
|
# Validate input
|
35
36
|
validate_input_type(pieces_in_hand_str)
|
36
37
|
validate_format(pieces_in_hand_str)
|
37
38
|
|
38
39
|
# Handle the no-pieces case early
|
39
|
-
return [] if pieces_in_hand_str ==
|
40
|
+
return [] if pieces_in_hand_str == "/"
|
40
41
|
|
41
|
-
#
|
42
|
-
|
43
|
-
validate_lexicographic_order(pieces_with_counts)
|
42
|
+
# Split by the separator to get uppercase and lowercase sections
|
43
|
+
uppercase_section, lowercase_section = pieces_in_hand_str.split("/", 2)
|
44
44
|
|
45
|
-
#
|
46
|
-
|
45
|
+
# Parse each section separately
|
46
|
+
uppercase_pieces = parse_pieces_section(uppercase_section || "", :uppercase)
|
47
|
+
lowercase_pieces = parse_pieces_section(lowercase_section || "", :lowercase)
|
48
|
+
|
49
|
+
# Combine all pieces and sort them alphabetically
|
50
|
+
all_pieces = uppercase_pieces + lowercase_pieces
|
51
|
+
all_pieces.sort
|
47
52
|
end
|
48
53
|
|
49
54
|
# Validates that the input is a non-empty string.
|
@@ -57,26 +62,130 @@ module Feen
|
|
57
62
|
end
|
58
63
|
|
59
64
|
# Validates that the input string matches the expected format according to FEEN specification.
|
65
|
+
# Format must be: "UPPERCASE_PIECES/LOWERCASE_PIECES"
|
60
66
|
#
|
61
67
|
# @param str [String] Input string to validate
|
62
68
|
# @raise [ArgumentError] If format is invalid
|
63
69
|
# @return [void]
|
64
70
|
private_class_method def self.validate_format(str)
|
65
|
-
|
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)
|
76
|
+
|
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
|
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)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Validates each individual piece in a section for PNN compliance
|
109
|
+
#
|
110
|
+
# @param section [String] FEEN pieces section string
|
111
|
+
# @param case_type [Symbol] Either :uppercase or :lowercase
|
112
|
+
# @raise [ArgumentError] If any piece is invalid PNN format
|
113
|
+
# @return [void]
|
114
|
+
private_class_method def self.validate_individual_pieces_in_section(section, case_type)
|
115
|
+
position = 0
|
116
|
+
|
117
|
+
while position < section.length
|
118
|
+
match = section[position..].match(PnnPatterns::PIECE_WITH_COUNT_PATTERN)
|
119
|
+
|
120
|
+
unless match
|
121
|
+
remaining = section[position..]
|
122
|
+
raise ::ArgumentError, format(Errors[:invalid_format], remaining)
|
123
|
+
end
|
124
|
+
|
125
|
+
count_str, piece = match.captures
|
126
|
+
|
127
|
+
# Skip empty matches (shouldn't happen with our pattern, but safety check)
|
128
|
+
if piece.nil? || piece.empty?
|
129
|
+
position += 1
|
130
|
+
next
|
131
|
+
end
|
132
|
+
|
133
|
+
# Validate the piece follows PNN specification
|
134
|
+
unless piece.match?(PnnPatterns::PNN_PIECE_PATTERN)
|
135
|
+
raise ::ArgumentError, format(Errors[:invalid_pnn_piece], piece)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Validate count format (no "0" or "1" prefixes allowed)
|
139
|
+
if count_str && !count_str.match?(PnnPatterns::VALID_COUNT_PATTERN)
|
140
|
+
raise ::ArgumentError, format(Errors[:invalid_count], count_str)
|
141
|
+
end
|
142
|
+
|
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
|
66
149
|
|
67
|
-
|
150
|
+
position += match[0].length
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
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]/)
|
162
|
+
end
|
163
|
+
|
164
|
+
# Parses a specific section (uppercase or lowercase) and returns expanded pieces
|
165
|
+
#
|
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)
|
174
|
+
|
175
|
+
# Expand the pieces into an array (no canonical order validation needed)
|
176
|
+
expand_pieces(pieces_with_counts)
|
68
177
|
end
|
69
178
|
|
70
|
-
# Extracts pieces with their counts from
|
179
|
+
# Extracts pieces with their counts from a section string.
|
71
180
|
#
|
72
|
-
# @param
|
181
|
+
# @param section [String] FEEN pieces section string
|
73
182
|
# @return [Array<Hash>] Array of hashes with :piece and :count keys
|
74
|
-
private_class_method def self.
|
183
|
+
private_class_method def self.extract_pieces_with_counts_from_section(section)
|
75
184
|
result = []
|
76
185
|
position = 0
|
77
186
|
|
78
|
-
while position <
|
79
|
-
match =
|
187
|
+
while position < section.length
|
188
|
+
match = section[position..].match(PnnPatterns::PIECE_WITH_COUNT_PATTERN)
|
80
189
|
break unless match
|
81
190
|
|
82
191
|
count_str, piece = match.captures
|
@@ -92,20 +201,6 @@ module Feen
|
|
92
201
|
result
|
93
202
|
end
|
94
203
|
|
95
|
-
# Validates that pieces are in lexicographic ASCII order.
|
96
|
-
#
|
97
|
-
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
98
|
-
# @raise [ArgumentError] If pieces are not in lexicographic order
|
99
|
-
# @return [void]
|
100
|
-
private_class_method def self.validate_lexicographic_order(pieces_with_counts)
|
101
|
-
pieces = pieces_with_counts.map { |item| item[:piece] }
|
102
|
-
|
103
|
-
# Verify the array is sorted lexicographically
|
104
|
-
return if pieces == pieces.sort
|
105
|
-
|
106
|
-
raise ::ArgumentError, Errors[:sorting_error]
|
107
|
-
end
|
108
|
-
|
109
204
|
# Expands the pieces based on their counts into an array.
|
110
205
|
#
|
111
206
|
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
data/lib/feen/parser.rb
CHANGED
@@ -26,18 +26,18 @@ module Feen
|
|
26
26
|
# @raise [ArgumentError] If the FEEN string is invalid
|
27
27
|
#
|
28
28
|
# @example Parsing a standard chess initial position
|
29
|
-
# feen = "
|
29
|
+
# feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
|
30
30
|
# result = Feen::Parser.parse(feen)
|
31
31
|
# # => {
|
32
32
|
# # piece_placement: [
|
33
|
-
# # ["r
|
33
|
+
# # ["r", "n", "b", "q", "k", "b", "n", "r"],
|
34
34
|
# # ["p", "p", "p", "p", "p", "p", "p", "p"],
|
35
35
|
# # ["", "", "", "", "", "", "", ""],
|
36
36
|
# # ["", "", "", "", "", "", "", ""],
|
37
37
|
# # ["", "", "", "", "", "", "", ""],
|
38
38
|
# # ["", "", "", "", "", "", "", ""],
|
39
39
|
# # ["P", "P", "P", "P", "P", "P", "P", "P"],
|
40
|
-
# # ["R
|
40
|
+
# # ["R", "N", "B", "Q", "K", "B", "N", "R"]
|
41
41
|
# # ],
|
42
42
|
# # pieces_in_hand: [],
|
43
43
|
# # games_turn: ["CHESS", "chess"]
|
@@ -76,7 +76,7 @@ module Feen
|
|
76
76
|
# @return [Hash, nil] Hash containing the parsed position data or nil if parsing fails
|
77
77
|
#
|
78
78
|
# @example Parsing a valid FEEN string
|
79
|
-
# feen = "
|
79
|
+
# feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
|
80
80
|
# result = Feen::Parser.safe_parse(feen)
|
81
81
|
# # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
|
82
82
|
#
|
data/lib/feen.rb
CHANGED
@@ -23,21 +23,21 @@ module Feen
|
|
23
23
|
# @raise [ArgumentError] If any parameter is invalid
|
24
24
|
# @example
|
25
25
|
# piece_placement = [
|
26
|
-
# ["r
|
26
|
+
# ["r", "n", "b", "q", "k", "b", "n", "r"],
|
27
27
|
# ["p", "p", "p", "p", "p", "p", "p", "p"],
|
28
28
|
# ["", "", "", "", "", "", "", ""],
|
29
29
|
# ["", "", "", "", "", "", "", ""],
|
30
30
|
# ["", "", "", "", "", "", "", ""],
|
31
31
|
# ["", "", "", "", "", "", "", ""],
|
32
32
|
# ["P", "P", "P", "P", "P", "P", "P", "P"],
|
33
|
-
# ["R
|
33
|
+
# ["R", "N", "B", "Q", "K", "B", "N", "R"]
|
34
34
|
# ]
|
35
35
|
# Feen.dump(
|
36
36
|
# piece_placement: piece_placement,
|
37
37
|
# pieces_in_hand: [],
|
38
38
|
# games_turn: ["CHESS", "chess"]
|
39
39
|
# )
|
40
|
-
# # => "
|
40
|
+
# # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
|
41
41
|
def self.dump(piece_placement:, pieces_in_hand:, games_turn:)
|
42
42
|
Dumper.dump(piece_placement:, pieces_in_hand:, games_turn:)
|
43
43
|
end
|
@@ -51,18 +51,18 @@ module Feen
|
|
51
51
|
# - :games_turn [Array<String>] - A two-element array with [active_variant, inactive_variant]
|
52
52
|
# @raise [ArgumentError] If the FEEN string is invalid
|
53
53
|
# @example
|
54
|
-
# feen_string = "
|
54
|
+
# feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
|
55
55
|
# Feen.parse(feen_string)
|
56
56
|
# # => {
|
57
57
|
# # piece_placement: [
|
58
|
-
# # ["r
|
58
|
+
# # ["r", "n", "b", "q", "k", "b", "n", "r"],
|
59
59
|
# # ["p", "p", "p", "p", "p", "p", "p", "p"],
|
60
60
|
# # ["", "", "", "", "", "", "", ""],
|
61
61
|
# # ["", "", "", "", "", "", "", ""],
|
62
62
|
# # ["", "", "", "", "", "", "", ""],
|
63
63
|
# # ["", "", "", "", "", "", "", ""],
|
64
64
|
# # ["P", "P", "P", "P", "P", "P", "P", "P"],
|
65
|
-
# # ["R
|
65
|
+
# # ["R", "N", "B", "Q", "K", "B", "N", "R"]
|
66
66
|
# # ],
|
67
67
|
# # pieces_in_hand: [],
|
68
68
|
# # games_turn: ["CHESS", "chess"]
|
@@ -80,7 +80,7 @@ module Feen
|
|
80
80
|
# @return [Hash, nil] Hash containing the parsed position data or nil if parsing fails
|
81
81
|
# @example
|
82
82
|
# # Valid FEEN string
|
83
|
-
# Feen.safe_parse("
|
83
|
+
# Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess")
|
84
84
|
# # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
|
85
85
|
#
|
86
86
|
# # Invalid FEEN string
|
@@ -104,7 +104,7 @@ module Feen
|
|
104
104
|
# @return [Boolean] True if the string is a valid and canonical FEEN string
|
105
105
|
# @example
|
106
106
|
# # Canonical form
|
107
|
-
# Feen.valid?("lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL
|
107
|
+
# Feen.valid?("lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL 2g2s5PNln SHOGI/shogi") # => true
|
108
108
|
#
|
109
109
|
# # Invalid syntax
|
110
110
|
# Feen.valid?("invalid feen string") # => false
|
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,7 +26,6 @@ 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
|
@@ -34,9 +33,7 @@ files:
|
|
34
33
|
- lib/feen/parser/piece_placement.rb
|
35
34
|
- lib/feen/parser/pieces_in_hand.rb
|
36
35
|
- lib/feen/parser/pieces_in_hand/errors.rb
|
37
|
-
- lib/feen/parser/pieces_in_hand/
|
38
|
-
- lib/feen/parser/pieces_in_hand/piece_count_pattern.rb
|
39
|
-
- lib/feen/parser/pieces_in_hand/valid_format_pattern.rb
|
36
|
+
- lib/feen/parser/pieces_in_hand/pnn_patterns.rb
|
40
37
|
homepage: https://github.com/sashite/feen.rb
|
41
38
|
licenses:
|
42
39
|
- MIT
|
@@ -61,7 +58,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
61
58
|
- !ruby/object:Gem::Version
|
62
59
|
version: '0'
|
63
60
|
requirements: []
|
64
|
-
rubygems_version: 3.6.
|
61
|
+
rubygems_version: 3.6.9
|
65
62
|
specification_version: 4
|
66
63
|
summary: FEEN (Forsyth–Edwards Enhanced Notation) support for the Ruby language.
|
67
64
|
test_files: []
|
@@ -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
|