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.
@@ -1,76 +1,248 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../error"
4
+ require_relative "../hands"
5
+
6
+ require "sashite/epin"
7
+
3
8
  module Sashite
4
9
  module Feen
5
10
  module Parser
11
+ # Parser for the pieces-in-hand field (second field of FEEN).
12
+ #
13
+ # Converts a FEEN pieces-in-hand string into a Hands object,
14
+ # decoding captured pieces held by each player with optional
15
+ # count prefixes.
16
+ #
17
+ # @see https://sashite.dev/specs/feen/1.0.0/
6
18
  module PiecesInHand
7
- module_function
19
+ # Player separator in pieces-in-hand field.
20
+ PLAYER_SEPARATOR = "/"
21
+
22
+ # Pattern to match EPIN pieces (optional state prefix, letter, optional derivation suffix).
23
+ EPIN_PATTERN = /\A[-+]?[A-Za-z]'?\z/
24
+
25
+ # Parse a FEEN pieces-in-hand string into a Hands object.
26
+ #
27
+ # @param string [String] FEEN pieces-in-hand field string
28
+ # @return [Hands] Parsed hands object
29
+ # @raise [Error::Syntax] If hands format is invalid
30
+ # @raise [Error::Piece] If EPIN notation is invalid
31
+ # @raise [Error::Count] If piece counts are invalid
32
+ #
33
+ # @example No pieces in hand
34
+ # parse("/") # => Hands.new([], [])
35
+ #
36
+ # @example First player has pieces
37
+ # parse("2P/p") # => Hands.new([P, P], [p])
38
+ #
39
+ # @example Both players have pieces
40
+ # parse("RBN/2p") # => Hands.new([R, B, N], [p, p])
41
+ def self.parse(string)
42
+ first_str, second_str = split_players(string)
43
+
44
+ first_player_pieces = parse_player_pieces(first_str)
45
+ second_player_pieces = parse_player_pieces(second_str)
46
+
47
+ Hands.new(first_player_pieces, second_player_pieces)
48
+ end
49
+
50
+ # Split pieces-in-hand string into first and second player parts.
51
+ #
52
+ # @param string [String] Pieces-in-hand field string
53
+ # @return [Array(String, String)] First and second player strings
54
+ # @raise [Error::Syntax] If separator is missing
55
+ #
56
+ # @example
57
+ # split_players("2P/p") # => ["2P", "p"]
58
+ # split_players("/") # => ["", ""]
59
+ private_class_method def self.split_players(string)
60
+ parts = string.split(PLAYER_SEPARATOR, 2)
61
+
62
+ raise Error::Syntax, "pieces-in-hand must contain '#{PLAYER_SEPARATOR}' separator" unless parts.size == 2
63
+
64
+ parts
65
+ end
66
+
67
+ # Parse pieces for a single player.
68
+ #
69
+ # Extracts pieces with optional count prefixes and expands them
70
+ # into individual piece objects.
71
+ #
72
+ # @param string [String] Player's pieces string (e.g., "2PRB")
73
+ # @return [Array] Array of piece objects
74
+ # @raise [Error::Syntax] If format is invalid
75
+ # @raise [Error::Piece] If EPIN notation is invalid
76
+ # @raise [Error::Count] If counts are invalid
77
+ #
78
+ # @example Single pieces
79
+ # parse_player_pieces("RBN") # => [R, B, N]
80
+ #
81
+ # @example With counts
82
+ # parse_player_pieces("3P2R") # => [P, P, P, R, R]
83
+ #
84
+ # @example Empty
85
+ # parse_player_pieces("") # => []
86
+ private_class_method def self.parse_player_pieces(string)
87
+ return [] if string.empty?
88
+
89
+ pieces = []
90
+ chars = string.chars
91
+ i = 0
92
+
93
+ while i < chars.size
94
+ count, epin_str, consumed = extract_piece_with_count(chars, i)
95
+ piece = parse_piece(epin_str)
96
+
97
+ count.times { pieces << piece }
98
+ i += consumed
99
+ end
8
100
 
9
- # Parse the pieces-in-hand field into a Hands value object.
101
+ pieces
102
+ end
103
+
104
+ # Extract a piece with optional count prefix from character array.
105
+ #
106
+ # Handles multi-digit counts and EPIN notation extraction.
107
+ #
108
+ # @param chars [Array<String>] Array of characters
109
+ # @param start_index [Integer] Starting index
110
+ # @return [Array(Integer, String, Integer)] Count, EPIN string, and chars consumed
111
+ # @raise [Error::Syntax] If format is invalid
112
+ # @raise [Error::Count] If count is invalid
10
113
  #
11
- # Strictness:
12
- # - "-" => empty hands (valid)
13
- # - "" => invalid (raises Error::Syntax)
114
+ # @example Single piece
115
+ # extract_piece_with_count(['K', 'Q'], 0) # => [1, "K", 1]
14
116
  #
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]*
117
+ # @example Multiple pieces
118
+ # extract_piece_with_count(['3', 'P', 'R'], 0) # => [3, "P", 2]
19
119
  #
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 == "-"
120
+ # @example Large count
121
+ # extract_piece_with_count(['1', '2', 'P'], 0) # => [12, "P", 3]
122
+ private_class_method def self.extract_piece_with_count(chars, start_index)
123
+ i = start_index
26
124
 
27
- entries = src.split(",").map(&:strip).reject(&:empty?)
28
- raise Error::Syntax, "malformed hands field" if entries.empty?
125
+ # Extract optional count (may be multi-digit)
126
+ count_chars = []
127
+ while i < chars.size && digit?(chars[i])
128
+ count_chars << chars[i]
129
+ i += 1
130
+ end
29
131
 
30
- counts = Hash.new(0)
132
+ count = if count_chars.empty?
133
+ 1
134
+ else
135
+ count_str = count_chars.join
136
+ count_val = count_str.to_i
137
+ validate_count(count_val, count_str)
138
+ count_val
139
+ end
31
140
 
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
141
+ # Extract EPIN piece
142
+ if i >= chars.size
143
+ raise Error::Syntax, "expected piece after count at position #{start_index}"
36
144
  end
37
145
 
38
- frozen_counts = {}
39
- counts.each { |k, v| frozen_counts[k] = Integer(v) }
146
+ epin_str, epin_consumed = extract_epin(chars, i)
147
+ i += epin_consumed
40
148
 
41
- Hands.new(frozen_counts.freeze)
149
+ consumed = i - start_index
150
+ [count, epin_str, consumed]
42
151
  end
43
152
 
44
- # Accepts forms: "P", "2xP", "P*2", "10*R", "[Shogi:P]*3"
45
- def _parse_hand_entry(str, _idx)
46
- s = str.strip
153
+ # Extract EPIN notation from character array.
154
+ #
155
+ # Handles state prefixes (+/-), base letter, and derivation suffix (').
156
+ #
157
+ # @param chars [Array<String>] Array of characters
158
+ # @param start_index [Integer] Starting index
159
+ # @return [Array(String, Integer)] EPIN string and number of characters consumed
160
+ # @raise [Error::Syntax] If EPIN format is incomplete
161
+ #
162
+ # @example Simple piece
163
+ # extract_epin(['K', 'Q'], 0) # => ["K", 1]
164
+ #
165
+ # @example Enhanced piece with derivation
166
+ # extract_epin(['+', 'R', "'", 'B'], 0) # => ["+R'", 3]
167
+ private_class_method def self.extract_epin(chars, start_index)
168
+ i = start_index
169
+ piece_chars = []
47
170
 
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
171
+ # Optional state prefix
172
+ if i < chars.size && (chars[i] == "+" || chars[i] == "-")
173
+ piece_chars << chars[i]
174
+ i += 1
175
+ end
51
176
 
52
- return [m[2].strip, n]
177
+ # Base letter (required)
178
+ if i >= chars.size || !letter?(chars[i])
179
+ raise Error::Syntax, "expected letter in EPIN notation at position #{start_index}"
53
180
  end
54
181
 
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
182
+ piece_chars << chars[i]
183
+ i += 1
58
184
 
59
- return [m[1].strip, n]
185
+ # Optional derivation suffix
186
+ if i < chars.size && chars[i] == "'"
187
+ piece_chars << chars[i]
188
+ i += 1
60
189
  end
61
190
 
62
- # Default: single piece
63
- [s, 1]
191
+ piece_str = piece_chars.join
192
+ consumed = i - start_index
193
+
194
+ [piece_str, consumed]
195
+ end
196
+
197
+ # Check if character is a digit.
198
+ #
199
+ # @param char [String] Single character
200
+ # @return [Boolean] True if character is 0-9
201
+ private_class_method def self.digit?(char)
202
+ char >= "0" && char <= "9"
64
203
  end
65
- module_function :_parse_hand_entry
66
- private_class_method :_parse_hand_entry
67
204
 
68
- def _parse_epin(token, idx)
69
- ::Sashite::Epin.parse(token)
205
+ # Check if character is a letter.
206
+ #
207
+ # @param char [String] Single character
208
+ # @return [Boolean] True if character is A-Z or a-z
209
+ private_class_method def self.letter?(char)
210
+ (char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
211
+ end
212
+
213
+ # Validate piece count.
214
+ #
215
+ # @param count [Integer] Piece count
216
+ # @param count_str [String] Original count string for error messages
217
+ # @raise [Error::Count] If count is invalid
218
+ private_class_method def self.validate_count(count, count_str)
219
+ if count < 1
220
+ raise Error::Count, "piece count must be at least 1, got #{count_str}"
221
+ end
222
+
223
+ if count > 999
224
+ raise Error::Count, "piece count too large: #{count_str}"
225
+ end
226
+ end
227
+
228
+ # Parse EPIN string into a piece object.
229
+ #
230
+ # @param epin_str [String] EPIN notation string
231
+ # @return [Object] Piece identifier object
232
+ # @raise [Error::Piece] If EPIN is invalid
233
+ #
234
+ # @example
235
+ # parse_piece("K") # => Epin::Identifier
236
+ # parse_piece("+R'") # => Epin::Identifier
237
+ private_class_method def self.parse_piece(epin_str)
238
+ unless EPIN_PATTERN.match?(epin_str)
239
+ raise Error::Piece, "invalid EPIN notation: #{epin_str}"
240
+ end
241
+
242
+ Sashite::Epin.parse(epin_str)
70
243
  rescue StandardError => e
71
- raise Error::Piece, "invalid EPIN token in hands (entry #{idx + 1}): #{token.inspect} (#{e.message})"
244
+ raise Error::Piece, "failed to parse EPIN '#{epin_str}': #{e.message}"
72
245
  end
73
- private_class_method :_parse_epin
74
246
  end
75
247
  end
76
248
  end
@@ -1,62 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../error"
4
+ require_relative "../styles"
5
+
6
+ require "sashite/sin"
7
+
3
8
  module Sashite
4
9
  module Feen
5
10
  module Parser
11
+ # Parser for the style-turn field (third field of FEEN).
12
+ #
13
+ # Converts a FEEN style-turn string into a Styles object,
14
+ # decoding game style identifiers and the active player indicator.
15
+ #
16
+ # @see https://sashite.dev/specs/feen/1.0.0/
17
+ # @see https://sashite.dev/specs/sin/1.0.0/
6
18
  module StyleTurn
7
- module_function
19
+ # Style separator in style-turn field.
20
+ STYLE_SEPARATOR = "/"
8
21
 
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).
22
+ # Parse a FEEN style-turn string into a Styles object.
23
+ #
24
+ # @param string [String] FEEN style-turn field string
25
+ # @return [Styles] Parsed styles object
26
+ # @raise [Error::Syntax] If style-turn format is invalid
27
+ # @raise [Error::Style] If SIN notation is invalid
15
28
  #
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)
29
+ # @example Chess game, white to move
30
+ # parse("C/c") # => Styles.new(sin_C, sin_c)
20
31
  #
21
- # Examples (invalid):
22
- # "w" , "C / c", "Cc", "c/c", "C/C", "x/y " (wrong pattern or spaces)
32
+ # @example Chess game, black to move
33
+ # parse("c/C") # => Styles.new(sin_c, sin_C)
23
34
  #
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/)
35
+ # @example Cross-style game, first player to move
36
+ # parse("C/m") # => Styles.new(sin_C, sin_m)
37
+ def self.parse(string)
38
+ active_str, inactive_str = split_styles(string)
30
39
 
31
- m = %r{\A([A-Za-z])/([A-Za-z])\z}.match(s)
32
- raise Error::Syntax, "invalid style/turn format" unless m
40
+ active = parse_style(active_str)
41
+ inactive = parse_style(inactive_str)
33
42
 
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")
43
+ Styles.new(active, inactive)
44
+ end
38
45
 
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
46
+ # Split style-turn string into active and inactive parts.
47
+ #
48
+ # @param string [String] Style-turn field string
49
+ # @return [Array(String, String)] Active and inactive style strings
50
+ # @raise [Error::Syntax] If separator is missing or format is invalid
51
+ #
52
+ # @example
53
+ # split_styles("C/c") # => ["C", "c"]
54
+ # split_styles("S/m") # => ["S", "m"]
55
+ private_class_method def self.split_styles(string)
56
+ parts = string.split(STYLE_SEPARATOR, 2)
41
57
 
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
58
+ raise Error::Syntax, "style-turn must contain '#{STYLE_SEPARATOR}' separator" unless parts.size == 2
45
59
 
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
60
+ raise Error::Syntax, "active style cannot be empty" if parts[0].empty?
61
+ raise Error::Syntax, "inactive style cannot be empty" if parts[1].empty?
62
+
63
+ parts
64
+ end
51
65
 
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}"
66
+ # Parse a SIN string into a style identifier object.
67
+ #
68
+ # @param sin_str [String] SIN notation string (single letter)
69
+ # @return [Object] Style identifier object
70
+ # @raise [Error::Style] If SIN is invalid
71
+ #
72
+ # @example
73
+ # parse_style("C") # => Sin::Identifier (Chess, first player)
74
+ # parse_style("c") # => Sin::Identifier (Chess, second player)
75
+ # parse_style("S") # => Sin::Identifier (Shogi, first player)
76
+ private_class_method def self.parse_style(sin_str)
77
+ unless valid_sin_format?(sin_str)
78
+ raise Error::Style, "invalid SIN notation: '#{sin_str}' (must be a single letter A-Z or a-z)"
56
79
  end
57
80
 
58
- turn = a_is_up ? :first : :second
59
- Styles.new(first_style, second_style, turn)
81
+ Sashite::Sin.parse(sin_str)
82
+ rescue ::StandardError => e
83
+ raise Error::Style, "failed to parse SIN '#{sin_str}': #{e.message}"
84
+ end
85
+
86
+ # Check if string is a valid SIN format.
87
+ #
88
+ # @param string [String] String to validate
89
+ # @return [Boolean] True if string is a single ASCII letter
90
+ private_class_method def self.valid_sin_format?(string)
91
+ string.length == 1 && letter?(string[0])
92
+ end
93
+
94
+ # Check if character is a letter.
95
+ #
96
+ # @param char [String] Single character
97
+ # @return [Boolean] True if character is A-Z or a-z
98
+ private_class_method def self.letter?(char)
99
+ (char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
60
100
  end
61
101
  end
62
102
  end
@@ -4,32 +4,68 @@ require_relative "parser/piece_placement"
4
4
  require_relative "parser/pieces_in_hand"
5
5
  require_relative "parser/style_turn"
6
6
 
7
+ require_relative "error"
8
+ require_relative "position"
9
+
7
10
  module Sashite
8
11
  module Feen
12
+ # Parser for FEEN (Forsyth–Edwards Enhanced Notation) strings.
13
+ #
14
+ # Parses a complete FEEN string by splitting it into three space-separated
15
+ # fields and delegating parsing to specialized parsers for each component.
16
+ #
17
+ # @see https://sashite.dev/specs/feen/1.0.0/
9
18
  module Parser
10
- module_function
19
+ # Field separator in FEEN notation.
20
+ FIELD_SEPARATOR = " "
21
+
22
+ # Number of required fields in a valid FEEN string.
23
+ FIELD_COUNT = 3
11
24
 
12
- def parse(feen)
13
- s = String(feen).strip
14
- raise Error::Syntax, "empty FEEN input" if s.empty?
25
+ # Parse a FEEN string into an immutable Position object.
26
+ #
27
+ # Validates the overall FEEN structure, splits the string into three
28
+ # space-separated fields, and delegates parsing of each field to the
29
+ # appropriate specialized parser.
30
+ #
31
+ # @param string [String] A FEEN notation string
32
+ # @return [Position] Immutable position object
33
+ # @raise [Error::Syntax] If the FEEN structure is malformed
34
+ #
35
+ # @example Parse a complete FEEN string
36
+ # position = Parser.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")
37
+ def self.parse(string)
38
+ fields = split_fields(string)
15
39
 
16
- a, b, c = _split_3_fields(s)
17
- placement = PiecePlacement.parse(a)
18
- hands = PiecesInHand.parse(b)
19
- styles = StyleTurn.parse(c)
40
+ placement = Parser::PiecePlacement.parse(fields[0])
41
+ hands = Parser::PiecesInHand.parse(fields[1])
42
+ styles = Parser::StyleTurn.parse(fields[2])
20
43
 
21
44
  Position.new(placement, hands, styles)
22
45
  end
23
46
 
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
47
+ # Split a FEEN string into its three constituent fields.
48
+ #
49
+ # Validates that exactly three space-separated fields are present.
50
+ #
51
+ # @param string [String] A FEEN notation string
52
+ # @return [Array<String>] Array of three field strings
53
+ # @raise [Error::Syntax] If field count is not exactly 3
54
+ #
55
+ # @example Valid FEEN string
56
+ # split_fields("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c")
57
+ # # => ["rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", "/", "C/c"]
58
+ #
59
+ # @example Invalid FEEN string (too few fields)
60
+ # split_fields("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR /")
61
+ # # raises Error::Syntax
62
+ private_class_method def self.split_fields(string)
63
+ fields = string.split(FIELD_SEPARATOR, FIELD_COUNT)
64
+
65
+ raise Error::Syntax, "FEEN must have exactly #{FIELD_COUNT} space-separated fields, got #{fields.size}" unless fields.size == FIELD_COUNT
66
+
67
+ fields
31
68
  end
32
- private_class_method :_split_3_fields
33
69
  end
34
70
  end
35
71
  end
@@ -2,30 +2,76 @@
2
2
 
3
3
  module Sashite
4
4
  module Feen
5
- # Immutable board placement: rectangular grid of cells (nil = empty, otherwise EPIN value)
5
+ # Immutable representation of board piece placement.
6
+ #
7
+ # Stores the configuration of pieces on a multi-dimensional board,
8
+ # where each position can contain a piece or be empty (nil).
9
+ #
10
+ # @see https://sashite.dev/specs/feen/1.0.0/
6
11
  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
12
+ # @return [Array<Array>] Array of ranks, each rank being an array of pieces/nils
13
+ attr_reader :ranks
14
+
15
+ # @return [Integer] Board dimensionality (2 for 2D, 3 for 3D, etc.)
16
+ attr_reader :dimension
17
+
18
+ # @return [Array<Integer>, nil] Section sizes for multi-dimensional boards (nil for 2D)
19
+ attr_reader :sections
20
+
21
+ # Create a new immutable Placement object.
22
+ #
23
+ # @param ranks [Array<Array>] Array of ranks with pieces and nils
24
+ # @param dimension [Integer] Board dimensionality (default: 2)
25
+ # @param sections [Array<Integer>, nil] Sizes of sections for dimension > 2
26
+ #
27
+ # @example Create a 2D placement
28
+ # ranks = [
29
+ # [rook, knight, bishop, queen, king, bishop, knight, rook],
30
+ # [pawn, pawn, pawn, pawn, pawn, pawn, pawn, pawn]
31
+ # ]
32
+ # placement = Placement.new(ranks)
33
+ #
34
+ # @example Create a 3D placement
35
+ # ranks = [...] # 15 ranks total
36
+ # placement = Placement.new(ranks, 3, [5, 5, 5]) # 3 sections of 5 ranks each
37
+ def initialize(ranks, dimension = 2, sections = nil)
38
+ @ranks = ranks.freeze
39
+ @dimension = dimension
40
+ @sections = sections&.freeze
41
+
27
42
  freeze
28
43
  end
44
+
45
+ # Convert placement to its FEEN string representation.
46
+ #
47
+ # @return [String] FEEN piece placement field
48
+ #
49
+ # @example
50
+ # placement.to_s
51
+ # # => "+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"
52
+ def to_s
53
+ Dumper::PiecePlacement.dump(self)
54
+ end
55
+
56
+ # Compare two placements for equality.
57
+ #
58
+ # @param other [Placement] Another placement object
59
+ # @return [Boolean] True if ranks, dimensions, and sections are equal
60
+ def ==(other)
61
+ other.is_a?(Placement) &&
62
+ ranks == other.ranks &&
63
+ dimension == other.dimension &&
64
+ sections == other.sections
65
+ end
66
+
67
+ alias eql? ==
68
+
69
+ # Generate hash code for placement.
70
+ #
71
+ # @return [Integer] Hash code based on ranks, dimension, and sections
72
+ def hash
73
+ [ranks, dimension, sections].hash
74
+ end
29
75
  end
30
76
  end
31
77
  end