sashite-feen 0.1.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 +7 -0
- data/LICENSE.md +21 -0
- data/README.md +236 -0
- data/lib/sashite/feen/dumper/piece_placement.rb +86 -0
- data/lib/sashite/feen/dumper/pieces_in_hand.rb +59 -0
- data/lib/sashite/feen/dumper/style_turn.rb +59 -0
- data/lib/sashite/feen/dumper.rb +49 -0
- data/lib/sashite/feen/error.rb +40 -0
- data/lib/sashite/feen/hands.rb +38 -0
- data/lib/sashite/feen/ordering.rb +16 -0
- data/lib/sashite/feen/parser/piece_placement.rb +153 -0
- data/lib/sashite/feen/parser/pieces_in_hand.rb +77 -0
- data/lib/sashite/feen/parser/style_turn.rb +64 -0
- data/lib/sashite/feen/parser.rb +35 -0
- data/lib/sashite/feen/placement.rb +31 -0
- data/lib/sashite/feen/position.rb +26 -0
- data/lib/sashite/feen/styles.rb +75 -0
- data/lib/sashite/feen.rb +106 -0
- data/lib/sashite-feen.rb +14 -0
- metadata +101 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5bcbcddcc2de55695f9a203e30fa5d3d236deaf7c0e3975011ae1a47f389cd7c
|
|
4
|
+
data.tar.gz: 98b184086642d30761cf4646dbeac1ca0a88860c11d18a15866f01e7f0b0e040
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cb41ec980de1a7ae5237da821f0a3be0cabe50768cb0dceec8988465336512a0705aec6f5aa9acfe1b0cf43770996581355029be8623a19d8a44dc737c628d83
|
|
7
|
+
data.tar.gz: 95507ce051c09837ca92da2b9447b2ef3f3c9a34fdc4e412cce0ed8e32595acf91acdbae37d2ed313315db9c0e29069edae0ec1c71770b3d1f853f20896a3c0c
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Sashité
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
Here you go — a lean, easy-to-read README that reflects the new “API / parser / dumper” design without drowning the reader in details.
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Feen.rb
|
|
6
|
+
|
|
7
|
+
> **FEEN** — Forsyth–Edwards Enhanced Notation for rule-agnostic board positions (Chess, Shōgi-like, Xiangqi-like, variants).
|
|
8
|
+
|
|
9
|
+
Purely functional, immutable Ruby implementation built on top of **EPIN** (piece identifiers) and **SIN** (style identifiers).
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Why FEEN?
|
|
14
|
+
|
|
15
|
+
* **Rule-agnostic**: expresses a board **position** without baking in game rules.
|
|
16
|
+
* **Portable & canonical**: a single, deterministic string per position.
|
|
17
|
+
* **Composable**: works nicely alongside other Sashité specs (e.g., STN for transitions).
|
|
18
|
+
|
|
19
|
+
FEEN strings have **three space-separated fields**:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
<piece_placement> <pieces_in_hand> <style_turn>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Add to your `Gemfile`:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
gem "sashite-feen"
|
|
33
|
+
````
|
|
34
|
+
|
|
35
|
+
Then:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
bundle install
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This gem depends on:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
gem "sashite-epin"
|
|
45
|
+
gem "sashite-sin"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Bundler will install them automatically when you use `sashite-feen`.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
require "sashite/feen"
|
|
56
|
+
|
|
57
|
+
# Parse
|
|
58
|
+
pos = Sashite::Feen.parse("<placement> <hands> <style1>/<style2>")
|
|
59
|
+
|
|
60
|
+
# Validate
|
|
61
|
+
Sashite::Feen.valid?("<your FEEN>") # => true/false
|
|
62
|
+
|
|
63
|
+
# Normalize (parse → canonical dump)
|
|
64
|
+
Sashite::Feen.normalize("<your FEEN>") # => canonical FEEN string
|
|
65
|
+
|
|
66
|
+
# Build from fields (strings)
|
|
67
|
+
pos = Sashite::Feen.build(
|
|
68
|
+
piece_placement: "<placement>",
|
|
69
|
+
pieces_in_hand: "<bagFirst>/<bagSecond>", # empty bags allowed: "/"
|
|
70
|
+
style_turn: "<activeSIN>/<inactiveSIN>"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Dump a Position (canonical)
|
|
74
|
+
Sashite::Feen.dump(pos) # => "<placement> <hands> <style1>/<style2>"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> **Tip:** FEEN itself does not do JSON; keep it minimal and functional. Serialize externally if needed.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Public API
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
Sashite::Feen.parse(str) # => Position (or raises Sashite::Feen::Error)
|
|
85
|
+
Sashite::Feen.valid?(str) # => Boolean
|
|
86
|
+
Sashite::Feen.dump(position) # => String (canonical)
|
|
87
|
+
Sashite::Feen.normalize(str) # => String (dump(parse(str)))
|
|
88
|
+
Sashite::Feen.build(
|
|
89
|
+
piece_placement:, pieces_in_hand:, style_turn:
|
|
90
|
+
) # => Position
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Position value-objects are immutable:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
pos.placement # => Sashite::Feen::Placement
|
|
97
|
+
pos.hands # => Sashite::Feen::Hands
|
|
98
|
+
pos.styles # => Sashite::Feen::Styles
|
|
99
|
+
pos.to_s # => canonical FEEN string (same as dump(pos))
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Canonicalization (short rules)
|
|
105
|
+
|
|
106
|
+
* **Piece placement (field 1)**
|
|
107
|
+
|
|
108
|
+
* Consecutive empties compress to digits `1..9`; runs `>9` are split into `"9"` + remainder.
|
|
109
|
+
* Digit `0` in empties is invalid.
|
|
110
|
+
* EPIN tokens are validated via `sashite-epin` and re-emitted canonically.
|
|
111
|
+
|
|
112
|
+
* **Pieces in hand (field 2)**
|
|
113
|
+
|
|
114
|
+
* Two concatenated bags separated by `/` (either side may be empty).
|
|
115
|
+
* Counts are aggregated; `1` is omitted in output.
|
|
116
|
+
* Deterministic sort per EPIN: quantity ↓, letter ↑ (case-insensitive), uppercase before lowercase, prefix `-` < `+` < none, suffix none < `'`.
|
|
117
|
+
|
|
118
|
+
* **Style-turn (field 3)**
|
|
119
|
+
|
|
120
|
+
* Exactly two SIN tokens separated by `/`.
|
|
121
|
+
* Exactly **one uppercase** style (first player) and **one lowercase** style (second).
|
|
122
|
+
* The **first token is the active** player’s style.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Design overview
|
|
127
|
+
|
|
128
|
+
The gem is small, layered, and testable:
|
|
129
|
+
|
|
130
|
+
* **API**: `Sashite::Feen` (parse / valid? / dump / normalize / build)
|
|
131
|
+
* **Value objects**: `Position`, `Placement`, `Hands`, `Styles` (immutable, canonical)
|
|
132
|
+
* **Parser**: `Feen::Parser` orchestrates field parsers (`Parser::PiecePlacement`, `Parser::PiecesInHand`, `Parser::StyleTurn`)
|
|
133
|
+
* **Dumper**: `Feen::Dumper` orchestrates field dumpers (`Dumper::PiecePlacement`, `Dumper::PiecesInHand`, `Dumper::StyleTurn`)
|
|
134
|
+
* **Ordering**: `Feen::Ordering` — single comparator used by the hands dumper
|
|
135
|
+
* **Errors**: `Feen::Error` (see below)
|
|
136
|
+
|
|
137
|
+
### Project layout
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
lib/
|
|
141
|
+
├─ sashite-feen.rb
|
|
142
|
+
└─ sashite/
|
|
143
|
+
├─ feen.rb # Public API
|
|
144
|
+
└─ feen/
|
|
145
|
+
├─ error.rb
|
|
146
|
+
├─ position.rb
|
|
147
|
+
├─ placement.rb
|
|
148
|
+
├─ hands.rb
|
|
149
|
+
├─ styles.rb
|
|
150
|
+
├─ ordering.rb
|
|
151
|
+
├─ parser.rb
|
|
152
|
+
├─ parser/
|
|
153
|
+
│ ├─ piece_placement.rb
|
|
154
|
+
│ ├─ pieces_in_hand.rb
|
|
155
|
+
│ └─ style_turn.rb
|
|
156
|
+
├─ dumper.rb
|
|
157
|
+
└─ dumper/
|
|
158
|
+
├─ piece_placement.rb
|
|
159
|
+
├─ pieces_in_hand.rb
|
|
160
|
+
└─ style_turn.rb
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
> Version is defined outside of `lib/sashite/feen/version.rb` (e.g., `VERSION.semver`).
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Errors
|
|
168
|
+
|
|
169
|
+
Rescue at the granularity you need:
|
|
170
|
+
|
|
171
|
+
* `Sashite::Feen::Error::Syntax` – tokenization/field arity
|
|
172
|
+
* `Sashite::Feen::Error::Piece` – EPIN validation failures
|
|
173
|
+
* `Sashite::Feen::Error::Style` – SIN validation/case issues
|
|
174
|
+
* `Sashite::Feen::Error::Count` – invalid counts in hands
|
|
175
|
+
* `Sashite::Feen::Error::Bounds` – internal dimension constraints (when relevant)
|
|
176
|
+
* `Sashite::Feen::Error::Validation` – generic structural/semantic errors
|
|
177
|
+
|
|
178
|
+
Example:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
begin
|
|
182
|
+
pos = Sashite::Feen.parse(str)
|
|
183
|
+
rescue Sashite::Feen::Error::Style => e
|
|
184
|
+
warn "Bad style-turn: #{e.message}"
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Dependencies & compatibility
|
|
191
|
+
|
|
192
|
+
* Runtime: `sashite-epin`, `sashite-sin`
|
|
193
|
+
* Purely functional; all objects are frozen; methods return new values.
|
|
194
|
+
* No JSON serialization in this gem.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Development
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
# Clone
|
|
202
|
+
git clone https://github.com/sashite/feen.rb.git
|
|
203
|
+
cd feen.rb
|
|
204
|
+
|
|
205
|
+
# Install
|
|
206
|
+
bundle install
|
|
207
|
+
|
|
208
|
+
# Run smoke tests
|
|
209
|
+
ruby test.rb
|
|
210
|
+
|
|
211
|
+
# Generate YARD docs
|
|
212
|
+
yard doc
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Contributing
|
|
218
|
+
|
|
219
|
+
1. Fork the repository
|
|
220
|
+
2. Create a feature branch: `git checkout -b feat/my-change`
|
|
221
|
+
3. Add tests covering your changes
|
|
222
|
+
4. Ensure everything is green (lint, tests, docs)
|
|
223
|
+
5. Commit with a conventional message
|
|
224
|
+
6. Push and open a Pull Request
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
Open source under the [MIT License](https://opensource.org/licenses/MIT).
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## About
|
|
235
|
+
|
|
236
|
+
Maintained by **Sashité** — promoting chess variants and sharing the beauty of board-game cultures.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Feen
|
|
5
|
+
module Dumper
|
|
6
|
+
module PiecePlacement
|
|
7
|
+
# Separator between ranks
|
|
8
|
+
RANK_SEPARATOR = "/"
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Dump a Placement grid to FEEN ranks (e.g., "rnbqkbnr/pppppppp/8/...")
|
|
13
|
+
#
|
|
14
|
+
# @param placement [Sashite::Feen::Placement]
|
|
15
|
+
# @return [String]
|
|
16
|
+
def dump(placement)
|
|
17
|
+
pl = _coerce_placement(placement)
|
|
18
|
+
|
|
19
|
+
grid = pl.grid
|
|
20
|
+
raise Error::Bounds, "empty grid" if grid.empty?
|
|
21
|
+
raise Error::Bounds, "grid must be an Array of rows" unless grid.is_a?(Array)
|
|
22
|
+
|
|
23
|
+
width = nil
|
|
24
|
+
dumped_rows = grid.each_with_index.map do |row, r_idx|
|
|
25
|
+
raise Error::Bounds, "row #{r_idx + 1} must be an Array, got #{row.class}" unless row.is_a?(Array)
|
|
26
|
+
|
|
27
|
+
width ||= row.length
|
|
28
|
+
raise Error::Bounds, "row #{r_idx + 1} has zero width" if width.zero?
|
|
29
|
+
|
|
30
|
+
if row.length != width
|
|
31
|
+
raise Error::Bounds,
|
|
32
|
+
"inconsistent row width at row #{r_idx + 1} (expected #{width}, got #{row.length})"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
_dump_row(row, r_idx)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
dumped_rows.join(RANK_SEPARATOR)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# -- internals ---------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
# Accept nil (and legacy "") as empty cells
|
|
44
|
+
def _empty_cell?(cell)
|
|
45
|
+
cell.nil? || cell == ""
|
|
46
|
+
end
|
|
47
|
+
private_class_method :_empty_cell?
|
|
48
|
+
|
|
49
|
+
def _dump_row(row, r_idx)
|
|
50
|
+
out = +""
|
|
51
|
+
empty_run = 0
|
|
52
|
+
|
|
53
|
+
row.each_with_index do |cell, c_idx|
|
|
54
|
+
if _empty_cell?(cell)
|
|
55
|
+
empty_run += 1
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if empty_run.positive?
|
|
60
|
+
out << empty_run.to_s
|
|
61
|
+
empty_run = 0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
out << ::Sashite::Epin.dump(cell)
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
raise Error::Piece,
|
|
68
|
+
"invalid EPIN value at (row #{r_idx + 1}, col #{c_idx + 1}): #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
out << empty_run.to_s if empty_run.positive?
|
|
73
|
+
out
|
|
74
|
+
end
|
|
75
|
+
private_class_method :_dump_row
|
|
76
|
+
|
|
77
|
+
def _coerce_placement(obj)
|
|
78
|
+
return obj if obj.is_a?(Placement)
|
|
79
|
+
|
|
80
|
+
raise TypeError, "expected Sashite::Feen::Placement, got #{obj.class}"
|
|
81
|
+
end
|
|
82
|
+
private_class_method :_coerce_placement
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Feen
|
|
5
|
+
module Dumper
|
|
6
|
+
module PiecesInHand
|
|
7
|
+
# Separator between hand entries
|
|
8
|
+
ENTRY_SEPARATOR = ","
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Dump a Hands multiset to FEEN (e.g., "-", "P,2xN,R")
|
|
13
|
+
#
|
|
14
|
+
# Canonicalization:
|
|
15
|
+
# - entries sorted lexicographically by EPIN token
|
|
16
|
+
# - counts rendered as "NxTOKEN" when N > 1
|
|
17
|
+
#
|
|
18
|
+
# @param hands [Sashite::Feen::Hands]
|
|
19
|
+
# @return [String]
|
|
20
|
+
def dump(hands)
|
|
21
|
+
h = _coerce_hands(hands)
|
|
22
|
+
|
|
23
|
+
map = h.map
|
|
24
|
+
raise Error::Count, "negative counts are not allowed" if map.values.any? { |v| Integer(v).negative? }
|
|
25
|
+
|
|
26
|
+
return "-" if map.empty?
|
|
27
|
+
|
|
28
|
+
entries = map.map do |epin_value, count|
|
|
29
|
+
c = Integer(count)
|
|
30
|
+
raise Error::Count, "hand count must be >= 1, got #{c}" if c <= 0
|
|
31
|
+
|
|
32
|
+
token = begin
|
|
33
|
+
::Sashite::Epin.dump(epin_value)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
raise Error::Piece, "invalid EPIN value in hands: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
[token, c]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Sort by EPIN token for deterministic output
|
|
42
|
+
entries.sort_by! { |(token, _)| token }
|
|
43
|
+
|
|
44
|
+
entries.map { |token, c| c == 1 ? token : "#{c}x#{token}" }
|
|
45
|
+
.join(ENTRY_SEPARATOR)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# -- helpers -----------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def _coerce_hands(obj)
|
|
51
|
+
return obj if obj.is_a?(Hands)
|
|
52
|
+
|
|
53
|
+
raise TypeError, "expected Sashite::Feen::Hands, got #{obj.class}"
|
|
54
|
+
end
|
|
55
|
+
private_class_method :_coerce_hands
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Feen
|
|
5
|
+
module Dumper
|
|
6
|
+
module StyleTurn
|
|
7
|
+
# Separator between turn and styles
|
|
8
|
+
TURN_STYLES_SEPARATOR = ";"
|
|
9
|
+
# Separator between multiple style tokens
|
|
10
|
+
STYLES_SEPARATOR = ","
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Dump the style/turn field (e.g., "w", "b;rule1,variantX")
|
|
15
|
+
#
|
|
16
|
+
# Canonicalization:
|
|
17
|
+
# - styles sorted lexicographically by SIN token
|
|
18
|
+
#
|
|
19
|
+
# @param styles [Sashite::Feen::Styles]
|
|
20
|
+
# @return [String]
|
|
21
|
+
def dump(styles)
|
|
22
|
+
st = _coerce_styles(styles)
|
|
23
|
+
|
|
24
|
+
turn_str = _dump_turn(st.turn)
|
|
25
|
+
|
|
26
|
+
return turn_str if st.list.nil? || st.list.empty?
|
|
27
|
+
|
|
28
|
+
tokens = st.list.map do |sin_value|
|
|
29
|
+
::Sashite::Sin.dump(sin_value)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
raise Error::Style, "invalid SIN value in styles: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
tokens.sort!
|
|
35
|
+
"#{turn_str}#{TURN_STYLES_SEPARATOR}#{tokens.join(STYLES_SEPARATOR)}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# -- internals ---------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def _dump_turn(turn)
|
|
41
|
+
case turn
|
|
42
|
+
when :first then "w"
|
|
43
|
+
when :second then "b"
|
|
44
|
+
else
|
|
45
|
+
raise Error::Style, "invalid turn symbol #{turn.inspect}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
private_class_method :_dump_turn
|
|
49
|
+
|
|
50
|
+
def _coerce_styles(obj)
|
|
51
|
+
return obj if obj.is_a?(Styles)
|
|
52
|
+
|
|
53
|
+
raise TypeError, "expected Sashite::Feen::Styles, got #{obj.class}"
|
|
54
|
+
end
|
|
55
|
+
private_class_method :_coerce_styles
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# FEEN Dumper (entry point)
|
|
4
|
+
# -------------------------
|
|
5
|
+
# Serializes a Position object into its canonical FEEN string by delegating
|
|
6
|
+
# each field to its dedicated sub-dumper.
|
|
7
|
+
#
|
|
8
|
+
# Sub-dumpers:
|
|
9
|
+
# dumper/piece_placement.rb
|
|
10
|
+
# dumper/pieces_in_hand.rb
|
|
11
|
+
# dumper/style_turn.rb
|
|
12
|
+
|
|
13
|
+
require_relative "dumper/piece_placement"
|
|
14
|
+
require_relative "dumper/pieces_in_hand"
|
|
15
|
+
require_relative "dumper/style_turn"
|
|
16
|
+
|
|
17
|
+
module Sashite
|
|
18
|
+
module Feen
|
|
19
|
+
module Dumper
|
|
20
|
+
# Separator used between the three FEEN fields
|
|
21
|
+
FIELD_SEPARATOR = " "
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Dump a Position into a FEEN string
|
|
26
|
+
#
|
|
27
|
+
# @param position [Sashite::Feen::Position]
|
|
28
|
+
# @return [String]
|
|
29
|
+
def dump(position)
|
|
30
|
+
pos = _coerce_position(position)
|
|
31
|
+
|
|
32
|
+
[
|
|
33
|
+
PiecePlacement.dump(pos.placement),
|
|
34
|
+
PiecesInHand.dump(pos.hands),
|
|
35
|
+
StyleTurn.dump(pos.styles)
|
|
36
|
+
].join(FIELD_SEPARATOR)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# -- helpers -------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def _coerce_position(obj)
|
|
42
|
+
return obj if obj.is_a?(Position)
|
|
43
|
+
|
|
44
|
+
raise TypeError, "expected Sashite::Feen::Position, got #{obj.class}"
|
|
45
|
+
end
|
|
46
|
+
private_class_method :_coerce_position
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Feen
|
|
5
|
+
# Namespaced error types for FEEN
|
|
6
|
+
module Error
|
|
7
|
+
# Base FEEN error (immutable, with optional context payload)
|
|
8
|
+
class Base < StandardError
|
|
9
|
+
# @return [Hash, nil] optional contextual information (e.g., { rank: 3, col: 5 })
|
|
10
|
+
attr_reader :context
|
|
11
|
+
|
|
12
|
+
# @param message [String, nil]
|
|
13
|
+
# @param context [Hash, nil] optional structured context (will be frozen)
|
|
14
|
+
def initialize(message = nil, context: nil)
|
|
15
|
+
@context = context&.dup&.freeze
|
|
16
|
+
super(message)
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised when the FEEN text does not match the required grammar
|
|
22
|
+
class Syntax < Base; end
|
|
23
|
+
|
|
24
|
+
# Raised for structural/semantic violations after syntactic parsing
|
|
25
|
+
class Validation < Base; end
|
|
26
|
+
|
|
27
|
+
# Raised when an EPIN token/value is invalid in the current context
|
|
28
|
+
class Piece < Base; end
|
|
29
|
+
|
|
30
|
+
# Raised when a SIN token/value or the style/turn field is invalid
|
|
31
|
+
class Style < Base; end
|
|
32
|
+
|
|
33
|
+
# Raised when a numeric count (e.g., run-length or hand quantity) is invalid
|
|
34
|
+
class Count < Base; end
|
|
35
|
+
|
|
36
|
+
# Raised when board/grid dimensions are empty/inconsistent/out of bounds
|
|
37
|
+
class Bounds < Base; end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Feen
|
|
5
|
+
# Immutable multiset of pieces-in-hand, keyed by EPIN value (as returned by Sashite::Epin.parse)
|
|
6
|
+
class Hands
|
|
7
|
+
attr_reader :map
|
|
8
|
+
|
|
9
|
+
# @param map [Hash<any, Integer>] counts per EPIN value
|
|
10
|
+
def initialize(map)
|
|
11
|
+
raise TypeError, "hands map must be a Hash, got #{map.class}" unless map.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
# Coerce counts to Integer and validate
|
|
14
|
+
coerced = {}
|
|
15
|
+
map.each do |k, v|
|
|
16
|
+
c = Integer(v)
|
|
17
|
+
raise Error::Count, "hand count must be >= 0, got #{c}" if c.negative?
|
|
18
|
+
next if c.zero? # normalize: skip zeros
|
|
19
|
+
|
|
20
|
+
coerced[k] = c
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Freeze shallowly (keys may already be complex frozen EPIN values)
|
|
24
|
+
@map = coerced.each_with_object({}) { |(k, v), h| h[k] = v }.freeze
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Convenience
|
|
29
|
+
def empty?
|
|
30
|
+
@map.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def each(&)
|
|
34
|
+
@map.each(&)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Feen
|
|
5
|
+
# Deterministic ordering helpers (kept minimal for now).
|
|
6
|
+
# If you later need domain-specific sort (e.g., EPIN-aware), centralize it here.
|
|
7
|
+
module Ordering
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Default lexicographic sort key for serialized EPIN tokens (String)
|
|
11
|
+
def hand_token_key(token_str)
|
|
12
|
+
String(token_str)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Feen
|
|
5
|
+
module Parser
|
|
6
|
+
module PiecePlacement
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Parse the piece placement field into a Placement value object.
|
|
10
|
+
#
|
|
11
|
+
# Grammar (pragmatic):
|
|
12
|
+
# placement := rank ( '/' rank | newline rank )*
|
|
13
|
+
# rank := ( int | '.' | epin | sep )*
|
|
14
|
+
# int := [1-9][0-9]* # run-length of empty cells
|
|
15
|
+
# sep := (',' | whitespace)*
|
|
16
|
+
# epin := bracketed_epin | bare_epin
|
|
17
|
+
# bracketed_epin := '[' ... ']' # balanced brackets
|
|
18
|
+
# bare_epin := /[A-Za-z0-9:+\-^~@']+/
|
|
19
|
+
#
|
|
20
|
+
# @param field [String]
|
|
21
|
+
# @return [Sashite::Feen::Placement]
|
|
22
|
+
def parse(field)
|
|
23
|
+
src = String(field)
|
|
24
|
+
raise Error::Syntax, "empty piece placement field" if src.strip.empty?
|
|
25
|
+
|
|
26
|
+
ranks = src.split(%r{(?:/|\R)}).map(&:strip)
|
|
27
|
+
raise Error::Syntax, "no ranks in piece placement" if ranks.empty?
|
|
28
|
+
|
|
29
|
+
grid = []
|
|
30
|
+
width = nil
|
|
31
|
+
|
|
32
|
+
ranks.each_with_index do |rank, r_idx|
|
|
33
|
+
row = _parse_rank(rank, r_idx)
|
|
34
|
+
width ||= row.length
|
|
35
|
+
raise Error::Bounds, "rank #{r_idx + 1} has zero width" if width.zero?
|
|
36
|
+
|
|
37
|
+
if row.length != width
|
|
38
|
+
raise Error::Bounds,
|
|
39
|
+
"inconsistent rank width at rank #{r_idx + 1} (expected #{width}, got #{row.length})"
|
|
40
|
+
end
|
|
41
|
+
grid << row.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
raise Error::Bounds, "empty grid" if grid.empty?
|
|
45
|
+
|
|
46
|
+
Placement.new(grid.freeze)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# -- internals ---------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
# Accepts:
|
|
52
|
+
# - digits => run of empties
|
|
53
|
+
# - '.' => single empty
|
|
54
|
+
# - '['...']' => EPIN token (balanced)
|
|
55
|
+
# - bare token composed of epin-safe chars
|
|
56
|
+
# - commas/whitespace ignored
|
|
57
|
+
def _parse_rank(rank_str, r_idx)
|
|
58
|
+
i = 0
|
|
59
|
+
n = rank_str.length
|
|
60
|
+
cells = []
|
|
61
|
+
|
|
62
|
+
while i < n
|
|
63
|
+
ch = rank_str[i]
|
|
64
|
+
|
|
65
|
+
# Skip separators
|
|
66
|
+
if ch == "," || ch =~ /\s/
|
|
67
|
+
i += 1
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Dot => single empty
|
|
72
|
+
if ch == "."
|
|
73
|
+
cells << nil
|
|
74
|
+
i += 1
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Number => run of empties
|
|
79
|
+
if /\d/.match?(ch)
|
|
80
|
+
j = i + 1
|
|
81
|
+
j += 1 while j < n && rank_str[j] =~ /\d/
|
|
82
|
+
count = rank_str[i...j].to_i
|
|
83
|
+
raise Error::Count, "empty run must be >= 1 at rank #{r_idx + 1}" if count <= 0
|
|
84
|
+
|
|
85
|
+
cells.concat([nil] * count)
|
|
86
|
+
i = j
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Bracketed EPIN token (balanced)
|
|
91
|
+
if ch == "["
|
|
92
|
+
token, j = _consume_bracketed(rank_str, i, r_idx)
|
|
93
|
+
cells << _parse_epin(token, r_idx, cells.length + 1)
|
|
94
|
+
i = j
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Bare EPIN token
|
|
99
|
+
token, j = _consume_bare(rank_str, i)
|
|
100
|
+
if token.empty?
|
|
101
|
+
raise Error::Piece,
|
|
102
|
+
"unexpected character #{rank_str[i].inspect} at rank #{r_idx + 1}, col #{cells.length + 1}"
|
|
103
|
+
end
|
|
104
|
+
cells << _parse_epin(token, r_idx, cells.length + 1)
|
|
105
|
+
i = j
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
cells
|
|
109
|
+
end
|
|
110
|
+
module_function :_parse_rank
|
|
111
|
+
private_class_method :_parse_rank
|
|
112
|
+
|
|
113
|
+
# Consume a balanced bracketed token starting at index i (where str[i] == '[')
|
|
114
|
+
# Returns [token_without_brackets, next_index_after_closing_bracket]
|
|
115
|
+
def _consume_bracketed(str, i, r_idx)
|
|
116
|
+
j = i + 1
|
|
117
|
+
depth = 1
|
|
118
|
+
while j < str.length && depth.positive?
|
|
119
|
+
case str[j]
|
|
120
|
+
when "[" then depth += 1
|
|
121
|
+
when "]" then depth -= 1
|
|
122
|
+
end
|
|
123
|
+
j += 1
|
|
124
|
+
end
|
|
125
|
+
raise Error::Piece, "unterminated EPIN bracket at rank #{r_idx + 1}, index #{i}" unless depth.zero?
|
|
126
|
+
|
|
127
|
+
[str[(i + 1)...(j - 1)], j]
|
|
128
|
+
end
|
|
129
|
+
private_class_method :_consume_bracketed
|
|
130
|
+
|
|
131
|
+
# Consume a run of bare EPIN-safe characters.
|
|
132
|
+
# We choose a wide, permissive class to avoid rejecting valid EPINs that include
|
|
133
|
+
# promotions/suffixes: letters, digits, + : - ^ ~ @ '
|
|
134
|
+
def _consume_bare(str, i)
|
|
135
|
+
j = i
|
|
136
|
+
# Autorisés : lettres + modificateurs (+ - : ^ ~ @ ')
|
|
137
|
+
j += 1 while j < str.length && str[j] =~ /[A-Za-z:+\-^~@']/
|
|
138
|
+
[str[i...j], j]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private_class_method :_consume_bare
|
|
142
|
+
|
|
143
|
+
def _parse_epin(token, r_idx, c_idx)
|
|
144
|
+
::Sashite::Epin.parse(token)
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
raise Error::Piece,
|
|
147
|
+
"invalid EPIN token at (rank #{r_idx + 1}, col #{c_idx}): #{token.inspect} (#{e.message})"
|
|
148
|
+
end
|
|
149
|
+
private_class_method :_parse_epin
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Feen
|
|
5
|
+
module Parser
|
|
6
|
+
module PiecesInHand
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Parse the pieces-in-hand field into a Hands value object.
|
|
10
|
+
#
|
|
11
|
+
# Strictness:
|
|
12
|
+
# - "-" => empty hands (valid)
|
|
13
|
+
# - "" => invalid (raises Error::Syntax)
|
|
14
|
+
#
|
|
15
|
+
# Grammar (tolerant for counts notation):
|
|
16
|
+
# hands := "-" | entry ("," entry)*
|
|
17
|
+
# entry := epin | int ("x"|"*") epin | epin ("x"|"*") int
|
|
18
|
+
# int := [1-9][0-9]*
|
|
19
|
+
#
|
|
20
|
+
# @param field [String]
|
|
21
|
+
# @return [Sashite::Feen::Hands]
|
|
22
|
+
def parse(field)
|
|
23
|
+
src = String(field).strip
|
|
24
|
+
raise Error::Syntax, "empty hands field" if src.empty?
|
|
25
|
+
return Hands.new({}.freeze) if src == "-"
|
|
26
|
+
|
|
27
|
+
entries = src.split(",").map(&:strip).reject(&:empty?)
|
|
28
|
+
raise Error::Syntax, "malformed hands field" if entries.empty?
|
|
29
|
+
|
|
30
|
+
counts = Hash.new(0)
|
|
31
|
+
|
|
32
|
+
entries.each_with_index do |entry, idx|
|
|
33
|
+
epin_token, qty = _parse_hand_entry(entry, idx)
|
|
34
|
+
epin_id = _parse_epin(epin_token, idx)
|
|
35
|
+
counts[epin_id] += qty
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
frozen_counts = {}
|
|
39
|
+
counts.each { |k, v| frozen_counts[k] = Integer(v) }
|
|
40
|
+
|
|
41
|
+
Hands.new(frozen_counts.freeze)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Accepts forms: "P", "2xP", "P*2", "10*R", "[Shogi:P]*3"
|
|
45
|
+
def _parse_hand_entry(str, _idx)
|
|
46
|
+
s = str.strip
|
|
47
|
+
|
|
48
|
+
if (m = /\A(\d+)\s*[x*]\s*(.+)\z/.match(s))
|
|
49
|
+
n = Integer(m[1])
|
|
50
|
+
raise Error::Count, "hand count must be >= 1, got #{n}" if n <= 0
|
|
51
|
+
|
|
52
|
+
return [m[2].strip, n]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if (m = /\A(.+?)\s*[x*]\s*(\d+)\z/.match(s))
|
|
56
|
+
n = Integer(m[2])
|
|
57
|
+
raise Error::Count, "hand count must be >= 1, got #{n}" if n <= 0
|
|
58
|
+
|
|
59
|
+
return [m[1].strip, n]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Default: single piece
|
|
63
|
+
[s, 1]
|
|
64
|
+
end
|
|
65
|
+
module_function :_parse_hand_entry
|
|
66
|
+
private_class_method :_parse_hand_entry
|
|
67
|
+
|
|
68
|
+
def _parse_epin(token, idx)
|
|
69
|
+
::Sashite::Epin.parse(token)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
raise Error::Piece, "invalid EPIN token in hands (entry #{idx + 1}): #{token.inspect} (#{e.message})"
|
|
72
|
+
end
|
|
73
|
+
private_class_method :_parse_epin
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Feen
|
|
5
|
+
module Parser
|
|
6
|
+
module StyleTurn
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Strict FEEN + SIN:
|
|
10
|
+
# style_turn := LETTER "/" LETTER # no whitespace
|
|
11
|
+
# semantics :
|
|
12
|
+
# - Uppercase marks the side to move.
|
|
13
|
+
# - Exactly one uppercase among the two.
|
|
14
|
+
# - Each letter is a SIN style code (validated via Sashite::Sin.parse).
|
|
15
|
+
#
|
|
16
|
+
# Examples (valid):
|
|
17
|
+
# "C/c" -> first to move, first_style="C", second_style="C" (Chess vs Chess)
|
|
18
|
+
# "c/C" -> second to move
|
|
19
|
+
# "S/o" -> first to move, first_style="S" (Shogi), second_style="O" (Ōgi)
|
|
20
|
+
#
|
|
21
|
+
# Examples (invalid):
|
|
22
|
+
# "w" , "C / c", "Cc", "c/c", "C/C", "x/y " (wrong pattern or spaces)
|
|
23
|
+
#
|
|
24
|
+
# @param field [String]
|
|
25
|
+
# @return [Sashite::Feen::Styles] with signature Styles.new(first_style, second_style, turn)
|
|
26
|
+
def parse(field)
|
|
27
|
+
s = String(field)
|
|
28
|
+
raise Error::Syntax, "empty style/turn field" if s.empty?
|
|
29
|
+
raise Error::Syntax, "whitespace not allowed in style/turn" if s.match?(/\s/)
|
|
30
|
+
|
|
31
|
+
m = %r{\A([A-Za-z])/([A-Za-z])\z}.match(s)
|
|
32
|
+
raise Error::Syntax, "invalid style/turn format" unless m
|
|
33
|
+
|
|
34
|
+
a_raw = m[1]
|
|
35
|
+
b_raw = m[2]
|
|
36
|
+
a_is_up = a_raw.between?("A", "Z")
|
|
37
|
+
b_is_up = b_raw.between?("A", "Z")
|
|
38
|
+
|
|
39
|
+
# Exactly one uppercase marks side to move
|
|
40
|
+
raise Error::Style, "ambiguous side-to-move: exactly one letter must be uppercase" unless a_is_up ^ b_is_up
|
|
41
|
+
|
|
42
|
+
# Canonical SIN tokens are uppercase (style identity is case-insensitive in FEEN)
|
|
43
|
+
a_tok = a_raw.upcase
|
|
44
|
+
b_tok = b_raw.upcase
|
|
45
|
+
|
|
46
|
+
first_style = begin
|
|
47
|
+
::Sashite::Sin.parse(a_tok)
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
raise Error::Style, "invalid SIN token for first side #{a_tok.inspect}: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
second_style = begin
|
|
53
|
+
::Sashite::Sin.parse(b_tok)
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
raise Error::Style, "invalid SIN token for second side #{b_tok.inspect}: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
turn = a_is_up ? :first : :second
|
|
59
|
+
Styles.new(first_style, second_style, turn)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser/piece_placement"
|
|
4
|
+
require_relative "parser/pieces_in_hand"
|
|
5
|
+
require_relative "parser/style_turn"
|
|
6
|
+
|
|
7
|
+
module Sashite
|
|
8
|
+
module Feen
|
|
9
|
+
module Parser
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def parse(feen)
|
|
13
|
+
s = String(feen).strip
|
|
14
|
+
raise Error::Syntax, "empty FEEN input" if s.empty?
|
|
15
|
+
|
|
16
|
+
a, b, c = _split_3_fields(s)
|
|
17
|
+
placement = PiecePlacement.parse(a)
|
|
18
|
+
hands = PiecesInHand.parse(b)
|
|
19
|
+
styles = StyleTurn.parse(c)
|
|
20
|
+
|
|
21
|
+
Position.new(placement, hands, styles)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def _split_3_fields(s)
|
|
25
|
+
parts = s.split(/\s+/, 3)
|
|
26
|
+
unless parts.length == 3
|
|
27
|
+
raise Error::Syntax,
|
|
28
|
+
"FEEN must have 3 whitespace-separated fields (got #{parts.length})"
|
|
29
|
+
end
|
|
30
|
+
parts
|
|
31
|
+
end
|
|
32
|
+
private_class_method :_split_3_fields
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Feen
|
|
5
|
+
# Immutable board placement: rectangular grid of cells (nil = empty, otherwise EPIN value)
|
|
6
|
+
class Placement
|
|
7
|
+
attr_reader :grid, :height, :width
|
|
8
|
+
|
|
9
|
+
# @param grid [Array<Array>]
|
|
10
|
+
# Each row is an Array; all rows must have identical length.
|
|
11
|
+
def initialize(grid)
|
|
12
|
+
raise TypeError, "grid must be an Array of rows, got #{grid.class}" unless grid.is_a?(Array)
|
|
13
|
+
raise Error::Bounds, "grid cannot be empty" if grid.empty?
|
|
14
|
+
unless grid.all?(Array)
|
|
15
|
+
raise Error::Bounds, "grid must be an Array of rows (Array), got #{grid.map(&:class).inspect}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
widths = grid.map(&:length)
|
|
19
|
+
width = widths.first || 0
|
|
20
|
+
raise Error::Bounds, "rows cannot be empty" if width.zero?
|
|
21
|
+
raise Error::Bounds, "inconsistent row width (#{widths.uniq.join(', ')})" if widths.any? { |w| w != width }
|
|
22
|
+
|
|
23
|
+
# Deep-freeze
|
|
24
|
+
@grid = grid.map { |row| row.dup.freeze }.freeze
|
|
25
|
+
@height = @grid.length
|
|
26
|
+
@width = width
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Feen
|
|
5
|
+
# Immutable aggregate for a FEEN position: placement + hands + styles
|
|
6
|
+
class Position
|
|
7
|
+
attr_reader :placement, :hands, :styles
|
|
8
|
+
|
|
9
|
+
# @param placement [Sashite::Feen::Placement]
|
|
10
|
+
# @param hands [Sashite::Feen::Hands]
|
|
11
|
+
# @param styles [Sashite::Feen::Styles]
|
|
12
|
+
def initialize(placement, hands, styles)
|
|
13
|
+
unless placement.is_a?(Placement)
|
|
14
|
+
raise TypeError, "placement must be Sashite::Feen::Placement, got #{placement.class}"
|
|
15
|
+
end
|
|
16
|
+
raise TypeError, "hands must be Sashite::Feen::Hands, got #{hands.class}" unless hands.is_a?(Hands)
|
|
17
|
+
raise TypeError, "styles must be Sashite::Feen::Styles, got #{styles.class}" unless styles.is_a?(Styles)
|
|
18
|
+
|
|
19
|
+
@placement = placement
|
|
20
|
+
@hands = hands
|
|
21
|
+
@styles = styles
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sashite/sin"
|
|
4
|
+
|
|
5
|
+
module Sashite
|
|
6
|
+
module Feen
|
|
7
|
+
# Immutable styles descriptor for FEEN style/turn field:
|
|
8
|
+
# - first_family : one-letter SIN family (Symbol, :A..:Z)
|
|
9
|
+
# - second_family : one-letter SIN family (Symbol, :A..:Z)
|
|
10
|
+
# - turn : :first or :second (uppercase on dumper for the side to move)
|
|
11
|
+
class Styles
|
|
12
|
+
attr_reader :first_family, :second_family, :turn
|
|
13
|
+
|
|
14
|
+
VALID_TURNS = %i[first second].freeze
|
|
15
|
+
|
|
16
|
+
# @param first_family [Symbol, String, Sashite::Sin::Identifier]
|
|
17
|
+
# @param second_family [Symbol, String, Sashite::Sin::Identifier]
|
|
18
|
+
# @param turn [:first, :second]
|
|
19
|
+
def initialize(first_family, second_family, turn)
|
|
20
|
+
@first_family = _coerce_family(first_family)
|
|
21
|
+
@second_family = _coerce_family(second_family)
|
|
22
|
+
@turn = _coerce_turn(turn)
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Helpers for dumper -----------------------------------------------------
|
|
27
|
+
|
|
28
|
+
# Return single-letter uppercase string for first/second family
|
|
29
|
+
def first_letter_uc
|
|
30
|
+
_family_letter_uc(@first_family)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def second_letter_uc
|
|
34
|
+
_family_letter_uc(@second_family)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def _coerce_turn(t)
|
|
40
|
+
raise ArgumentError, "turn must be :first or :second, got #{t.inspect}" unless VALID_TURNS.include?(t)
|
|
41
|
+
|
|
42
|
+
t
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Accepts SIN Identifier, Symbol, or String
|
|
46
|
+
# Canonical storage is a Symbol in :A..:Z (uppercase)
|
|
47
|
+
def _coerce_family(x)
|
|
48
|
+
family_sym =
|
|
49
|
+
case x
|
|
50
|
+
when ::Sashite::Sin::Identifier
|
|
51
|
+
x.family
|
|
52
|
+
when Symbol
|
|
53
|
+
x
|
|
54
|
+
else
|
|
55
|
+
s = String(x)
|
|
56
|
+
raise ArgumentError, "invalid SIN family #{x.inspect}" unless s.match?(/\A[A-Za-z]\z/)
|
|
57
|
+
|
|
58
|
+
s.upcase.to_sym
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
raise ArgumentError, "Family must be :A..:Z, got #{family_sym.inspect}" unless (:A..:Z).cover?(family_sym)
|
|
62
|
+
|
|
63
|
+
# Validate via SIN once (ensures family is recognized by sashite-sin)
|
|
64
|
+
raise Error::Style, "Unknown SIN family #{family_sym.inspect}" unless ::Sashite::Sin.valid?(family_sym.to_s)
|
|
65
|
+
|
|
66
|
+
family_sym
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def _family_letter_uc(family_sym)
|
|
70
|
+
# Build a canonical SIN identifier to get the letter; side doesn't matter for uc
|
|
71
|
+
::Sashite::Sin.identifier(family_sym, :first).to_s # uppercase
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/sashite/feen.rb
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Public API for FEEN (Forsyth–Edwards Enhanced Notation)
|
|
4
|
+
# - Pure functions, no global state
|
|
5
|
+
# - Immutable value objects
|
|
6
|
+
# - Delegates parsing/dumping to dedicated components
|
|
7
|
+
|
|
8
|
+
require "sashite/epin"
|
|
9
|
+
require "sashite/sin"
|
|
10
|
+
|
|
11
|
+
require_relative "feen/error"
|
|
12
|
+
require_relative "feen/position"
|
|
13
|
+
require_relative "feen/placement"
|
|
14
|
+
require_relative "feen/hands"
|
|
15
|
+
require_relative "feen/styles"
|
|
16
|
+
require_relative "feen/ordering"
|
|
17
|
+
require_relative "feen/parser"
|
|
18
|
+
require_relative "feen/dumper"
|
|
19
|
+
|
|
20
|
+
module Sashite
|
|
21
|
+
module Feen
|
|
22
|
+
class << self
|
|
23
|
+
# Parse a FEEN string into an immutable Position object.
|
|
24
|
+
#
|
|
25
|
+
# @param feen [String]
|
|
26
|
+
# @return [Sashite::Feen::Position]
|
|
27
|
+
def parse(feen)
|
|
28
|
+
s = String(feen).strip
|
|
29
|
+
raise Error::Syntax, "empty FEEN input" if s.empty?
|
|
30
|
+
|
|
31
|
+
Parser.parse(s).freeze
|
|
32
|
+
rescue ArgumentError => e
|
|
33
|
+
# Normalise en Syntax pour surface d'erreurs plus propre
|
|
34
|
+
raise Error::Syntax, e.message
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Validate a FEEN string.
|
|
38
|
+
#
|
|
39
|
+
# @param feen [String]
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def valid?(feen)
|
|
42
|
+
parse(feen)
|
|
43
|
+
true
|
|
44
|
+
rescue Error::Validation, Error::Syntax, Error::Piece, Error::Style, Error::Count, Error::Bounds
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Dump a Position to its canonical FEEN string.
|
|
49
|
+
#
|
|
50
|
+
# @param position [Sashite::Feen::Position, String] # String => parse puis dump
|
|
51
|
+
# @return [String] canonical FEEN
|
|
52
|
+
def dump(position)
|
|
53
|
+
pos = _coerce_position(position)
|
|
54
|
+
Dumper.dump(pos).dup.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Canonicalize a FEEN string (parse → dump).
|
|
58
|
+
#
|
|
59
|
+
# @param feen [String]
|
|
60
|
+
# @return [String] canonical FEEN
|
|
61
|
+
def normalize(feen)
|
|
62
|
+
dump(parse(feen))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Build a Position from its three FEEN fields.
|
|
66
|
+
#
|
|
67
|
+
# Each argument accepts either a String (parsed by its field parser)
|
|
68
|
+
# or the corresponding value-object (Placement/Hands/Styles).
|
|
69
|
+
#
|
|
70
|
+
# @param piece_placement [String, Sashite::Feen::Placement]
|
|
71
|
+
# @param pieces_in_hand [String, Sashite::Feen::Hands]
|
|
72
|
+
# @param style_turn [String, Sashite::Feen::Styles]
|
|
73
|
+
# @return [Sashite::Feen::Position]
|
|
74
|
+
def build(piece_placement:, pieces_in_hand:, style_turn:)
|
|
75
|
+
placement = _coerce_component(Placement, Parser::PiecePlacement, piece_placement)
|
|
76
|
+
hands = _coerce_component(Hands, Parser::PiecesInHand, pieces_in_hand)
|
|
77
|
+
styles = _coerce_component(Styles, Parser::StyleTurn, style_turn)
|
|
78
|
+
|
|
79
|
+
Position.new(placement, hands, styles).freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# -- helpers -------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _coerce_position(obj)
|
|
87
|
+
return obj if obj.is_a?(Position)
|
|
88
|
+
return parse(obj) if obj.is_a?(String)
|
|
89
|
+
|
|
90
|
+
raise TypeError, "expected Sashite::Feen::Position or FEEN String, got #{obj.class}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def _coerce_component(klass, field_parser_mod, value)
|
|
94
|
+
case value
|
|
95
|
+
when klass
|
|
96
|
+
value
|
|
97
|
+
when String
|
|
98
|
+
field_parser_mod.parse(value)
|
|
99
|
+
else
|
|
100
|
+
raise TypeError,
|
|
101
|
+
"expected #{klass} or String for #{klass.name.split('::').last.downcase}, got #{value.class}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/sashite-feen.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sashite/feen"
|
|
4
|
+
|
|
5
|
+
# Sashité namespace for board game notation libraries
|
|
6
|
+
#
|
|
7
|
+
# Sashité provides a collection of libraries for representing and manipulating
|
|
8
|
+
# board game concepts according to the Sashité Protocol specifications.
|
|
9
|
+
#
|
|
10
|
+
# @see https://sashite.dev/protocol/ Sashité Protocol
|
|
11
|
+
# @see https://sashite.dev/specs/ Sashité Specifications
|
|
12
|
+
# @author Sashité
|
|
13
|
+
module Sashite
|
|
14
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sashite-feen
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Cyril Kato
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: sashite-epin
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: sashite-sin
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.1'
|
|
40
|
+
description: |
|
|
41
|
+
FEEN (Forsyth—Edwards Enhanced Notation) provides a universal, rule-agnostic format for
|
|
42
|
+
representing board game positions. This gem implements the FEEN Specification v1.0.0 with
|
|
43
|
+
a modern Ruby interface featuring immutable position objects and functional programming
|
|
44
|
+
principles. FEEN extends traditional FEN notation to support multiple game systems (chess,
|
|
45
|
+
shōgi, xiangqi, makruk), cross-style games, multi-dimensional boards, and captured pieces
|
|
46
|
+
held in reserve. Built on EPIN (piece notation) and SIN (style notation) foundations,
|
|
47
|
+
FEEN enables canonical position representation across diverse abstract strategy board games.
|
|
48
|
+
Perfect for game engines, position analysis tools, and hybrid gaming systems requiring
|
|
49
|
+
comprehensive board state representation.
|
|
50
|
+
email: contact@cyril.email
|
|
51
|
+
executables: []
|
|
52
|
+
extensions: []
|
|
53
|
+
extra_rdoc_files: []
|
|
54
|
+
files:
|
|
55
|
+
- LICENSE.md
|
|
56
|
+
- README.md
|
|
57
|
+
- lib/sashite-feen.rb
|
|
58
|
+
- lib/sashite/feen.rb
|
|
59
|
+
- lib/sashite/feen/dumper.rb
|
|
60
|
+
- lib/sashite/feen/dumper/piece_placement.rb
|
|
61
|
+
- lib/sashite/feen/dumper/pieces_in_hand.rb
|
|
62
|
+
- lib/sashite/feen/dumper/style_turn.rb
|
|
63
|
+
- lib/sashite/feen/error.rb
|
|
64
|
+
- lib/sashite/feen/hands.rb
|
|
65
|
+
- lib/sashite/feen/ordering.rb
|
|
66
|
+
- lib/sashite/feen/parser.rb
|
|
67
|
+
- lib/sashite/feen/parser/piece_placement.rb
|
|
68
|
+
- lib/sashite/feen/parser/pieces_in_hand.rb
|
|
69
|
+
- lib/sashite/feen/parser/style_turn.rb
|
|
70
|
+
- lib/sashite/feen/placement.rb
|
|
71
|
+
- lib/sashite/feen/position.rb
|
|
72
|
+
- lib/sashite/feen/styles.rb
|
|
73
|
+
homepage: https://github.com/sashite/feen.rb
|
|
74
|
+
licenses:
|
|
75
|
+
- MIT
|
|
76
|
+
metadata:
|
|
77
|
+
bug_tracker_uri: https://github.com/sashite/feen.rb/issues
|
|
78
|
+
documentation_uri: https://rubydoc.info/github/sashite/feen.rb/main
|
|
79
|
+
homepage_uri: https://github.com/sashite/feen.rb
|
|
80
|
+
source_code_uri: https://github.com/sashite/feen.rb
|
|
81
|
+
specification_uri: https://sashite.dev/specs/feen/1.0.0/
|
|
82
|
+
rubygems_mfa_required: 'true'
|
|
83
|
+
rdoc_options: []
|
|
84
|
+
require_paths:
|
|
85
|
+
- lib
|
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: 3.2.0
|
|
91
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
requirements: []
|
|
97
|
+
rubygems_version: 3.7.1
|
|
98
|
+
specification_version: 4
|
|
99
|
+
summary: FEEN (Forsyth—Edwards Enhanced Notation) implementation for Ruby with universal
|
|
100
|
+
position representation
|
|
101
|
+
test_files: []
|