sashite-feen 0.2.0 → 0.3.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.
@@ -4,46 +4,167 @@ module Sashite
4
4
  module Feen
5
5
  # Immutable representation of board piece placement.
6
6
  #
7
- # Stores the configuration of pieces on a multi-dimensional board,
8
- # where each position can contain a piece or be empty (nil).
7
+ # Stores board configuration as a flat array of ranks with explicit
8
+ # separators, allowing representation of any valid FEEN structure
9
+ # including highly irregular multi-dimensional boards.
10
+ #
11
+ # This design supports complete flexibility:
12
+ # - Any number of dimensions (1D to nD)
13
+ # - Irregular board shapes (different rank sizes)
14
+ # - Arbitrary separator patterns (different separator lengths)
15
+ #
16
+ # @example 1D board (no separators)
17
+ # ranks = [[king, nil, pawn]]
18
+ # placement = Placement.new(ranks)
19
+ #
20
+ # @example Regular 2D board
21
+ # ranks = [
22
+ # [rook, knight, bishop, queen, king, bishop, knight, rook],
23
+ # [pawn, pawn, pawn, pawn, pawn, pawn, pawn, pawn],
24
+ # # ... 6 more ranks
25
+ # ]
26
+ # separators = ["/", "/", "/", "/", "/", "/", "/"]
27
+ # placement = Placement.new(ranks, separators)
28
+ #
29
+ # @example Irregular 3D board
30
+ # ranks = [[r1], [r2], [r3], [r4]]
31
+ # separators = ["/", "//", "/"] # Mixed dimension separators
32
+ # placement = Placement.new(ranks, separators)
33
+ #
34
+ # @example Highly irregular structure
35
+ # # "99999/3///K/k//r"
36
+ # ranks = [[nil]*99999, [nil]*3, [king], [king_b], [rook]]
37
+ # separators = ["/", "///", "/", "//"]
38
+ # placement = Placement.new(ranks, separators)
9
39
  #
10
40
  # @see https://sashite.dev/specs/feen/1.0.0/
11
41
  class Placement
12
- # @return [Array<Array>] Array of ranks, each rank being an array of pieces/nils
42
+ # @return [Array<Array>] Flat array of all ranks
43
+ # Each rank is an array containing piece objects and/or nils
13
44
  attr_reader :ranks
14
45
 
15
- # @return [Integer] Board dimensionality (2 for 2D, 3 for 3D, etc.)
16
- attr_reader :dimension
46
+ # @return [Array<String>] Separators between consecutive ranks
47
+ # separators[i] is the separator between ranks[i] and ranks[i+1]
48
+ # Each separator is a string of one or more "/" characters
49
+ # Always has length = ranks.length - 1 (or empty for single rank)
50
+ attr_reader :separators
17
51
 
18
- # @return [Array<Integer>, nil] Section sizes for multi-dimensional boards (nil for 2D)
19
- attr_reader :sections
52
+ # @return [Integer] Board dimensionality
53
+ # Calculated as: 1 + (maximum consecutive "/" characters in any separator)
54
+ # Examples:
55
+ # - No separators → 1D
56
+ # - Only "/" → 2D
57
+ # - At least one "//" → 3D
58
+ # - At least one "///" → 4D
59
+ attr_reader :dimension
20
60
 
21
61
  # Create a new immutable Placement object.
22
62
  #
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
63
+ # @param ranks [Array<Array>] Array of ranks (each rank is array of pieces/nils)
64
+ # @param separators [Array<String>] Separators between ranks (default: [])
65
+ # @param dimension [Integer, nil] Explicit dimension (auto-calculated if nil)
66
+ #
67
+ # @raise [ArgumentError] If separators count doesn't match ranks
68
+ # @raise [ArgumentError] If any separator is invalid
69
+ # @raise [ArgumentError] If dimension is less than 1
70
+ #
71
+ # @example Create 1D placement
72
+ # Placement.new([[king, nil, pawn]])
73
+ #
74
+ # @example Create 2D placement
75
+ # Placement.new(
76
+ # [[rank1_pieces], [rank2_pieces]],
77
+ # ["/"]
78
+ # )
79
+ #
80
+ # @example Create 3D placement with explicit dimension
81
+ # Placement.new(
82
+ # [[r1], [r2], [r3]],
83
+ # ["/", "//"],
84
+ # 3
85
+ # )
86
+ def initialize(ranks, separators = [], dimension = nil)
87
+ @ranks = deep_freeze_ranks(ranks)
88
+ @separators = separators.freeze
89
+ @dimension = dimension || calculate_dimension(separators)
41
90
 
91
+ validate!
42
92
  freeze
43
93
  end
44
94
 
95
+ # Get total number of ranks across all dimensions.
96
+ #
97
+ # @return [Integer] Total rank count
98
+ #
99
+ # @example
100
+ # placement.rank_count # => 8 (for standard chess board)
101
+ def rank_count
102
+ @ranks.size
103
+ end
104
+
105
+ # Check if the board is 1-dimensional (single rank, no separators).
106
+ #
107
+ # @return [Boolean] True if dimension is 1
108
+ #
109
+ # @example
110
+ # placement.one_dimensional? # => false (for 2D chess board)
111
+ def one_dimensional?
112
+ @dimension == 1
113
+ end
114
+
115
+ # Get all pieces from all ranks (flattened).
116
+ #
117
+ # @return [Array] Flat array of all pieces (nils excluded)
118
+ #
119
+ # @example
120
+ # placement.all_pieces.size # => 32 (for chess starting position)
121
+ def all_pieces
122
+ @ranks.flatten.compact
123
+ end
124
+
125
+ # Get total number of squares across all ranks.
126
+ #
127
+ # @return [Integer] Total square count
128
+ #
129
+ # @example
130
+ # placement.total_squares # => 64 (for 8x8 chess board)
131
+ def total_squares
132
+ @ranks.sum(&:size)
133
+ end
134
+
135
+ # Convert placement to array representation based on dimensionality.
136
+ #
137
+ # The returned structure depends on board dimension:
138
+ # - 1D boards: Returns single rank array (or empty array if no ranks)
139
+ # - 2D+ boards: Returns array of ranks
140
+ #
141
+ # @return [Array] Array representation of the board
142
+ #
143
+ # @example 1D board (single rank)
144
+ # placement = Placement.new([[K, nil, P]], [], 1)
145
+ # placement.to_a # => [K, nil, P]
146
+ #
147
+ # @example 1D empty board
148
+ # placement = Placement.new([], [], 1)
149
+ # placement.to_a # => []
150
+ #
151
+ # @example 2D board (multiple ranks)
152
+ # placement = Placement.new([[r, n], [p, p]], ["/"], 2)
153
+ # placement.to_a # => [[r, n], [p, p]]
154
+ #
155
+ # @example 3D board (returns flat array of all ranks)
156
+ # placement = Placement.new([[r], [n], [b]], ["/", "//"], 3)
157
+ # placement.to_a # => [[r], [n], [b]]
158
+ def to_a
159
+ return ranks.first || [] if one_dimensional?
160
+
161
+ ranks
162
+ end
163
+
45
164
  # Convert placement to its FEEN string representation.
46
165
  #
166
+ # Delegates to Dumper::PiecePlacement for canonical serialization.
167
+ #
47
168
  # @return [String] FEEN piece placement field
48
169
  #
49
170
  # @example
@@ -55,22 +176,131 @@ module Sashite
55
176
 
56
177
  # Compare two placements for equality.
57
178
  #
179
+ # Two placements are equal if they have the same ranks, separators,
180
+ # and dimension.
181
+ #
58
182
  # @param other [Placement] Another placement object
59
- # @return [Boolean] True if ranks, dimensions, and sections are equal
183
+ # @return [Boolean] True if all attributes are equal
184
+ #
185
+ # @example
186
+ # placement1 == placement2 # => true (if identical)
60
187
  def ==(other)
61
188
  other.is_a?(Placement) &&
62
189
  ranks == other.ranks &&
63
- dimension == other.dimension &&
64
- sections == other.sections
190
+ separators == other.separators &&
191
+ dimension == other.dimension
65
192
  end
66
193
 
67
194
  alias eql? ==
68
195
 
69
196
  # Generate hash code for placement.
70
197
  #
71
- # @return [Integer] Hash code based on ranks, dimension, and sections
198
+ # Ensures that equal placements have equal hash codes for use
199
+ # in hash-based collections.
200
+ #
201
+ # @return [Integer] Hash code based on ranks, separators, and dimension
202
+ #
203
+ # @example
204
+ # placement1.hash == placement2.hash # => true (if equal)
72
205
  def hash
73
- [ranks, dimension, sections].hash
206
+ [ranks, separators, dimension].hash
207
+ end
208
+
209
+ # Get a human-readable representation of the placement.
210
+ #
211
+ # @return [String] Debug representation
212
+ #
213
+ # @example
214
+ # placement.inspect
215
+ # # => "#<Sashite::Feen::Placement dimension=2 ranks=8 separators=7>"
216
+ def inspect
217
+ "#<#{self.class.name} dimension=#{dimension} ranks=#{rank_count} separators=#{separators.size}>"
218
+ end
219
+
220
+ private
221
+
222
+ # Deep freeze ranks array to ensure immutability.
223
+ #
224
+ # Freezes both the outer array and each individual rank array.
225
+ #
226
+ # @param ranks_array [Array<Array>] Array of ranks
227
+ # @return [Array<Array>] Frozen ranks array
228
+ def deep_freeze_ranks(ranks_array)
229
+ ranks_array.map(&:freeze).freeze
230
+ end
231
+
232
+ # Validate placement structure.
233
+ #
234
+ # Checks:
235
+ # 1. Separator count matches rank count (must be ranks.size - 1)
236
+ # 2. All separators are valid (one or more "/" characters)
237
+ # 3. Dimension is at least 1
238
+ #
239
+ # @raise [ArgumentError] If validation fails
240
+ def validate!
241
+ validate_separator_count!
242
+ validate_separators!
243
+ validate_dimension!
244
+ end
245
+
246
+ # Validate that separator count matches rank count.
247
+ #
248
+ # For n ranks, there must be exactly n-1 separators.
249
+ # Special case: 0 or 1 rank requires empty separators array.
250
+ #
251
+ # @raise [ArgumentError] If count mismatch
252
+ def validate_separator_count!
253
+ expected_count = [ranks.size - 1, 0].max
254
+
255
+ return if separators.size == expected_count
256
+
257
+ raise ArgumentError,
258
+ "Expected #{expected_count} separator(s) for #{ranks.size} rank(s), got #{separators.size}"
259
+ end
260
+
261
+ # Validate that all separators are valid.
262
+ #
263
+ # Each separator must be a non-empty string containing only "/" characters.
264
+ #
265
+ # @raise [ArgumentError] If any separator is invalid
266
+ def validate_separators!
267
+ separators.each_with_index do |sep, idx|
268
+ unless sep.is_a?(String) && !sep.empty? && sep.match?(%r{\A/+\z})
269
+ raise ArgumentError,
270
+ "Invalid separator at index #{idx}: #{sep.inspect} (must be one or more '/' characters)"
271
+ end
272
+ end
273
+ end
274
+
275
+ # Validate that dimension is valid.
276
+ #
277
+ # Dimension must be at least 1.
278
+ #
279
+ # @raise [ArgumentError] If dimension is invalid
280
+ def validate_dimension!
281
+ return if dimension.is_a?(Integer) && dimension >= 1
282
+
283
+ raise ArgumentError,
284
+ "Dimension must be an integer >= 1, got #{dimension.inspect}"
285
+ end
286
+
287
+ # Calculate dimension from separators.
288
+ #
289
+ # Dimension is defined as: 1 + (max consecutive "/" in any separator)
290
+ #
291
+ # Examples:
292
+ # - [] → 1 (no separators = 1D)
293
+ # - ["/", "/"] → 2 (only single "/" = 2D)
294
+ # - ["/", "//", "/"] → 3 (max is "//" = 3D)
295
+ # - ["///"] → 4 (max is "///" = 4D)
296
+ #
297
+ # @param seps [Array<String>] Array of separator strings
298
+ # @return [Integer] Calculated dimension
299
+ def calculate_dimension(seps)
300
+ return 1 if seps.empty?
301
+
302
+ max_slashes = seps.map(&:length).max
303
+ max_slashes + 1
74
304
  end
75
305
  end
76
306
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sashite-feen
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato