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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bcbcddcc2de55695f9a203e30fa5d3d236deaf7c0e3975011ae1a47f389cd7c
4
- data.tar.gz: 98b184086642d30761cf4646dbeac1ca0a88860c11d18a15866f01e7f0b0e040
3
+ metadata.gz: 98fd7fceb1edd4b6479a217c0932443c76335b4d41238b2ee0d3f2e3fc2dd397
4
+ data.tar.gz: 6f31b473124e985baab36d30bf84d3c7cf05d129d66bbfe16b4b60068a44c545
5
5
  SHA512:
6
- metadata.gz: cb41ec980de1a7ae5237da821f0a3be0cabe50768cb0dceec8988465336512a0705aec6f5aa9acfe1b0cf43770996581355029be8623a19d8a44dc737c628d83
7
- data.tar.gz: 95507ce051c09837ca92da2b9447b2ef3f3c9a34fdc4e412cce0ed8e32595acf91acdbae37d2ed313315db9c0e29069edae0ec1c71770b3d1f853f20896a3c0c
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
- > **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).
3
+ [![Version](https://img.shields.io/github/v/tag/sashite/feen.rb?label=Version&logo=github)](https://github.com/sashite/feen.rb/tags)
4
+ [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/sashite/feen.rb/main)
5
+ ![Ruby](https://github.com/sashite/feen.rb/actions/workflows/main.yml/badge.svg?branch=main)
6
+ [![License](https://img.shields.io/github/license/sashite/feen.rb?label=License&logo=github)](https://github.com/sashite/feen.rb/raw/main/LICENSE.md)
10
7
 
11
- ---
8
+ > **FEEN** (Forsyth–Edwards Enhanced Notation) implementation for the Ruby language.
12
9
 
13
- ## Why FEEN?
10
+ ## What is FEEN?
14
11
 
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).
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 strings have **three space-separated fields**:
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
- Bundler will install them automatically when you use `sashite-feen`.
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
- 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
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
- # 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>"
32
+ # Dump a position object into a canonical FEEN string
33
+ feen_string = Sashite::Feen.dump(position)
75
34
  ```
76
35
 
77
- > **Tip:** FEEN itself does not do JSON; keep it minimal and functional. Serialize externally if needed.
36
+ ### Methods
78
37
 
79
- ---
38
+ #### `Sashite::Feen.parse(string)`
80
39
 
81
- ## Public API
40
+ Parses a FEEN string and returns an immutable `Position` object.
82
41
 
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
- ```
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
- Position value-objects are immutable:
46
+ #### `Sashite::Feen.dump(position)`
94
47
 
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
- ```
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
- ## Canonicalization (short rules)
54
+ ### Position Object
105
55
 
106
- * **Piece placement (field 1)**
56
+ The `Position` object returned by `parse` is immutable and provides read-only access to the three FEEN components:
107
57
 
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:
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
- * **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)
65
+ ## Format
136
66
 
137
- ### Project layout
67
+ A FEEN string consists of three space-separated fields:
138
68
 
139
69
  ```
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
70
+ <piece-placement> <pieces-in-hand> <style-turn>
161
71
  ```
162
72
 
163
- > Version is defined outside of `lib/sashite/feen/version.rb` (e.g., `VERSION.semver`).
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
- Rescue at the granularity you need:
77
+ For complete format details, see the [FEEN Specification](https://sashite.dev/specs/feen/1.0.0/).
170
78
 
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
79
+ ## Error Handling
177
80
 
178
- Example:
81
+ The library defines specific error classes for different validation failures:
179
82
 
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
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
- ```sh
201
- # Clone
202
- git clone https://github.com/sashite/feen.rb.git
203
- cd feen.rb
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
- # Install
206
- bundle install
100
+ ## Dependencies
207
101
 
208
- # Run smoke tests
209
- ruby test.rb
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
- # Generate YARD docs
212
- yard doc
213
- ```
214
-
215
- ---
216
-
217
- ## Contributing
105
+ ## Documentation
218
106
 
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
- ---
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
- Open source under the [MIT License](https://opensource.org/licenses/MIT).
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 **Sashité** promoting chess variants and sharing the beauty of board-game cultures.
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
- # Separator between ranks
14
+ # Rank separator for 2D boards.
8
15
  RANK_SEPARATOR = "/"
9
16
 
10
- module_function
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
- # @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?
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
- if row.length != width
31
- raise Error::Bounds,
32
- "inconsistent row width at row #{r_idx + 1} (expected #{width}, got #{row.length})"
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
- dumped_rows.join(RANK_SEPARATOR)
64
+ result << empty_count.to_s if empty_count > 0
65
+ result.join
39
66
  end
40
67
 
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
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
- 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
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
- # Separator between hand entries
8
- ENTRY_SEPARATOR = ","
13
+ # Player separator in pieces-in-hand field.
14
+ PLAYER_SEPARATOR = "/"
9
15
 
10
- module_function
11
-
12
- # Dump a Hands multiset to FEEN (e.g., "-", "P,2xN,R")
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
- # Canonicalization:
15
- # - entries sorted lexicographically by EPIN token
16
- # - counts rendered as "NxTOKEN" when N > 1
29
+ # @example First player has pieces
30
+ # dump(hands)
31
+ # # => "2P/p"
17
32
  #
18
- # @param hands [Sashite::Feen::Hands]
19
- # @return [String]
20
- def dump(hands)
21
- h = _coerce_hands(hands)
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
- map = h.map
24
- raise Error::Count, "negative counts are not allowed" if map.values.any? { |v| Integer(v).negative? }
40
+ "#{first_player_str}#{PLAYER_SEPARATOR}#{second_player_str}"
41
+ end
25
42
 
26
- return "-" if map.empty?
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
- 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
61
+ grouped = group_pieces(pieces)
62
+ sorted = sort_grouped_pieces(grouped)
63
+ format_pieces(sorted)
64
+ end
31
65
 
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
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
- [token, c]
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
- # -- helpers -----------------------------------------------------------
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
- def _coerce_hands(obj)
51
- return obj if obj.is_a?(Hands)
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
- raise TypeError, "expected Sashite::Feen::Hands, got #{obj.class}"
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