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.
- checksums.yaml +4 -4
- data/README.md +243 -418
- data/lib/sashite/feen/dumper/hands.rb +105 -0
- data/lib/sashite/feen/dumper/piece_placement.rb +75 -143
- data/lib/sashite/feen/dumper/style_turn.rb +15 -30
- data/lib/sashite/feen/dumper.rb +12 -42
- data/lib/sashite/feen/error.rb +5 -73
- data/lib/sashite/feen/errors/cardinality_error.rb +18 -0
- data/lib/sashite/feen/errors/hands_error.rb +20 -0
- data/lib/sashite/feen/errors/parse_error.rb +20 -0
- data/lib/sashite/feen/errors/piece_placement_error.rb +25 -0
- data/lib/sashite/feen/errors/style_turn_error.rb +18 -0
- data/lib/sashite/feen/parser/hand.rb +243 -0
- data/lib/sashite/feen/parser/hands.rb +102 -0
- data/lib/sashite/feen/parser/piece_placement.rb +422 -311
- data/lib/sashite/feen/parser/style_turn.rb +80 -76
- data/lib/sashite/feen/parser.rb +187 -56
- data/lib/sashite/feen/shared/ascii.rb +52 -0
- data/lib/sashite/feen/shared/limits.rb +24 -0
- data/lib/sashite/feen/shared/separators.rb +18 -0
- data/lib/sashite/feen.rb +23 -50
- data/lib/sashite-feen.rb +0 -11
- metadata +22 -38
- data/LICENSE.md +0 -21
- data/lib/sashite/feen/dumper/pieces_in_hand.rb +0 -149
- data/lib/sashite/feen/hands.rb +0 -80
- data/lib/sashite/feen/parser/pieces_in_hand.rb +0 -243
- data/lib/sashite/feen/placement.rb +0 -307
- data/lib/sashite/feen/position.rb +0 -77
- data/lib/sashite/feen/styles.rb +0 -72
data/README.md
CHANGED
|
@@ -1,29 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# feen.rb
|
|
2
2
|
|
|
3
3
|
[](https://github.com/sashite/feen.rb/tags)
|
|
4
4
|
[](https://rubydoc.info/github/sashite/feen.rb/main)
|
|
5
|
-
](https://github.com/sashite/feen.rb/raw/main/LICENSE
|
|
5
|
+
[](https://github.com/sashite/feen.rb/actions)
|
|
6
|
+
[](https://github.com/sashite/feen.rb/raw/main/LICENSE)
|
|
7
7
|
|
|
8
|
-
> **FEEN** (
|
|
8
|
+
> **FEEN** (Field Expression Encoding Notation) implementation for Ruby.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Overview
|
|
11
11
|
|
|
12
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
##
|
|
42
|
+
## Dependencies
|
|
37
43
|
|
|
38
44
|
```ruby
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
+
### Parsing (FEEN String → Qi)
|
|
74
51
|
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
###
|
|
96
|
+
### Dumping (Qi → FEEN String)
|
|
107
97
|
|
|
108
|
-
|
|
98
|
+
Convert a `Qi` back to a canonical FEEN string.
|
|
109
99
|
|
|
110
100
|
```ruby
|
|
111
|
-
|
|
112
|
-
position
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
###
|
|
124
|
-
|
|
125
|
-
Represents the board configuration as a flat array of ranks with explicit separators.
|
|
113
|
+
### Validation
|
|
126
114
|
|
|
127
115
|
```ruby
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
The `to_a` method returns an array representation that adapts to the board's dimensionality:
|
|
126
|
+
### Round-trip Examples
|
|
142
127
|
|
|
143
|
-
|
|
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
|
-
#
|
|
148
|
-
feen = "
|
|
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
|
|
151
|
-
# => [K, nil, nil, P, nil, nil, nil, k]
|
|
134
|
+
Sashite::Feen.dump(position) == feen # => true
|
|
152
135
|
|
|
153
|
-
#
|
|
154
|
-
feen = "
|
|
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
|
|
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
|
-
|
|
142
|
+
### Hands
|
|
226
143
|
|
|
227
|
-
|
|
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
|
-
#
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
#
|
|
316
|
-
|
|
317
|
-
large_board.placement.total_squares # => 300
|
|
151
|
+
position.first_player_hand # => { "P" => 1 }
|
|
152
|
+
position.second_player_hand # => { "p" => 1 }
|
|
318
153
|
|
|
319
|
-
#
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
###
|
|
163
|
+
### Multi-dimensional Boards
|
|
325
164
|
|
|
326
|
-
|
|
165
|
+
`Qi` supports 1D, 2D, and 3D boards natively.
|
|
327
166
|
|
|
328
167
|
```ruby
|
|
329
|
-
#
|
|
330
|
-
feen = "
|
|
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
|
-
#
|
|
334
|
-
|
|
335
|
-
position
|
|
336
|
-
position.
|
|
337
|
-
|
|
338
|
-
#
|
|
339
|
-
|
|
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
|
-
###
|
|
184
|
+
### Style–Turn Mapping
|
|
350
185
|
|
|
351
|
-
FEEN
|
|
186
|
+
The FEEN style–turn field maps directly to `Qi`'s style and turn accessors.
|
|
352
187
|
|
|
353
188
|
```ruby
|
|
354
|
-
#
|
|
355
|
-
|
|
356
|
-
position
|
|
357
|
-
|
|
358
|
-
position.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
position.
|
|
362
|
-
|
|
363
|
-
#
|
|
364
|
-
|
|
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
|
-
|
|
202
|
+
## API Reference
|
|
368
203
|
|
|
369
|
-
|
|
204
|
+
### Module Methods
|
|
370
205
|
|
|
371
206
|
```ruby
|
|
372
|
-
#
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
###
|
|
234
|
+
### Constants
|
|
384
235
|
|
|
385
236
|
```ruby
|
|
386
|
-
#
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
+
Sashite::Feen.parse(input)
|
|
421
262
|
rescue Sashite::Feen::Error => e
|
|
422
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
Sashite::Feen.parse(
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
#
|
|
492
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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 [
|
|
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).
|