feen 5.0.0.beta1 → 5.0.0.beta3

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: 2fa56bccbb1911f15569f2f5643646986cbf83661efba3165c69978e7c85fd56
4
- data.tar.gz: 13d706d1bfe1c09f6d015d0a8f28af621754677711074ec60ec96e395f1a1061
3
+ metadata.gz: ad6a4426ae68ac5344888465a23eea36cf28b3e0137b50af8483b98288012969
4
+ data.tar.gz: f9f42840240c5b75629cdb5459f492f966d3fc2706a354c18abc9841a9f17e21
5
5
  SHA512:
6
- metadata.gz: 5479cbd02c902aa8c45cc12ecfab31c7b7283eb1f8ec60b110c889505a7ebbcf76fa7b0d2e6ad44c60da954312fa85ae03cd4981d929207b0612754f1d62a033
7
- data.tar.gz: b6268ce619808bf2c2f889261b55007edbfc030bc37b8edea7f73f505de55e8c0fc1cd4a268e8e2bdc71fe21c9fa33f904c8b26a23b92b471e406e109494bd97
6
+ metadata.gz: 31141ae8b866a11ffec6eed44308b475d03e75174ec0bc12b14ff0dc60bfe8b21ad60af3d32e1747d7dce94fe0cff0bbd5add502f26c26aeffac97104fbeeaca
7
+ data.tar.gz: 82c82de3cf0024e596d9f318e612a8077069a9b519e6b4e753dc808bbee91045f72328489a26307f0877194a4e99ffc8502dd3d3d1610900215ab697fa479ce4
data/LICENSE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # The MIT License
2
2
 
3
- Copyright (c) 2020-2023 Sashité
3
+ Copyright (c) 2020-2025 Sashité
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,89 +1,215 @@
1
1
  # Feen.rb
2
2
 
3
- [![Version](https://img.shields.io/github/v/tag/sashite/feen.rb?label=Version&logo=github)](https://github.com/sashite/feen.rb/releases)
3
+ [![Version](https://img.shields.io/github/v/tag/sashite/feen.rb?label=Version&logo=github)](https://github.com/sashite/feen.rb/tags)
4
4
  [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/sashite/feen.rb/main)
5
- [![CI](https://github.com/sashite/feen.rb/workflows/CI/badge.svg?branch=main)](https://github.com/sashite/feen.rb/actions?query=workflow%3Aci+branch%3Amain)
6
- [![RuboCop](https://github.com/sashite/feen.rb/workflows/RuboCop/badge.svg?branch=main)](https://github.com/sashite/feen.rb/actions?query=workflow%3Arubocop+branch%3Amain)
5
+ ![Ruby](https://github.com/sashite/feen.rb/actions/workflows/main.yml/badge.svg?branch=main)
7
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)
8
7
 
9
- > __FEEN__ (Forsyth–Edwards Expanded Notation) support for the Ruby language.
8
+ > **FEEN** (Format for Encounter & Entertainment Notation) support for the Ruby language.
10
9
 
11
- ## Overview
10
+ ## What is FEEN?
12
11
 
13
- This is an implementation of [FEEN](https://github.com/sashite/specs/blob/main/forsyth-edwards-expanded-notation.md), a flexible and minimalist format for describing chess variant positions.
12
+ FEEN (Format for Encounter & Entertainment Notation) is a compact, canonical, and rule-agnostic textual format for representing static board positions in two-player piece-placement games.
14
13
 
15
- ## Installation
14
+ This gem implements the [FEEN Specification v1.0.0](https://sashite.dev/documents/feen/1.0.0/), providing a Ruby interface for:
15
+ - Representing positions from various games without knowledge of specific rules
16
+ - Supporting boards of arbitrary dimensions
17
+ - Encoding pieces in hand (as used in Shogi)
18
+ - Facilitating serialization and deserialization of positions
16
19
 
17
- Add this line to your application's Gemfile:
20
+ ## Installation
18
21
 
19
22
  ```ruby
20
- gem "feen", ">= 5.0.0.beta1"
23
+ # In your Gemfile
24
+ gem "feen", ">= 5.0.0.beta3"
21
25
  ```
22
26
 
23
- And then execute:
27
+ Or install manually:
24
28
 
25
29
  ```sh
26
- bundle install
30
+ gem install feen --pre
27
31
  ```
28
32
 
29
- Or install it yourself as:
33
+ ## FEEN Format
30
34
 
31
- ```sh
32
- gem install feen --pre
35
+ A FEEN record consists of three space-separated fields:
36
+
37
+ ```
38
+ <PIECE-PLACEMENT> <PIECES-IN-HAND> <GAMES-TURN>
33
39
  ```
34
40
 
35
- ## Usage
41
+ ## Basic Usage
36
42
 
37
- ### Serialization
43
+ ### Parsing FEEN Strings
38
44
 
39
- A position can be serialized by filling in these fields:
45
+ Convert a FEEN string into a structured Ruby object:
40
46
 
41
- - **Board shape**: An array of integers. For instance, it would be `[10, 9]` for a Xiangqi board. Or it would be `[8, 8]` for a Chess board.
42
- - **Piece placement**: Describes the placement of pieces on the board with a hash that references each piece on the board.
43
- - **Side to move**: A char that indicates who moves next. In chess, "`w`" would mean that White can play a move.
47
+ ```ruby
48
+ require "feen"
49
+
50
+ feen_string = "rnbqk=bnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK=BNR - CHESS/chess"
51
+ position = Feen.parse(feen_string)
52
+
53
+ # Result is a hash:
54
+ # {
55
+ # "piece_placement" => [
56
+ # ["r", "n", "b", "q", "k=", "b", "n", "r"],
57
+ # ["p", "p", "p", "p", "p", "p", "p", "p"],
58
+ # ["", "", "", "", "", "", "", ""],
59
+ # ["", "", "", "", "", "", "", ""],
60
+ # ["", "", "", "", "", "", "", ""],
61
+ # ["", "", "", "", "", "", "", ""],
62
+ # ["P", "P", "P", "P", "P", "P", "P", "P"],
63
+ # ["R", "N", "B", "Q", "K=", "B", "N", "R"]
64
+ # ],
65
+ # "games_turn" => ["CHESS", "chess"],
66
+ # "pieces_in_hand" => []
67
+ # }
68
+ ```
44
69
 
45
- #### Example
70
+ ### Creating FEEN Strings
46
71
 
47
- From a classic Tsume Shogi problem:
72
+ Convert position components to a FEEN string using named arguments:
48
73
 
49
74
  ```ruby
50
75
  require "feen"
51
76
 
52
- Feen.dump(
53
- board_shape: [9, 9],
54
- side_to_move: "s",
55
- piece_placement: {
56
- 3 => "s",
57
- 4 => "k",
58
- 5 => "s",
59
- 22 => "+P",
60
- 43 => "+B"
61
- }
77
+ # Representation of a chess board in initial position
78
+ piece_placement = [
79
+ ["r", "n", "b", "q", "k=", "b", "n", "r"],
80
+ ["p", "p", "p", "p", "p", "p", "p", "p"],
81
+ ["", "", "", "", "", "", "", ""],
82
+ ["", "", "", "", "", "", "", ""],
83
+ ["", "", "", "", "", "", "", ""],
84
+ ["", "", "", "", "", "", "", ""],
85
+ ["P", "P", "P", "P", "P", "P", "P", "P"],
86
+ ["R", "N", "B", "Q", "K=", "B", "N", "R"]
87
+ ]
88
+
89
+ result = Feen.dump(
90
+ piece_placement: piece_placement,
91
+ games_turn: %w[CHESS chess],
92
+ pieces_in_hand: []
62
93
  )
63
- # => "3sks3/9/4+P4/9/7+B1/9/9/9/9 s"
94
+ # => "rnbqk=bnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK=BNR - CHESS/chess"
95
+ ```
96
+
97
+ ### Validation
98
+
99
+ Check if a string is valid FEEN notation:
100
+
101
+ ```ruby
102
+ require "feen"
103
+
104
+ Feen.valid?("rnbqk=bnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK=BNR - CHESS/chess")
105
+ # => true
106
+
107
+ Feen.valid?("invalid feen string")
108
+ # => false
109
+ ```
110
+
111
+ ## Game Examples
112
+
113
+ As FEEN is rule-agnostic, it can represent positions from various board games. Here are some examples:
114
+
115
+ ### International Chess
116
+
117
+ ```ruby
118
+ feen_string = "rnbqk=bnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK=BNR - CHESS/chess"
119
+ ```
120
+
121
+ In this initial chess position:
122
+ - The `=` suffixes on kings indicate castling rights on both sides (though FEEN doesn't define this semantics)
123
+ - The third field `CHESS/chess` indicates it's the player with uppercase pieces' turn to move
124
+
125
+ ### Shogi (Japanese Chess)
126
+
127
+ ```ruby
128
+ feen_string = "lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL N5P2g2snl SHOGI/shogi"
129
+ ```
130
+
131
+ In this shogi position:
132
+ - The format supports promotions with the `+` prefix (e.g., `+P` for a promoted pawn)
133
+ - The notation allows for pieces in hand, indicated in the second field
134
+ - `SHOGI/shogi` indicates it's Sente's (Black's, uppercase) turn to move
135
+ - `N5P2g2snl` shows the pieces in hand: Sente has a Knight (N) and 5 Pawns (P), while Gote has 2 Golds (g), 2 Silvers (s), a Knight (n), and a Lance (l)
136
+
137
+ ### Makruk (Thai Chess)
138
+
139
+ ```ruby
140
+ feen_string = "rnbqkbnr/8/pppppppp/8/8/PPPPPPPP/8/RNBQKBNR - MAKRUK/makruk"
141
+ ```
142
+
143
+ This initial Makruk position is easily represented in FEEN without needing to know the specific rules of the game.
144
+
145
+ ### Xiangqi (Chinese Chess)
146
+
147
+ ```ruby
148
+ feen_string = "rheagaehr/9/1c5c1/s1s1s1s1s/9/9/S1S1S1S1S/1C5C1/9/RHEAGAEHR - XIANGQI/xiangqi"
64
149
  ```
65
150
 
66
- ### Deserialization
151
+ In this Xiangqi position:
152
+ - The representation uses single letters for the different pieces
153
+ - The format naturally adapts to the presence of a "river" (empty space in the middle)
154
+
155
+ ## Advanced Features
67
156
 
68
- Serialized positions can be converted back to fields.
157
+ ### Multi-dimensional Boards
69
158
 
70
- #### Example
159
+ FEEN supports arbitrary-dimensional board configurations:
71
160
 
72
161
  ```ruby
73
162
  require "feen"
74
163
 
75
- Feen.parse("3sks3/9/4+P4/9/7+B1/9/9/9/9 s")
76
- # {:board_shape=>[9, 9],
77
- # :piece_placement=>{3=>"s", 4=>"k", 5=>"s", 22=>"+P", 43=>"+B"},
78
- # :side_to_move=>"s"}
164
+ # 3D board
165
+ piece_placement = [
166
+ [
167
+ %w[r n b],
168
+ %w[q k p]
169
+ ],
170
+ [
171
+ ["P", "R", ""],
172
+ ["", "K", "Q"]
173
+ ]
174
+ ]
175
+
176
+ result = Feen.dump(
177
+ piece_placement: piece_placement,
178
+ games_turn: %w[FOO bar],
179
+ pieces_in_hand: []
180
+ )
181
+ # => "rnb/qkp//PR1/1KQ - FOO/bar"
79
182
  ```
80
183
 
184
+ ### Piece Modifiers
185
+
186
+ FEEN supports prefixes and suffixes for pieces to denote various states or capabilities:
187
+
188
+ - **Prefix `+`**: May indicate promotion or special state
189
+ - Example in shogi: `+P` may represent a promoted pawn
190
+
191
+ - **Suffix `=`**: May indicate dual-option status
192
+ - Example in chess: `K=` may represent a king eligible for both kingside and queenside castling
193
+
194
+ - **Suffix `<`**: May indicate left-side constraint
195
+ - Example in chess: `K<` may represent a king eligible for queenside castling only
196
+ - Example in chess: `P<` may represent a pawn that may be captured _en passant_ from the left
197
+
198
+ - **Suffix `>`**: May indicate right-side constraint
199
+ - Example in chess: `K>` may represent a king eligible for kingside castling only
200
+ - Example in chess: `P>` may represent a pawn that may be captured en passant from the right
201
+
202
+ These modifiers have no defined semantics in the FEEN specification itself but provide a flexible framework for representing piece-specific conditions while maintaining FEEN's rule-agnostic nature.
203
+
204
+ ## Documentation
205
+
206
+ - [Official FEEN Specification](https://sashite.dev/documents/feen/1.0.0/)
207
+ - [API Documentation](https://rubydoc.info/github/sashite/feen.rb/main)
208
+
81
209
  ## License
82
210
 
83
- The code is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
211
+ The [gem](https://rubygems.org/gems/feen) is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
84
212
 
85
213
  ## About Sashité
86
214
 
87
- This [gem](https://rubygems.org/gems/feen) is maintained by [Sashité](https://sashite.com/).
88
-
89
- With some [lines of code](https://github.com/sashite/), let's share the beauty of Chinese, Japanese and Western cultures through the game of chess!
215
+ This project is maintained by [Sashité](https://sashite.com/) - a project dedicated to promoting chess variants and sharing the beauty of Chinese, Japanese, and Western chess cultures.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feen
4
+ module Dumper
5
+ # Handles conversion of games turn data to FEEN notation string
6
+ module GamesTurn
7
+ ERRORS = {
8
+ type: "%s must be a String, got %s",
9
+ empty: "%s cannot be empty",
10
+ mixed: "%s has mixed case: %s",
11
+ casing: "One variant must be uppercase and the other lowercase",
12
+ chars: "Variant identifiers must contain only alphabetic characters (a-z, A-Z)"
13
+ }.freeze
14
+
15
+ # Converts the active and inactive variant identifiers to a FEEN-formatted games turn string
16
+ #
17
+ # @param active_variant [String] Identifier for the player to move and their game variant
18
+ # @param inactive_variant [String] Identifier for the opponent and their game variant
19
+ # @return [String] FEEN-formatted games turn string
20
+ def self.dump(active_variant, inactive_variant)
21
+ validate_variants(active_variant, inactive_variant)
22
+ "#{active_variant}/#{inactive_variant}"
23
+ end
24
+
25
+ # Validates the game variant identifiers
26
+ #
27
+ # @param active [String] The active player's variant identifier
28
+ # @param inactive [String] The inactive player's variant identifier
29
+ # @raise [ArgumentError] If the variant identifiers are invalid
30
+ # @return [void]
31
+ private_class_method def self.validate_variants(active, inactive)
32
+ # Validate basic type and presence
33
+ [["Active variant", active], ["Inactive variant", inactive]].each do |name, variant|
34
+ raise ArgumentError, format(ERRORS[:type], name, variant.class) unless variant.is_a?(String)
35
+ raise ArgumentError, format(ERRORS[:empty], name) if variant.empty?
36
+ raise ArgumentError, ERRORS[:chars] unless variant.match?(/\A[a-zA-Z]+\z/)
37
+ end
38
+
39
+ # Validate casing (one must be uppercase, one must be lowercase)
40
+ active_uppercase = active == active.upcase && active != active.downcase
41
+ inactive_uppercase = inactive == inactive.upcase && inactive != inactive.downcase
42
+
43
+ # If both have the same casing (both uppercase or both lowercase), raise error
44
+ raise ArgumentError, ERRORS[:casing] if active_uppercase == inactive_uppercase
45
+
46
+ # Check for mixed case (must be all uppercase or all lowercase)
47
+ if active_uppercase && active != active.upcase
48
+ raise ArgumentError, format(ERRORS[:mixed], "Active variant", active)
49
+ end
50
+
51
+ if inactive_uppercase && inactive != inactive.upcase
52
+ raise ArgumentError, format(ERRORS[:mixed], "Inactive variant", inactive)
53
+ end
54
+
55
+ if !active_uppercase && active != active.downcase
56
+ raise ArgumentError, format(ERRORS[:mixed], "Active variant", active)
57
+ end
58
+
59
+ if !inactive_uppercase && inactive != inactive.downcase
60
+ raise ArgumentError, format(ERRORS[:mixed], "Inactive variant", inactive)
61
+ end
62
+
63
+ true
64
+ end
65
+ end
66
+ end
67
+ end
@@ -2,84 +2,133 @@
2
2
 
3
3
  module Feen
4
4
  module Dumper
5
- # The PiecePlacement class.
6
- #
7
- # @example Dump an empty board of Xiangqi
8
- # PiecePlacement.new([10, 9]).to_s # => "9/9/9/9/9/9/9/9/9/9"
9
- #
10
- # @example Dump the Xiangqi starting position board
11
- # PiecePlacement.new(
12
- # [10, 9],
13
- # {
14
- # 0 => "車",
15
- # 1 => "馬",
16
- # 2 => "象",
17
- # 3 => "士",
18
- # 4 => "將",
19
- # 5 => "士",
20
- # 6 => "象",
21
- # 7 => "馬",
22
- # 8 => "車",
23
- # 19 => "砲",
24
- # 25 => "砲",
25
- # 27 => "卒",
26
- # 29 => "卒",
27
- # 31 => "卒",
28
- # 33 => "卒",
29
- # 35 => "卒",
30
- # 54 => "兵",
31
- # 56 => "兵",
32
- # 58 => "兵",
33
- # 60 => "兵",
34
- # 62 => "兵",
35
- # 64 => "炮",
36
- # 70 => "炮",
37
- # 81 => "俥",
38
- # 82 => "傌",
39
- # 83 => "相",
40
- # 84 => "仕",
41
- # 85 => "帥",
42
- # 86 => "仕",
43
- # 87 => "相",
44
- # 88 => "傌",
45
- # 89 => "俥"
46
- # }
47
- # ).to_s # => "車馬象士將士象馬車/9/1砲5砲1/卒1卒1卒1卒1卒/9/9/兵1兵1兵1兵1兵/1炮5炮1/9/俥傌相仕帥仕相傌俥"
48
- class PiecePlacement
49
- # @param indexes [Array] The shape of the board.
50
- # @param piece_placement [Hash] The index of each piece on the board.
51
- def initialize(indexes, piece_placement = {})
52
- @indexes = indexes
53
- @squares = ::Array.new(length) { |i| piece_placement.fetch(i, nil) }
5
+ module PiecePlacement
6
+ # Converts a piece placement structure to a FEEN-compliant string
7
+ #
8
+ # @param piece_placement [Array] Hierarchical array representing the board where:
9
+ # - Empty squares are represented by empty strings ("")
10
+ # - Pieces are represented by strings (e.g., "r", "K=", "+P")
11
+ # - Dimensions are represented by nested arrays
12
+ # @return [String] FEEN piece placement string
13
+ # @raise [ArgumentError] If the piece placement structure is invalid
14
+ def self.dump(piece_placement)
15
+ # Détecter la forme du tableau directement à partir de la structure
16
+ detect_shape(piece_placement)
17
+
18
+ # Formater directement la structure en chaîne FEEN
19
+ format_placement(piece_placement)
20
+ end
21
+
22
+ # Detects the shape of the board based on the piece_placement structure
23
+ #
24
+ # @param piece_placement [Array] Hierarchical array structure representing the board
25
+ # @return [Array<Integer>] Array of dimension sizes
26
+ # @raise [ArgumentError] If the piece_placement structure is invalid
27
+ def self.detect_shape(piece_placement)
28
+ return [] if piece_placement.empty?
29
+
30
+ dimensions = []
31
+ current = piece_placement
32
+
33
+ # Traverse the structure to determine shape
34
+ while current.is_a?(Array) && !current.empty?
35
+ dimensions << current.size
36
+
37
+ # Check if all elements at this level have the same structure
38
+ validate_dimension_uniformity(current)
39
+
40
+ # Check if we've reached the leaf level (array of strings)
41
+ break if current.first.is_a?(String) ||
42
+ (current.first.is_a?(Array) && current.first.empty?)
43
+
44
+ current = current.first
45
+ end
46
+
47
+ dimensions
54
48
  end
55
49
 
56
- # @return [String] The string representing the board.
57
- def to_s
58
- unflatten(@squares, @indexes)
50
+ # Validates that all elements in a dimension have the same structure
51
+ #
52
+ # @param dimension [Array] Array of elements at a particular dimension level
53
+ # @raise [ArgumentError] If elements have inconsistent structure
54
+ def self.validate_dimension_uniformity(dimension)
55
+ return if dimension.empty?
56
+
57
+ first_type = dimension.first.class
58
+ first_size = dimension.first.is_a?(Array) ? dimension.first.size : nil
59
+
60
+ dimension.each do |element|
61
+ unless element.class == first_type
62
+ raise ArgumentError, "Inconsistent element types in dimension: #{first_type} vs #{element.class}"
63
+ end
64
+
65
+ if element.is_a?(Array) && element.size != first_size
66
+ raise ArgumentError, "Inconsistent dimension sizes: expected #{first_size}, got #{element.size}"
67
+ end
68
+ end
59
69
  end
60
70
 
61
- private
71
+ # Formats the piece placement structure into a FEEN string
72
+ #
73
+ # @param placement [Array] Piece placement structure
74
+ # @return [String] FEEN piece placement string
75
+ def self.format_placement(placement)
76
+ # For 1D arrays (ranks), format directly
77
+ if !placement.is_a?(Array) ||
78
+ (placement.is_a?(Array) && (placement.empty? || !placement.first.is_a?(Array)))
79
+ return format_rank(placement)
80
+ end
62
81
 
63
- def length
64
- @indexes.inject(:*)
82
+ # For 2D+ arrays, format each sub-element and join with appropriate separator
83
+ depth = calculate_depth(placement) - 1
84
+ separator = "/" * depth
85
+
86
+ # Important: Ne pas inverser le tableau - nous voulons maintenir l'ordre original
87
+ elements = placement
88
+ elements.map { |element| format_placement(element) }.join(separator)
65
89
  end
66
90
 
67
- def unflatten(squares, remaining_indexes)
68
- return row(squares) if remaining_indexes.length == 1
91
+ # Formats a rank (1D array of cells)
92
+ #
93
+ # @param rank [Array] 1D array of cells
94
+ # @return [String] FEEN rank string
95
+ def self.format_rank(rank)
96
+ return "" if !rank.is_a?(Array) || rank.empty?
97
+
98
+ result = ""
99
+ empty_count = 0
69
100
 
70
- squares
71
- .each_slice(squares.length / remaining_indexes.fetch(0))
72
- .to_a
73
- .map { |sub_squares| unflatten(sub_squares, remaining_indexes[1..]) }
74
- .join("/" * remaining_indexes.length.pred)
101
+ rank.each do |cell|
102
+ if cell.empty?
103
+ empty_count += 1
104
+ else
105
+ # Add accumulated empty squares
106
+ result += empty_count.to_s if empty_count > 0
107
+ empty_count = 0
108
+
109
+ # Add the piece
110
+ result += cell
111
+ end
112
+ end
113
+
114
+ # Add any trailing empty squares
115
+ result += empty_count.to_s if empty_count > 0
116
+
117
+ result
75
118
  end
76
119
 
77
- def row(squares)
78
- squares
79
- .map { |square| square.nil? ? 1 : square }
80
- .join(",")
81
- .gsub(/1,[1,]*1/) { |str| str.split(",").length }
82
- .delete(",")
120
+ # Calculates the depth of a nested structure
121
+ #
122
+ # @param structure [Array] Structure to analyze
123
+ # @return [Integer] Depth of the structure
124
+ def self.calculate_depth(structure)
125
+ return 0 unless structure.is_a?(Array) && !structure.empty?
126
+
127
+ if structure.first.is_a?(Array)
128
+ 1 + calculate_depth(structure.first)
129
+ else
130
+ 1
131
+ end
83
132
  end
84
133
  end
85
134
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feen
4
+ module Dumper
5
+ module PiecesInHand
6
+ Errors = {
7
+ invalid_type: "Piece at index: %<index>s must be a String, got type: %<type>s",
8
+ invalid_format: "Piece at index: %<index>s has an invalid format: '%<value>s'"
9
+ }.freeze
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feen
4
+ module Dumper
5
+ module PiecesInHand
6
+ # Character used to represent no pieces in hand
7
+ NoPieces = "-"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative File.join("pieces_in_hand", "no_pieces")
4
+ require_relative File.join("pieces_in_hand", "errors")
5
+
6
+ module Feen
7
+ module Dumper
8
+ # Handles conversion of pieces in hand data to FEEN notation string
9
+ module PiecesInHand
10
+ # Converts an array of piece identifiers to a FEEN-formatted pieces in hand string
11
+ #
12
+ # @param piece_chars [Array<String>] Array of single-character piece identifiers
13
+ # @return [String] FEEN-formatted pieces in hand string
14
+ # @raise [ArgumentError] If any piece identifier is invalid
15
+ # @example
16
+ # PiecesInHand.dump("P", "p", "B")
17
+ # # => "BPp"
18
+ #
19
+ # PiecesInHand.dump
20
+ # # => "-"
21
+ def self.dump(*piece_chars)
22
+ # If no pieces in hand, return the standardized empty indicator
23
+ return NoPieces if piece_chars.empty?
24
+
25
+ # Validate each piece character according to the FEEN specification
26
+ validated_chars = validate_piece_chars(piece_chars)
27
+
28
+ # Sort pieces in ASCII lexicographic order and join them
29
+ validated_chars.sort.join
30
+ end
31
+
32
+ # Validates all piece characters according to FEEN specification
33
+ #
34
+ # @param piece_chars [Array<Object>] Array of piece character candidates
35
+ # @return [Array<String>] Array of validated piece characters
36
+ # @raise [ArgumentError] If any piece character is invalid
37
+ private_class_method def self.validate_piece_chars(piece_chars)
38
+ piece_chars.each_with_index.map do |char, index|
39
+ validate_piece_char(char, index)
40
+ end
41
+ end
42
+
43
+ # Validates a single piece character according to FEEN specification
44
+ #
45
+ # @param char [Object] Piece character candidate
46
+ # @param index [Integer] Index of the character in the original array
47
+ # @return [String] Validated piece character
48
+ # @raise [ArgumentError] If the piece character is invalid
49
+ private_class_method def self.validate_piece_char(char, index)
50
+ # Validate type
51
+ unless char.is_a?(::String)
52
+ raise ::ArgumentError, format(
53
+ Errors[:invalid_type],
54
+ index: index,
55
+ type: char.class
56
+ )
57
+ end
58
+
59
+ # Validate format (single alphabetic character)
60
+ unless char.match?(/\A[a-zA-Z]\z/)
61
+ raise ::ArgumentError, format(
62
+ Errors[:invalid_format],
63
+ index: index,
64
+ value: char
65
+ )
66
+ end
67
+
68
+ char
69
+ end
70
+ end
71
+ end
72
+ end