sashite-feen 0.1.0 → 0.2.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 +64 -183
- data/lib/sashite/feen/dumper/piece_placement.rb +86 -63
- data/lib/sashite/feen/dumper/pieces_in_hand.rb +124 -35
- data/lib/sashite/feen/dumper/style_turn.rb +29 -45
- data/lib/sashite/feen/dumper.rb +40 -30
- data/lib/sashite/feen/error.rb +72 -29
- data/lib/sashite/feen/hands.rb +62 -20
- data/lib/sashite/feen/parser/piece_placement.rb +302 -110
- data/lib/sashite/feen/parser/pieces_in_hand.rb +216 -44
- data/lib/sashite/feen/parser/style_turn.rb +81 -41
- data/lib/sashite/feen/parser.rb +52 -16
- data/lib/sashite/feen/placement.rb +67 -21
- data/lib/sashite/feen/position.rb +64 -13
- data/lib/sashite/feen/styles.rb +54 -57
- data/lib/sashite/feen.rb +57 -96
- metadata +1 -2
- data/lib/sashite/feen/ordering.rb +0 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98fd7fceb1edd4b6479a217c0932443c76335b4d41238b2ee0d3f2e3fc2dd397
|
|
4
|
+
data.tar.gz: 6f31b473124e985baab36d30bf84d3c7cf05d129d66bbfe16b4b60068a44c545
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f5c62abd73d7628ded615367f74644933615488fdffdceecb85f06f2c371fe96ad269b3c4722343fd59d6145c5650492f8e1b9ec9f660c1ed57be83877b7eaa
|
|
7
|
+
data.tar.gz: 19e3e8f686a1d114dc9da267209716b3c28372585c90d061c604ac6d13692b67cb15abbdf967ff62f125518c26a63506e73525b2115421a659c18094c19e270e
|
data/README.md
CHANGED
|
@@ -1,236 +1,117 @@
|
|
|
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
1
|
# Feen.rb
|
|
6
2
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
[](https://github.com/sashite/feen.rb/tags)
|
|
4
|
+
[](https://rubydoc.info/github/sashite/feen.rb/main)
|
|
5
|
+

|
|
6
|
+
[](https://github.com/sashite/feen.rb/raw/main/LICENSE.md)
|
|
10
7
|
|
|
11
|
-
|
|
8
|
+
> **FEEN** (Forsyth–Edwards Enhanced Notation) implementation for the Ruby language.
|
|
12
9
|
|
|
13
|
-
##
|
|
10
|
+
## What is FEEN?
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
* **Portable & canonical**: a single, deterministic string per position.
|
|
17
|
-
* **Composable**: works nicely alongside other Sashité specs (e.g., STN for transitions).
|
|
12
|
+
FEEN (Forsyth–Edwards Enhanced Notation) is a universal, rule-agnostic notation for representing board game positions. It extends traditional FEN to support multiple game systems, cross-style games, multi-dimensional boards, and captured pieces.
|
|
18
13
|
|
|
19
|
-
FEEN
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
<piece_placement> <pieces_in_hand> <style_turn>
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
---
|
|
14
|
+
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.
|
|
26
15
|
|
|
27
16
|
## Installation
|
|
28
17
|
|
|
29
|
-
Add to your `Gemfile`:
|
|
30
|
-
|
|
31
18
|
```ruby
|
|
32
19
|
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
20
|
```
|
|
47
21
|
|
|
48
|
-
|
|
22
|
+
## API
|
|
49
23
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
## Quick start
|
|
24
|
+
The library provides two methods for converting between FEEN strings and position objects:
|
|
53
25
|
|
|
54
26
|
```ruby
|
|
55
27
|
require "sashite/feen"
|
|
56
28
|
|
|
57
|
-
# Parse
|
|
58
|
-
|
|
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
|
|
29
|
+
# Parse a FEEN string into a position object
|
|
30
|
+
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")
|
|
65
31
|
|
|
66
|
-
#
|
|
67
|
-
|
|
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>"
|
|
32
|
+
# Dump a position object into a canonical FEEN string
|
|
33
|
+
feen_string = Sashite::Feen.dump(position)
|
|
75
34
|
```
|
|
76
35
|
|
|
77
|
-
|
|
36
|
+
### Methods
|
|
78
37
|
|
|
79
|
-
|
|
38
|
+
#### `Sashite::Feen.parse(string)`
|
|
80
39
|
|
|
81
|
-
|
|
40
|
+
Parses a FEEN string and returns an immutable `Position` object.
|
|
82
41
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
Sashite::Feen
|
|
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
|
-
```
|
|
42
|
+
- **Input**: FEEN string with three space-separated fields
|
|
43
|
+
- **Returns**: `Sashite::Feen::Position` instance
|
|
44
|
+
- **Raises**: `Sashite::Feen::Error` subclasses on invalid input
|
|
92
45
|
|
|
93
|
-
|
|
46
|
+
#### `Sashite::Feen.dump(position)`
|
|
94
47
|
|
|
95
|
-
|
|
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
|
-
```
|
|
48
|
+
Converts a position object into its canonical FEEN string representation.
|
|
101
49
|
|
|
102
|
-
|
|
50
|
+
- **Input**: `Sashite::Feen::Position` instance
|
|
51
|
+
- **Returns**: Canonical FEEN string
|
|
52
|
+
- **Guarantees**: Deterministic output (same position always produces same string)
|
|
103
53
|
|
|
104
|
-
|
|
54
|
+
### Position Object
|
|
105
55
|
|
|
106
|
-
|
|
56
|
+
The `Position` object returned by `parse` is immutable and provides read-only access to the three FEEN components:
|
|
107
57
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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:
|
|
58
|
+
```ruby
|
|
59
|
+
position.placement # => Placement object (board arrangement)
|
|
60
|
+
position.hands # => Hands object (pieces in hand)
|
|
61
|
+
position.styles # => Styles object (style-turn information)
|
|
62
|
+
position.to_s # => Canonical FEEN string (equivalent to dump)
|
|
63
|
+
```
|
|
129
64
|
|
|
130
|
-
|
|
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)
|
|
65
|
+
## Format
|
|
136
66
|
|
|
137
|
-
|
|
67
|
+
A FEEN string consists of three space-separated fields:
|
|
138
68
|
|
|
139
69
|
```
|
|
140
|
-
|
|
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
|
|
70
|
+
<piece-placement> <pieces-in-hand> <style-turn>
|
|
161
71
|
```
|
|
162
72
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
## Errors
|
|
73
|
+
1. **Piece placement**: Board configuration using EPIN notation
|
|
74
|
+
2. **Pieces in hand**: Captured pieces held by each player
|
|
75
|
+
3. **Style-turn**: Game styles and active player
|
|
168
76
|
|
|
169
|
-
|
|
77
|
+
For complete format details, see the [FEEN Specification](https://sashite.dev/specs/feen/1.0.0/).
|
|
170
78
|
|
|
171
|
-
|
|
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
|
|
79
|
+
## Error Handling
|
|
177
80
|
|
|
178
|
-
|
|
81
|
+
The library defines specific error classes for different validation failures:
|
|
179
82
|
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
83
|
+
```txt
|
|
84
|
+
Sashite::Feen::Error # Base error class
|
|
85
|
+
├── Error::Syntax # Malformed FEEN structure
|
|
86
|
+
├── Error::Piece # Invalid EPIN notation
|
|
87
|
+
├── Error::Style # Invalid SIN notation
|
|
88
|
+
├── Error::Count # Invalid piece counts
|
|
89
|
+
└── Error::Validation # Other semantic violations
|
|
186
90
|
```
|
|
187
91
|
|
|
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
|
|
92
|
+
## Properties
|
|
199
93
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
94
|
+
- **Purely functional**: Immutable data structures, no side effects
|
|
95
|
+
- **Canonical output**: Deterministic string generation
|
|
96
|
+
- **Specification compliant**: Strict adherence to FEEN v1.0.0
|
|
97
|
+
- **Minimal API**: Two methods for complete functionality
|
|
98
|
+
- **Composable**: Built on EPIN and SIN specifications
|
|
204
99
|
|
|
205
|
-
|
|
206
|
-
bundle install
|
|
100
|
+
## Dependencies
|
|
207
101
|
|
|
208
|
-
|
|
209
|
-
|
|
102
|
+
- [sashite-epin](https://github.com/sashite/epin.rb) – Extended Piece Identifier Notation
|
|
103
|
+
- [sashite-sin](https://github.com/sashite/sin.rb) – Style Identifier Notation
|
|
210
104
|
|
|
211
|
-
|
|
212
|
-
yard doc
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
---
|
|
216
|
-
|
|
217
|
-
## Contributing
|
|
105
|
+
## Documentation
|
|
218
106
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
---
|
|
107
|
+
- [FEEN Specification v1.0.0](https://sashite.dev/specs/feen/1.0.0/)
|
|
108
|
+
- [FEEN Examples](https://sashite.dev/specs/feen/1.0.0/examples/)
|
|
109
|
+
- [API Documentation](https://rubydoc.info/github/sashite/feen.rb/main)
|
|
227
110
|
|
|
228
111
|
## License
|
|
229
112
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
---
|
|
113
|
+
Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
|
|
233
114
|
|
|
234
115
|
## About
|
|
235
116
|
|
|
236
|
-
Maintained by
|
|
117
|
+
Maintained by [Sashité](https://sashite.com/) – promoting chess variants and sharing the beauty of board game cultures.
|
|
@@ -3,83 +3,106 @@
|
|
|
3
3
|
module Sashite
|
|
4
4
|
module Feen
|
|
5
5
|
module Dumper
|
|
6
|
+
# Dumper for the piece placement field (first field of FEEN).
|
|
7
|
+
#
|
|
8
|
+
# Converts a Placement object into its FEEN string representation,
|
|
9
|
+
# encoding board configuration using EPIN notation with empty square
|
|
10
|
+
# compression and multi-dimensional separator support.
|
|
11
|
+
#
|
|
12
|
+
# @see https://sashite.dev/specs/feen/1.0.0/
|
|
6
13
|
module PiecePlacement
|
|
7
|
-
#
|
|
14
|
+
# Rank separator for 2D boards.
|
|
8
15
|
RANK_SEPARATOR = "/"
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# Dump a Placement grid to FEEN ranks (e.g., "rnbqkbnr/pppppppp/8/...")
|
|
17
|
+
# Dump a Placement object into its FEEN piece placement string.
|
|
13
18
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
# Converts the board configuration into FEEN notation by processing
|
|
20
|
+
# each rank, compressing consecutive empty squares into digits, and
|
|
21
|
+
# joining ranks with appropriate separators for multi-dimensional boards.
|
|
22
|
+
#
|
|
23
|
+
# @param placement [Placement] The board placement object
|
|
24
|
+
# @return [String] FEEN piece placement field string
|
|
25
|
+
#
|
|
26
|
+
# @example Chess starting position
|
|
27
|
+
# dump(placement)
|
|
28
|
+
# # => "+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"
|
|
29
|
+
#
|
|
30
|
+
# @example Empty 8x8 board
|
|
31
|
+
# dump(placement)
|
|
32
|
+
# # => "8/8/8/8/8/8/8/8"
|
|
33
|
+
def self.dump(placement)
|
|
34
|
+
ranks = placement.ranks.map { |rank| dump_rank(rank) }
|
|
35
|
+
join_ranks(ranks, placement.dimension, placement.sections)
|
|
36
|
+
end
|
|
29
37
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
# Dump a single rank into its FEEN representation.
|
|
39
|
+
#
|
|
40
|
+
# Converts a rank (array of pieces and nils) into FEEN notation by:
|
|
41
|
+
# 1. Converting pieces to EPIN strings
|
|
42
|
+
# 2. Compressing consecutive nils into digit counts
|
|
43
|
+
#
|
|
44
|
+
# @param rank [Array] Array of piece objects and nils
|
|
45
|
+
# @return [String] FEEN rank string
|
|
46
|
+
#
|
|
47
|
+
# @example Rank with pieces and empty squares
|
|
48
|
+
# dump_rank([piece1, nil, nil, piece2])
|
|
49
|
+
# # => "K2Q"
|
|
50
|
+
private_class_method def self.dump_rank(rank)
|
|
51
|
+
result = []
|
|
52
|
+
empty_count = 0
|
|
53
|
+
|
|
54
|
+
rank.each do |square|
|
|
55
|
+
if square.nil?
|
|
56
|
+
empty_count += 1
|
|
57
|
+
else
|
|
58
|
+
result << empty_count.to_s if empty_count > 0
|
|
59
|
+
result << square.to_s
|
|
60
|
+
empty_count = 0
|
|
33
61
|
end
|
|
34
|
-
|
|
35
|
-
_dump_row(row, r_idx)
|
|
36
62
|
end
|
|
37
63
|
|
|
38
|
-
|
|
64
|
+
result << empty_count.to_s if empty_count > 0
|
|
65
|
+
result.join
|
|
39
66
|
end
|
|
40
67
|
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
# Join ranks with appropriate separators for multi-dimensional boards.
|
|
69
|
+
#
|
|
70
|
+
# Uses section information if available, otherwise treats all ranks equally.
|
|
71
|
+
#
|
|
72
|
+
# @param ranks [Array<String>] Array of rank strings
|
|
73
|
+
# @param dimension [Integer] Board dimensionality (default 2)
|
|
74
|
+
# @param sections [Array<Integer>, nil] Section sizes for grouping
|
|
75
|
+
# @return [String] Complete piece placement string
|
|
76
|
+
#
|
|
77
|
+
# @example 2D board
|
|
78
|
+
# join_ranks(["8", "8"], 2, nil)
|
|
79
|
+
# # => "8/8"
|
|
80
|
+
#
|
|
81
|
+
# @example 3D board with sections
|
|
82
|
+
# join_ranks(["5", "5", "5", "5"], 3, [2, 2])
|
|
83
|
+
# # => "5/5//5/5"
|
|
84
|
+
private_class_method def self.join_ranks(ranks, dimension = 2, sections = nil)
|
|
85
|
+
if dimension == 2 || sections.nil?
|
|
86
|
+
# Simple 2D case or no section info
|
|
87
|
+
separator = RANK_SEPARATOR * (dimension - 1)
|
|
88
|
+
ranks.join(separator)
|
|
89
|
+
else
|
|
90
|
+
# Multi-dimensional with section info
|
|
91
|
+
rank_separator = RANK_SEPARATOR
|
|
92
|
+
section_separator = RANK_SEPARATOR * (dimension - 1)
|
|
93
|
+
|
|
94
|
+
# Group ranks by sections
|
|
95
|
+
result = []
|
|
96
|
+
offset = 0
|
|
97
|
+
sections.each do |section_size|
|
|
98
|
+
section_ranks = ranks[offset, section_size]
|
|
99
|
+
result << section_ranks.join(rank_separator)
|
|
100
|
+
offset += section_size
|
|
62
101
|
end
|
|
63
102
|
|
|
64
|
-
|
|
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
|
|
103
|
+
result.join(section_separator)
|
|
70
104
|
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
105
|
end
|
|
82
|
-
private_class_method :_coerce_placement
|
|
83
106
|
end
|
|
84
107
|
end
|
|
85
108
|
end
|
|
@@ -3,56 +3,145 @@
|
|
|
3
3
|
module Sashite
|
|
4
4
|
module Feen
|
|
5
5
|
module Dumper
|
|
6
|
+
# Dumper for the pieces-in-hand field (second field of FEEN).
|
|
7
|
+
#
|
|
8
|
+
# Converts a Hands object into its FEEN string representation,
|
|
9
|
+
# encoding captured pieces held by each player in canonical sorted order.
|
|
10
|
+
#
|
|
11
|
+
# @see https://sashite.dev/specs/feen/1.0.0/
|
|
6
12
|
module PiecesInHand
|
|
7
|
-
#
|
|
8
|
-
|
|
13
|
+
# Player separator in pieces-in-hand field.
|
|
14
|
+
PLAYER_SEPARATOR = "/"
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
#
|
|
16
|
+
# Dump a Hands object into its FEEN pieces-in-hand string.
|
|
17
|
+
#
|
|
18
|
+
# Generates canonical representation with pieces sorted according to
|
|
19
|
+
# FEEN ordering rules: by quantity (descending), base letter (ascending),
|
|
20
|
+
# case (uppercase first), prefix (-, +, none), and suffix (none, ').
|
|
21
|
+
#
|
|
22
|
+
# @param hands [Hands] The hands object containing pieces for both players
|
|
23
|
+
# @return [String] FEEN pieces-in-hand field string
|
|
24
|
+
#
|
|
25
|
+
# @example No pieces in hand
|
|
26
|
+
# dump(hands)
|
|
27
|
+
# # => "/"
|
|
13
28
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
29
|
+
# @example First player has pieces
|
|
30
|
+
# dump(hands)
|
|
31
|
+
# # => "2P/p"
|
|
17
32
|
#
|
|
18
|
-
# @
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
# @example Both players have pieces
|
|
34
|
+
# dump(hands)
|
|
35
|
+
# # => "RBN/2p"
|
|
36
|
+
def self.dump(hands)
|
|
37
|
+
first_player_str = dump_player_pieces(hands.first_player)
|
|
38
|
+
second_player_str = dump_player_pieces(hands.second_player)
|
|
22
39
|
|
|
23
|
-
|
|
24
|
-
|
|
40
|
+
"#{first_player_str}#{PLAYER_SEPARATOR}#{second_player_str}"
|
|
41
|
+
end
|
|
25
42
|
|
|
26
|
-
|
|
43
|
+
# Dump pieces for a single player.
|
|
44
|
+
#
|
|
45
|
+
# Groups identical pieces, counts them, sorts canonically, and formats
|
|
46
|
+
# with count prefix when needed (e.g., "3P" for three pawns).
|
|
47
|
+
#
|
|
48
|
+
# @param pieces [Array] Array of piece objects for one player
|
|
49
|
+
# @return [String] Formatted piece string (empty if no pieces)
|
|
50
|
+
#
|
|
51
|
+
# @example Single piece types
|
|
52
|
+
# dump_player_pieces([pawn1, pawn2, pawn3, rook1])
|
|
53
|
+
# # => "3PR"
|
|
54
|
+
#
|
|
55
|
+
# @example Empty hand
|
|
56
|
+
# dump_player_pieces([])
|
|
57
|
+
# # => ""
|
|
58
|
+
private_class_method def self.dump_player_pieces(pieces)
|
|
59
|
+
return "" if pieces.empty?
|
|
27
60
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
61
|
+
grouped = group_pieces(pieces)
|
|
62
|
+
sorted = sort_grouped_pieces(grouped)
|
|
63
|
+
format_pieces(sorted)
|
|
64
|
+
end
|
|
31
65
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
66
|
+
# Group identical pieces and count occurrences.
|
|
67
|
+
#
|
|
68
|
+
# @param pieces [Array] Array of piece objects
|
|
69
|
+
# @return [Hash] Hash mapping piece strings to counts
|
|
70
|
+
#
|
|
71
|
+
# @example
|
|
72
|
+
# group_pieces([pawn1, pawn2, rook1])
|
|
73
|
+
# # => {"P" => 2, "R" => 1}
|
|
74
|
+
private_class_method def self.group_pieces(pieces)
|
|
75
|
+
pieces.group_by(&:to_s).transform_values(&:size)
|
|
76
|
+
end
|
|
37
77
|
|
|
38
|
-
|
|
78
|
+
# Sort grouped pieces according to FEEN canonical ordering.
|
|
79
|
+
#
|
|
80
|
+
# Sorting rules (in order of precedence):
|
|
81
|
+
# 1. By quantity (descending) - most pieces first
|
|
82
|
+
# 2. By base letter (ascending, case-insensitive)
|
|
83
|
+
# 3. By case - uppercase before lowercase
|
|
84
|
+
# 4. By prefix - "-", "+", then none
|
|
85
|
+
# 5. By suffix - none, then "'"
|
|
86
|
+
#
|
|
87
|
+
# @param grouped [Hash] Hash of piece strings to counts
|
|
88
|
+
# @return [Array<Array>] Sorted array of [piece_string, count] pairs
|
|
89
|
+
#
|
|
90
|
+
# @example
|
|
91
|
+
# sort_grouped_pieces({"p" => 2, "P" => 3, "R" => 1, "+K" => 1, "K'" => 1})
|
|
92
|
+
# # => [["+K", 1], ["K'", 1], ["P", 3], ["p", 2], ["R", 1]]
|
|
93
|
+
private_class_method def self.sort_grouped_pieces(grouped)
|
|
94
|
+
grouped.sort_by do |piece_str, count|
|
|
95
|
+
[
|
|
96
|
+
-count, # Quantity (descending)
|
|
97
|
+
extract_base_letter(piece_str), # Base letter (ascending)
|
|
98
|
+
piece_str.match?(/[A-Z]/) ? 0 : 1, # Case (uppercase first)
|
|
99
|
+
prefix_order(piece_str), # Prefix order
|
|
100
|
+
piece_str.end_with?("'") ? 1 : 0 # Suffix order (none first)
|
|
101
|
+
]
|
|
39
102
|
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
103
|
end
|
|
47
104
|
|
|
48
|
-
#
|
|
105
|
+
# Extract base letter from piece string (without modifiers).
|
|
106
|
+
#
|
|
107
|
+
# @param piece_str [String] EPIN piece string
|
|
108
|
+
# @return [String] Uppercase base letter
|
|
109
|
+
#
|
|
110
|
+
# @example
|
|
111
|
+
# extract_base_letter("+K'") # => "K"
|
|
112
|
+
# extract_base_letter("-p") # => "P"
|
|
113
|
+
private_class_method def self.extract_base_letter(piece_str)
|
|
114
|
+
piece_str.gsub(/[+\-']/, "").upcase
|
|
115
|
+
end
|
|
49
116
|
|
|
50
|
-
|
|
51
|
-
|
|
117
|
+
# Determine prefix sorting order.
|
|
118
|
+
#
|
|
119
|
+
# @param piece_str [String] EPIN piece string
|
|
120
|
+
# @return [Integer] Sort order (0 for "-", 1 for "+", 2 for none)
|
|
121
|
+
#
|
|
122
|
+
# @example
|
|
123
|
+
# prefix_order("-K") # => 0
|
|
124
|
+
# prefix_order("+K") # => 1
|
|
125
|
+
# prefix_order("K") # => 2
|
|
126
|
+
private_class_method def self.prefix_order(piece_str)
|
|
127
|
+
return 0 if piece_str.start_with?("-")
|
|
128
|
+
return 1 if piece_str.start_with?("+")
|
|
129
|
+
2
|
|
130
|
+
end
|
|
52
131
|
|
|
53
|
-
|
|
132
|
+
# Format sorted pieces with count prefixes.
|
|
133
|
+
#
|
|
134
|
+
# @param sorted [Array<Array>] Sorted array of [piece_string, count] pairs
|
|
135
|
+
# @return [String] Formatted piece string
|
|
136
|
+
#
|
|
137
|
+
# @example
|
|
138
|
+
# format_pieces([["P", 3], ["R", 1], ["p", 2]])
|
|
139
|
+
# # => "3PR2p"
|
|
140
|
+
private_class_method def self.format_pieces(sorted)
|
|
141
|
+
sorted.map do |piece_str, count|
|
|
142
|
+
count > 1 ? "#{count}#{piece_str}" : piece_str
|
|
143
|
+
end.join
|
|
54
144
|
end
|
|
55
|
-
private_class_method :_coerce_hands
|
|
56
145
|
end
|
|
57
146
|
end
|
|
58
147
|
end
|