sashite-feen 0.3.0 → 0.5.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.
data/README.md CHANGED
@@ -1,29 +1,35 @@
1
- # Feen.rb
1
+ # feen.rb
2
2
 
3
3
  [![Version](https://img.shields.io/github/v/tag/sashite/feen.rb?label=Version&logo=github)](https://github.com/sashite/feen.rb/tags)
4
4
  [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/sashite/feen.rb/main)
5
- ![Ruby](https://github.com/sashite/feen.rb/actions/workflows/main.yml/badge.svg?branch=main)
6
- [![License](https://img.shields.io/github/license/sashite/feen.rb?label=License&logo=github)](https://github.com/sashite/feen.rb/raw/main/LICENSE.md)
5
+ [![CI](https://github.com/sashite/feen.rb/actions/workflows/ruby.yml/badge.svg?branch=main)](https://github.com/sashite/feen.rb/actions)
6
+ [![License](https://img.shields.io/github/license/sashite/feen.rb?label=License&logo=github)](https://github.com/sashite/feen.rb/raw/main/LICENSE)
7
7
 
8
- > **FEEN** (Forsyth–Edwards Enhanced Notation) implementation for the Ruby language.
8
+ > **FEEN** (Field Expression Encoding Notation) implementation for Ruby.
9
9
 
10
- ## What is FEEN?
10
+ ## Overview
11
11
 
12
- FEEN (Forsyth–Edwards Enhanced Notation) is a universal, rule-agnostic notation for representing board game positions. It extends traditional FEN to support:
12
+ This library implements the [FEEN Specification v1.0.0](https://sashite.dev/specs/feen/1.0.0/), providing serialization and deserialization of board game positions between FEEN strings and [`Qi`](https://github.com/sashite/qi.rb) objects.
13
13
 
14
- - **Multiple game systems** (Chess, Shōgi, Xiangqi, and more)
15
- - **Cross-style games** where players use different piece sets
16
- - **Multi-dimensional boards** (2D, 3D, and beyond)
17
- - **Captured pieces** (pieces-in-hand for drop mechanics)
18
- - **Arbitrarily large boards** with efficient empty square encoding
19
- - **Completely irregular structures** (any valid combination of ranks and separators)
20
- - **Board-less positions** (positions without piece placement, useful for pure style/turn tracking)
14
+ FEEN is a rule-agnostic, canonical position encoding for two-player, turn-based board games built on the [Sashité Game Protocol](https://sashite.dev/game-protocol/). A FEEN string encodes exactly three fields: **piece placement** (board structure and occupancy), **hands** (off-board pieces), and **style–turn** (player styles and active player).
21
15
 
22
- This gem implements the [FEEN Specification v1.0.0](https://sashite.dev/specs/feen/1.0.0/) as a pure functional library with immutable data structures.
16
+ ### Implementation Constraints
17
+
18
+ | Constraint | Value | Rationale |
19
+ |------------|-------|-----------|
20
+ | Regular shapes only | Required | All ranks must have equal length within each dimension |
21
+ | Max string length | 4096 | Sufficient for realistic board positions |
22
+ | Max board dimensions | 3 | Sufficient for 1D, 2D, 3D boards |
23
+ | Max dimension size | 255 | Fits in 8-bit integer; covers 255×255×255 boards |
24
+
25
+ These constraints enable bounded memory usage and safe parsing.
26
+
27
+ Only regular board shapes are supported — every rank within a dimension must contain the same number of cells. For example, `9x10` and `8x8` boards are valid. Irregular structures where ranks have different sizes (e.g., ranks of 3, 2, and 4 cells) are not supported.
23
28
 
24
29
  ## Installation
25
30
 
26
31
  ```ruby
32
+ # In your Gemfile
27
33
  gem "sashite-feen"
28
34
  ```
29
35
 
@@ -33,479 +39,298 @@ Or install manually:
33
39
  gem install sashite-feen
34
40
  ```
35
41
 
36
- ## Quick Start
42
+ ## Dependencies
37
43
 
38
44
  ```ruby
39
- require "sashite/feen"
40
-
41
- # Parse a FEEN string into an immutable position object
42
- position = Sashite::Feen.parse("+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c")
43
-
44
- # Access position components
45
- position.placement # Board configuration
46
- position.hands # Captured pieces
47
- position.styles # Game styles and active player
48
-
49
- # Convert placement to array based on dimensionality
50
- position.placement.to_a # => [[pieces...], [pieces...], ...] for 2D boards
51
-
52
- # Convert back to canonical FEEN string
53
- feen_string = Sashite::Feen.dump(position) # or position.to_s
54
- ```
55
-
56
- ## FEEN Format
57
-
58
- A FEEN string consists of three space-separated fields:
59
-
60
- ```
61
- <piece-placement> <pieces-in-hand> <style-turn>
62
- ```
63
-
64
- **Example:**
65
- ```txt
66
- +rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c
45
+ gem "qi", "~> 13.0" # Position model
67
46
  ```
68
47
 
69
- 1. **Piece placement**: Board configuration using EPIN notation with `/` separators (can be empty for board-less positions)
70
- 2. **Pieces in hand**: Captured pieces for each player (format: `first/second`)
71
- 3. **Style-turn**: Game styles and active player (format: `active/inactive`)
48
+ ## Usage
72
49
 
73
- See the [FEEN Specification](https://sashite.dev/specs/feen/1.0.0/) for complete format details.
50
+ ### Parsing (FEEN String Qi)
74
51
 
75
- ## API Reference
76
-
77
- ### Module Methods
78
-
79
- #### `Sashite::Feen.parse(string)`
80
-
81
- Parses a FEEN string into an immutable `Position` object.
82
-
83
- - **Parameter**: `string` (String) - FEEN notation string
84
- - **Returns**: `Position` - Immutable position object
85
- - **Raises**: `Sashite::Feen::Error` subclasses on invalid input
52
+ Convert a FEEN string into a `Qi` object.
86
53
 
87
54
  ```ruby
88
- position = Sashite::Feen.parse("8/8/8/8/8/8/8/8 / C/c")
89
-
90
- # Board-less position (empty piece placement)
91
- position = Sashite::Feen.parse(" / C/c")
92
- ```
93
-
94
- #### `Sashite::Feen.dump(position)`
95
-
96
- Converts a position object into its canonical FEEN string.
97
-
98
- - **Parameter**: `position` (Position) - Position object
99
- - **Returns**: `String` - Canonical FEEN string
100
- - **Guarantees**: Deterministic output (same position always produces same string)
55
+ require "sashite/feen"
101
56
 
102
- ```ruby
103
- feen_string = Sashite::Feen.dump(position)
57
+ # Parse a Shōgi starting position
58
+ position = Sashite::Feen.parse("lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / S/s")
59
+
60
+ # The result is a Qi
61
+ position.shape
62
+ # => [9, 9]
63
+
64
+ position.board
65
+ # => ["l", "n", "s", "g", "k^", "g", "s", "n", "l",
66
+ # nil, "r", nil, nil, nil, nil, nil, "b", nil,
67
+ # "p", "p", "p", "p", "p", "p", "p", "p", "p",
68
+ # nil, nil, nil, nil, nil, nil, nil, nil, nil,
69
+ # nil, nil, nil, nil, nil, nil, nil, nil, nil,
70
+ # nil, nil, nil, nil, nil, nil, nil, nil, nil,
71
+ # "P", "P", "P", "P", "P", "P", "P", "P", "P",
72
+ # nil, "B", nil, nil, nil, nil, nil, "R", nil,
73
+ # "L", "N", "S", "G", "K^", "G", "S", "N", "L"]
74
+
75
+ position.to_nested
76
+ # => [["l", "n", "s", "g", "k^", "g", "s", "n", "l"],
77
+ # [nil, "r", nil, nil, nil, nil, nil, "b", nil],
78
+ # ["p", "p", "p", "p", "p", "p", "p", "p", "p"],
79
+ # [nil, nil, nil, nil, nil, nil, nil, nil, nil],
80
+ # [nil, nil, nil, nil, nil, nil, nil, nil, nil],
81
+ # [nil, nil, nil, nil, nil, nil, nil, nil, nil],
82
+ # ["P", "P", "P", "P", "P", "P", "P", "P", "P"],
83
+ # [nil, "B", nil, nil, nil, nil, nil, "R", nil],
84
+ # ["L", "N", "S", "G", "K^", "G", "S", "N", "L"]]
85
+
86
+ position.first_player_hand # => {}
87
+ position.second_player_hand # => {}
88
+ position.first_player_style # => "S"
89
+ position.second_player_style # => "s"
90
+ position.turn # => :first
91
+
92
+ # Invalid input raises an error
93
+ Sashite::Feen.parse("invalid") # => raises Sashite::Feen::ParseError
104
94
  ```
105
95
 
106
- ### Position Object
96
+ ### Dumping (Qi → FEEN String)
107
97
 
108
- The `Position` object is immutable and provides read-only access to three components:
98
+ Convert a `Qi` back to a canonical FEEN string.
109
99
 
110
100
  ```ruby
111
- position.placement # => Placement (board configuration)
112
- position.hands # => Hands (pieces in hand)
113
- position.styles # => Styles (style-turn information)
114
- position.to_s # => String (canonical FEEN)
115
- ```
116
-
117
- **Equality and hashing:**
118
- ```ruby
119
- position1 == position2 # Component-wise equality
120
- position1.hash # Consistent hash for same positions
101
+ # From an existing Qi
102
+ position = Sashite::Feen.parse("8/8/8/8/8/8/8/8 / C/c")
103
+ Sashite::Feen.dump(position)
104
+ # => "8/8/8/8/8/8/8/8 / C/c"
105
+
106
+ # From a Qi built manually
107
+ position = Qi.new([1, 8], first_player_style: "C", second_player_style: "c")
108
+ .board_diff(0 => "K^", 7 => "k^")
109
+ Sashite::Feen.dump(position)
110
+ # => "K^6k^ / C/c"
121
111
  ```
122
112
 
123
- ### Placement Object
124
-
125
- Represents the board configuration as a flat array of ranks with explicit separators.
113
+ ### Validation
126
114
 
127
115
  ```ruby
128
- placement.ranks # => Array<Array> - Flat array of all ranks
129
- placement.separators # => Array<String> - Separators between ranks (e.g., ["/", "//"])
130
- placement.dimension # => Integer - Board dimensionality (1 + max consecutive slashes)
131
- placement.rank_count # => Integer - Total number of ranks
132
- placement.one_dimensional? # => Boolean - True if dimension is 1
133
- placement.all_pieces # => Array - All pieces (nils excluded)
134
- placement.total_squares # => Integer - Total square count
135
- placement.to_s # => String - Piece placement field
136
- placement.to_a # => Array - Array representation (dimension-aware)
116
+ # Boolean check (never raises)
117
+ Sashite::Feen.valid?("lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / S/s") # => true
118
+ Sashite::Feen.valid?("8/8/8/8/8/8/8/8 / C/c") # => true (empty board)
119
+ Sashite::Feen.valid?("k^+p4+PK^ / C/c") # => true (1D board)
120
+ Sashite::Feen.valid?("a/b//c/d / G/g") # => true (3D board)
121
+ Sashite::Feen.valid?("rkr//PPPP / G/g") # => false (dimensional coherence)
122
+ Sashite::Feen.valid?("invalid") # => false
123
+ Sashite::Feen.valid?(nil) # => false
137
124
  ```
138
125
 
139
- #### `to_a` - Dimension-Aware Array Conversion
140
-
141
- The `to_a` method returns an array representation that adapts to the board's dimensionality:
126
+ ### Round-trip Examples
142
127
 
143
- - **1D boards**: Returns a single rank array (or empty array if no ranks)
144
- - **2D+ boards**: Returns array of ranks
128
+ FEEN parsing and dumping are perfect inverses any valid FEEN string round-trips through `Qi` without loss.
145
129
 
146
130
  ```ruby
147
- # 1D board - Returns flat array
148
- feen = "K2P3k / C/c"
131
+ # Chess starting position
132
+ feen = "-rnbqk^bn-r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/-RNBQK^BN-R / C/c"
149
133
  position = Sashite::Feen.parse(feen)
150
- position.placement.to_a
151
- # => [K, nil, nil, P, nil, nil, nil, k]
134
+ Sashite::Feen.dump(position) == feen # => true
152
135
 
153
- # 2D board - Returns array of arrays
154
- feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
136
+ # Xiangqi starting position
137
+ feen = "rheag^aehr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RHEAG^AEHR / X/x"
155
138
  position = Sashite::Feen.parse(feen)
156
- position.placement.to_a
157
- # => [[r,n,b,q,k,b,n,r], [p,p,p,p,p,p,p,p], [nil×8], ...]
158
-
159
- # 3D board - Returns array of ranks (to be structured by application)
160
- feen = "5/5//5/5 / R/r"
161
- position = Sashite::Feen.parse(feen)
162
- position.placement.to_a
163
- # => [[nil×5], [nil×5], [nil×5], [nil×5]]
164
-
165
- # Empty board
166
- placement = Sashite::Feen::Placement.new([], [], 1)
167
- placement.to_a
168
- # => []
169
- ```
170
-
171
- **Other methods:**
172
-
173
- ```ruby
174
- # Access specific positions
175
- first_rank = placement.ranks[0]
176
- piece_at_a1 = first_rank[0] # Piece object or nil
177
-
178
- # Check dimensionality
179
- placement.dimension # => 2 (2D board)
180
-
181
- # Inspect separator structure
182
- placement.separators # => ["/", "/", "/", "/", "/", "/", "/"]
183
- ```
184
-
185
- ### Hands Object
186
-
187
- Represents captured pieces held by each player.
188
-
189
- ```ruby
190
- hands.first_player # => Array - Pieces held by first player
191
- hands.second_player # => Array - Pieces held by second player
192
- hands.empty? # => Boolean - True if both hands are empty
193
- hands.to_s # => String - Pieces-in-hand field
194
- ```
195
-
196
- **Example:**
197
- ```ruby
198
- # Count pieces in hand
199
- first_player_pawns = hands.first_player.count { |p| p.to_s == "P" }
200
-
201
- # Check if any captures
202
- hands.empty? # => false
203
- ```
204
-
205
- ### Styles Object
206
-
207
- Represents game styles and indicates the active player.
208
-
209
- ```ruby
210
- styles.active # => SIN identifier - Active player's style
211
- styles.inactive # => SIN identifier - Inactive player's style
212
- styles.to_s # => String - Style-turn field
213
- ```
214
-
215
- **Example:**
216
- ```ruby
217
- # Determine active player
218
- styles.active.to_s # => "C" (first player Chess)
219
- styles.inactive.to_s # => "c" (second player Chess)
220
-
221
- # Check if cross-style
222
- styles.active.to_s.upcase != styles.inactive.to_s.upcase
139
+ Sashite::Feen.dump(position) == feen # => true
223
140
  ```
224
141
 
225
- ## Examples
142
+ ### Hands
226
143
 
227
- ### Chess Positions
144
+ Pieces in hand are represented as count maps (`Hash{String => Integer}`) in `Qi`. FEEN automatically handles aggregation (for serialization) and expansion (for parsing).
228
145
 
229
146
  ```ruby
230
- # Starting position
231
- chess_start = Sashite::Feen.parse(
232
- "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c"
233
- )
234
-
235
- # After 1.e4
236
- after_e4 = Sashite::Feen.parse(
237
- "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/4P3/8/+P+P+P+P1+P+P+P/+RNBQ+KBN+R / c/C"
238
- )
239
-
240
- # Ruy Lopez opening
241
- ruy_lopez = Sashite::Feen.parse(
242
- "r1bqkbnr/+p+p+p+p1+p+p+p/2n5/1B2p3/4P3/5N2/+P+P+P+P1+P+P+P/RNBQK2R / c/C"
243
- )
244
- ```
245
-
246
- ### Shōgi with Captured Pieces
247
-
248
- ```ruby
249
- # Starting position
250
- shogi_start = Sashite::Feen.parse(
251
- "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL / S/s"
252
- )
253
-
254
- # Position with pieces in hand
255
- shogi_midgame = Sashite::Feen.parse(
256
- "lnsgkgsnl/1r5b1/pppp1pppp/9/4p4/9/PPPP1PPPP/1B5R1/LNSGKGSNL P/p s/S"
257
- )
258
-
259
- # Access captured pieces
260
- position = shogi_midgame
261
- position.hands.first_player # => [P] (one pawn)
262
- position.hands.second_player # => [p] (one pawn)
263
-
264
- # Count specific pieces in hand
265
- position.hands.first_player.count { |p| p.to_s == "P" } # => 1
266
- ```
267
-
268
- ### Cross-Style Games
269
-
270
- ```ruby
271
- # Chess vs Makruk
272
- chess_vs_makruk = Sashite::Feen.parse(
273
- "rnsmksnr/8/pppppppp/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/m"
274
- )
275
-
276
- # Chess vs Shōgi
277
- chess_vs_shogi = Sashite::Feen.parse(
278
- "lnsgkgsnl/1r5b1/pppppppp/9/9/9/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/s"
279
- )
280
-
281
- # Check styles
282
- position = chess_vs_makruk
283
- position.styles.active.to_s # => "C" (Chess, first player)
284
- position.styles.inactive.to_s # => "m" (Makruk, second player)
285
- ```
286
-
287
- ### Multi-Dimensional Boards
288
-
289
- ```ruby
290
- # 3D Chess (Raumschach)
291
- raumschach = Sashite::Feen.parse(
292
- "rnknr/+p+p+p+p+p/5/5/5//buqbu/+p+p+p+p+p/5/5/5//5/5/5/5/5//5/5/5/+P+P+P+P+P/BUQBU//5/5/5/+P+P+P+P+P/RNKNR / R/r"
293
- )
294
-
295
- # Check dimensionality
296
- raumschach.placement.dimension # => 3 (3D board)
297
- raumschach.placement.ranks.size # => 25 (total ranks)
298
-
299
- # Inspect separator structure
300
- level_seps = raumschach.placement.separators.count { |s| s == "//" }
301
- rank_seps = raumschach.placement.separators.count { |s| s == "/" }
302
- # level_seps => 4 (separates 5 levels)
303
- # rank_seps => 20 (separates ranks within levels)
304
- ```
305
-
306
- ### Irregular Boards
307
-
308
- ```ruby
309
- # Diamond-shaped board
310
- diamond = Sashite::Feen.parse("3/4/5/4/3 / G/g")
311
-
312
- # Check structure
313
- diamond.placement.ranks.map(&:size) # => [3, 4, 5, 4, 3]
147
+ # Shōgi mid-game with captured pieces
148
+ feen = "lnsgk^gsnl/1r5b1/pppp1pppp/9/9/9/PPPP1PPPP/1B5R1/LNSGK^GSNL P/p S/s"
149
+ position = Sashite::Feen.parse(feen)
314
150
 
315
- # Very large board
316
- large_board = Sashite::Feen.parse("100/100/100 / G/g")
317
- large_board.placement.total_squares # => 300
151
+ position.first_player_hand # => { "P" => 1 }
152
+ position.second_player_hand # => { "p" => 1 }
318
153
 
319
- # Single square
320
- single = Sashite::Feen.parse("K / C/c")
321
- single.placement.rank_count # => 1
154
+ # Multiple identical pieces are aggregated in FEEN
155
+ position = Qi.new([3, 3], first_player_style: "S", second_player_style: "s")
156
+ .board_diff(4 => "K^")
157
+ .first_player_hand_diff("P": 2, "B": 1)
158
+ .second_player_hand_diff("p": 1)
159
+ Sashite::Feen.dump(position)
160
+ # => "3/1K^1/3 2PB/p S/s"
322
161
  ```
323
162
 
324
- ### Completely Irregular Structures
163
+ ### Multi-dimensional Boards
325
164
 
326
- FEEN supports any valid combination of ranks and separators:
165
+ `Qi` supports 1D, 2D, and 3D boards natively.
327
166
 
328
167
  ```ruby
329
- # Extreme irregularity with variable separators
330
- feen = "99999/3///K/k//r / G/g"
168
+ # 1D board
169
+ feen = "k^+p4+PK^ / C/c"
331
170
  position = Sashite::Feen.parse(feen)
171
+ position.shape # => [8]
172
+ position.board
173
+ # => ["k^", "+p", nil, nil, nil, nil, "+P", "K^"]
332
174
 
333
- # Access the structure
334
- position.placement.ranks.size # => 5 ranks
335
- position.placement.separators # => ["/", "///", "/", "//"]
336
- position.placement.dimension # => 4 (max separator is "///")
337
-
338
- # Each rank can have different sizes
339
- position.placement.ranks[0].size # => 99999
340
- position.placement.ranks[1].size # => 3
341
- position.placement.ranks[2].size # => 1
342
- position.placement.ranks[3].size # => 1
343
- position.placement.ranks[4].size # => 1
344
-
345
- # Round-trip preservation
346
- Sashite::Feen.dump(position) == feen # => true
175
+ # 3D board (2 layers × 2 ranks × 2 files)
176
+ feen = "ab/cd//AB/CD / G/g"
177
+ position = Sashite::Feen.parse(feen)
178
+ position.shape # => [2, 2, 2]
179
+ position.to_nested
180
+ # => [[["a", "b"], ["c", "d"]],
181
+ # [["A", "B"], ["C", "D"]]]
347
182
  ```
348
183
 
349
- ### Empty Ranks
184
+ ### Style–Turn Mapping
350
185
 
351
- FEEN supports empty ranks (ranks with no pieces):
186
+ The FEEN style–turn field maps directly to `Qi`'s style and turn accessors.
352
187
 
353
188
  ```ruby
354
- # Trailing separator creates empty rank
355
- feen = "K/// / C/c"
356
- position = Sashite::Feen.parse(feen)
357
-
358
- position.placement.ranks.size # => 2
359
- position.placement.ranks[0] # => [K]
360
- position.placement.ranks[1] # => [] (empty rank)
361
- position.placement.separators # => ["///"]
362
-
363
- # Round-trip preserves structure
364
- Sashite::Feen.dump(position) == feen # => true
189
+ # First player to move (uppercase style is active)
190
+ position = Sashite::Feen.parse("8/8/8/8/8/8/8/8 / C/c")
191
+ position.first_player_style # => "C"
192
+ position.second_player_style # => "c"
193
+ position.turn # => :first
194
+
195
+ # Second player to move (lowercase style is active)
196
+ position = Sashite::Feen.parse("8/8/8/8/8/8/8/8 / c/C")
197
+ position.first_player_style # => "C"
198
+ position.second_player_style # => "c"
199
+ position.turn # => :second
365
200
  ```
366
201
 
367
- ### Board-less Positions
202
+ ## API Reference
368
203
 
369
- FEEN supports positions without piece placement, useful for tracking only style and turn information:
204
+ ### Module Methods
370
205
 
371
206
  ```ruby
372
- # Position with empty board (no piece placement)
373
- board_less = Sashite::Feen.parse(" / C/c")
374
-
375
- board_less.placement.ranks.size # => 1
376
- board_less.placement.dimension # => 1
377
- board_less.placement.to_a # => []
378
-
379
- # Convert back to FEEN
380
- Sashite::Feen.dump(board_less) # => " / C/c"
207
+ # Parses a FEEN string into a Qi.
208
+ # Pieces on the board are EPIN token strings; empty squares are nil.
209
+ # Raises ParseError (or subclass) if the string is not valid.
210
+ #
211
+ # @param feen_string [String] FEEN string
212
+ # @return [Qi]
213
+ # @raise [ParseError] if invalid
214
+ def Sashite::Feen.parse(feen_string)
215
+
216
+ # Reports whether string is a valid FEEN position.
217
+ # Never raises; returns false for any invalid input.
218
+ # Uses an exception-free code path internally for performance.
219
+ #
220
+ # @param feen_string [String] FEEN string
221
+ # @return [Boolean]
222
+ def Sashite::Feen.valid?(feen_string)
223
+
224
+ # Serializes a Qi to a canonical FEEN string.
225
+ # Board pieces must be valid EPIN token strings.
226
+ # Style values must be valid SIN token strings.
227
+ #
228
+ # @param position [Qi] Position to serialize
229
+ # @return [String] Canonical FEEN string
230
+ # @raise [ArgumentError] if position contains invalid tokens
231
+ def Sashite::Feen.dump(position)
381
232
  ```
382
233
 
383
- ### Working with Positions
234
+ ### Constants
384
235
 
385
236
  ```ruby
386
- # Compare positions
387
- position1 = Sashite::Feen.parse("8/8/8/8/8/8/8/8 / C/c")
388
- position2 = Sashite::Feen.parse("8/8/8/8/8/8/8/8 / C/c")
389
- position1 == position2 # => true
390
-
391
- # Round-trip parsing
392
- original = "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c"
393
- position = Sashite::Feen.parse(original)
394
- Sashite::Feen.dump(position) == original # => true
395
-
396
- # Extract specific information
397
- position.placement.ranks[0] # First rank (array of pieces/nils)
398
- position.hands.first_player.size # Number of captured pieces
237
+ Sashite::Feen::Limits::MAX_STRING_LENGTH # => 4096
238
+ Sashite::Feen::Limits::MAX_DIMENSIONS # => 3
239
+ Sashite::Feen::Limits::MAX_DIMENSION_SIZE # => 255
399
240
  ```
400
241
 
401
- ### State Modifiers and Derivation
402
-
403
- ```ruby
404
- # Enhanced pieces (promoted, with special rights)
405
- enhanced = Sashite::Feen.parse("+K+Q+R+B/8/8/8/8/8/8/8 / C/c")
242
+ ### Error Hierarchy
406
243
 
407
- # Diminished pieces (weakened, vulnerable)
408
- diminished = Sashite::Feen.parse("-K-Q-R-B/8/8/8/8/8/8/8 / C/c")
244
+ All errors inherit from `Sashite::Feen::Error`, which inherits from `ArgumentError`:
409
245
 
410
- # Foreign pieces (using opponent's style)
411
- foreign = Sashite::Feen.parse("K'Q'R'B'/k'q'r'b'/8/8/8/8/8/8 / C/s")
246
+ ```
247
+ ArgumentError
248
+ └── Sashite::Feen::Error
249
+ └── Sashite::Feen::ParseError
250
+ ├── Sashite::Feen::PiecePlacementError
251
+ ├── Sashite::Feen::HandsError
252
+ ├── Sashite::Feen::StyleTurnError
253
+ └── Sashite::Feen::CardinalityError
412
254
  ```
413
255
 
414
- ## Error Handling
415
-
416
- FEEN defines specific error classes for different validation failures:
256
+ You can rescue at any level:
417
257
 
418
258
  ```ruby
259
+ # Catch all FEEN errors
419
260
  begin
420
- position = Sashite::Feen.parse("invalid feen")
261
+ Sashite::Feen.parse(input)
421
262
  rescue Sashite::Feen::Error => e
422
- # Base error class catches all FEEN errors
423
- warn "FEEN error: #{e.message}"
263
+ puts "FEEN error: #{e.message}"
424
264
  end
425
- ```
426
-
427
- ### Error Hierarchy
428
-
429
- ```txt
430
- Sashite::Feen::Error # Base error class
431
- ├── Error::Syntax # Malformed FEEN structure
432
- ├── Error::Piece # Invalid EPIN notation
433
- ├── Error::Style # Invalid SIN notation
434
- ├── Error::Count # Invalid piece counts
435
- └── Error::Validation # Other semantic violations
436
- ```
437
-
438
- ### Common Errors
439
265
 
440
- ```ruby
441
- # Syntax error - wrong field count
442
- Sashite::Feen.parse("8/8/8/8/8/8/8/8 /")
443
- # => Error::Syntax: "FEEN must have exactly 3 space-separated fields, got 2"
444
-
445
- # Style error - invalid SIN
446
- Sashite::Feen.parse("8/8/8/8/8/8/8/8 / 1/2")
447
- # => Error::Style: "failed to parse SIN '1': invalid SIN notation: '1' (must be a single letter A-Z or a-z)"
448
-
449
- # Count error - invalid quantity
450
- Sashite::Feen.parse("8/8/8/8/8/8/8/8 0P/ C/c")
451
- # => Error::Count: "piece count must be at least 1, got 0"
452
- ```
453
-
454
- ## Properties
455
-
456
- - **Purely functional**: Immutable data structures, no side effects
457
- - **Canonical output**: Deterministic string generation (same position → same string)
458
- - **Specification compliant**: Strict adherence to [FEEN v1.0.0](https://sashite.dev/specs/feen/1.0.0/)
459
- - **Minimal API**: Two methods (`parse` and `dump`) for complete functionality
460
- - **Universal**: Supports any abstract strategy board game
461
- - **Completely flexible**: Accepts any valid combination of ranks and separators
462
- - **Perfect round-trip**: `parse(dump(position)) == position` guaranteed
463
- - **Dimension-aware**: Intelligent array conversion based on board structure
464
- - **Composable**: Built on [EPIN](https://github.com/sashite/epin.rb) and [SIN](https://github.com/sashite/sin.rb) specifications
465
-
466
- ## Dependencies
467
-
468
- - [sashite-epin](https://github.com/sashite/epin.rb) — Extended Piece Identifier Notation
469
- - [sashite-sin](https://github.com/sashite/sin.rb) — Style Identifier Notation
470
-
471
- ## Documentation
472
-
473
- - [FEEN Specification v1.0.0](https://sashite.dev/specs/feen/1.0.0/) — Complete technical specification
474
- - [FEEN Examples](https://sashite.dev/specs/feen/1.0.0/examples/) — Comprehensive examples
475
- - [API Documentation](https://rubydoc.info/github/sashite/feen.rb/main) — Full API reference
476
- - [GitHub Wiki](https://github.com/sashite/feen.rb/wiki) — Advanced usage and patterns
477
-
478
- ## Development
479
-
480
- ```sh
481
- # Clone the repository
482
- git clone https://github.com/sashite/feen.rb.git
483
- cd feen.rb
484
-
485
- # Install dependencies
486
- bundle install
487
-
488
- # Run tests
489
- ruby test.rb
266
+ # Catch specific field errors
267
+ begin
268
+ Sashite::Feen.parse(input)
269
+ rescue Sashite::Feen::PiecePlacementError => e
270
+ puts "Board error: #{e.message}"
271
+ rescue Sashite::Feen::HandsError => e
272
+ puts "Hands error: #{e.message}"
273
+ end
490
274
 
491
- # Generate documentation
492
- yard doc
275
+ # Or catch as standard ArgumentError
276
+ begin
277
+ Sashite::Feen.parse(input)
278
+ rescue ArgumentError => e
279
+ puts "Invalid argument: #{e.message}"
280
+ end
493
281
  ```
494
282
 
495
- ## Contributing
496
-
497
- 1. Fork the repository
498
- 2. Create a feature branch (`git checkout -b feature/new-feature`)
499
- 3. Add tests for your changes
500
- 4. Ensure all tests pass (`ruby test.rb`)
501
- 5. Commit your changes (`git commit -am 'Add new feature'`)
502
- 6. Push to the branch (`git push origin feature/new-feature`)
503
- 7. Create a Pull Request
283
+ ### Error Messages
284
+
285
+ | Error Class | Message | Cause |
286
+ |-------------|---------|-------|
287
+ | `ParseError` | `"input exceeds 4096 characters"` | String too long |
288
+ | `ParseError` | `"invalid field count"` | Not exactly 3 space-separated fields |
289
+ | `PiecePlacementError` | `"piece placement is empty"` | Field 1 is empty |
290
+ | `PiecePlacementError` | `"piece placement starts with separator"` | Field 1 starts with `/` |
291
+ | `PiecePlacementError` | `"piece placement ends with separator"` | Field 1 ends with `/` |
292
+ | `PiecePlacementError` | `"invalid empty count"` | Empty count is zero or has leading zeros |
293
+ | `PiecePlacementError` | `"invalid piece token"` | Token is not a valid EPIN identifier |
294
+ | `PiecePlacementError` | `"dimensional coherence violation"` | Separator depth mismatch |
295
+ | `PiecePlacementError` | `"exceeds 3 dimensions"` | Board has more than 3 dimensions |
296
+ | `PiecePlacementError` | `"dimension size exceeds 255"` | A rank exceeds 255 squares |
297
+ | `HandsError` | `"invalid hands delimiter"` | Field 2 missing `/` or has multiple |
298
+ | `HandsError` | `"invalid hand count"` | Multiplicity is 0, 1, or has leading zeros |
299
+ | `HandsError` | `"hand items not aggregated"` | Identical EPIN tokens not combined |
300
+ | `HandsError` | `"hand items not in canonical order"` | Items violate ordering rules |
301
+ | `StyleTurnError` | `"invalid style-turn delimiter"` | Field 3 missing `/` or has multiple |
302
+ | `StyleTurnError` | `"invalid style token"` | Token is not a valid SIN identifier |
303
+ | `StyleTurnError` | `"style tokens must have opposite case"` | Both tokens same case |
304
+ | `CardinalityError` | `"too many pieces for board size"` | Total pieces exceeds total squares |
305
+
306
+ ## Design Principles
307
+
308
+ - **Spec conformance**: Strict adherence to FEEN v1.0.0
309
+ - **Qi integration**: Parses to and dumps from `Qi`, the shared position model across Sashité libraries
310
+ - **Zero external parsing dependencies**: EPIN and SIN validation is inlined for performance; only `Qi` is required at runtime
311
+ - **Canonical output**: `dump` always produces canonical form
312
+ - **Structured errors**: Hierarchical error classes for precise handling
313
+ - **Ruby idioms**: `valid?` predicate, `parse`/`dump` symmetry, `ArgumentError` for invalid input
314
+ - **Defensive limits**: Bounded memory usage via configurable constraints
315
+ - **Performance-oriented internals**: Exception-free validation path; exceptions only at the public API boundary
316
+
317
+ ### Performance Architecture
318
+
319
+ Parsing is internally split into two layers to avoid using exceptions for control flow:
320
+
321
+ - **Validation layer** — Each sub-parser (`PiecePlacement`, `Hands`, `StyleTurn`) exposes a `safe_parse` method that returns `nil` on invalid input instead of raising an exception. This path performs all structural validation and data extraction without allocating exception objects or capturing backtraces.
322
+ - **Public API layer** — `parse` calls the validation layer internally. On failure, it raises the appropriate error class exactly once, at the boundary. `valid?` calls the same validation layer and returns a boolean directly, never raising and never constructing a `Qi` object on invalid input.
323
+
324
+ This dual-path design eliminates the cost of exception-based control flow on the hot path. Since `valid?` is commonly called on untrusted or invalid input, avoiding `raise`/`rescue` per rejection keeps validation at pure method-call speed. On the `parse` side, the single `raise` at the boundary is an acceptable cost — it happens once per invalid call, not once per sub-parser.
325
+
326
+ ## Related Specifications
327
+
328
+ - [Game Protocol](https://sashite.dev/game-protocol/) — Conceptual foundation
329
+ - [FEEN Specification](https://sashite.dev/specs/feen/1.0.0/) — Official specification
330
+ - [FEEN Examples](https://sashite.dev/specs/feen/1.0.0/examples/) — Usage examples
331
+ - [EPIN Specification](https://sashite.dev/specs/epin/1.0.0/) — Piece token format
332
+ - [SIN Specification](https://sashite.dev/specs/sin/1.0.0/) — Style token format
504
333
 
505
334
  ## License
506
335
 
507
- Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
508
-
509
- ## About
510
-
511
- Maintained by [Sashité](https://sashite.com/) — promoting chess variants and sharing the beauty of board game cultures.
336
+ Available as open source under the [Apache License 2.0](https://opensource.org/licenses/Apache-2.0).