sashite-feen 0.1.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.
@@ -2,30 +2,306 @@
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 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)
39
+ #
40
+ # @see https://sashite.dev/specs/feen/1.0.0/
6
41
  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
42
+ # @return [Array<Array>] Flat array of all ranks
43
+ # Each rank is an array containing piece objects and/or nils
44
+ attr_reader :ranks
45
+
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
51
+
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
17
60
 
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 }
61
+ # Create a new immutable Placement object.
62
+ #
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)
22
90
 
23
- # Deep-freeze
24
- @grid = grid.map { |row| row.dup.freeze }.freeze
25
- @height = @grid.length
26
- @width = width
91
+ validate!
27
92
  freeze
28
93
  end
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
+
164
+ # Convert placement to its FEEN string representation.
165
+ #
166
+ # Delegates to Dumper::PiecePlacement for canonical serialization.
167
+ #
168
+ # @return [String] FEEN piece placement field
169
+ #
170
+ # @example
171
+ # placement.to_s
172
+ # # => "+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"
173
+ def to_s
174
+ Dumper::PiecePlacement.dump(self)
175
+ end
176
+
177
+ # Compare two placements for equality.
178
+ #
179
+ # Two placements are equal if they have the same ranks, separators,
180
+ # and dimension.
181
+ #
182
+ # @param other [Placement] Another placement object
183
+ # @return [Boolean] True if all attributes are equal
184
+ #
185
+ # @example
186
+ # placement1 == placement2 # => true (if identical)
187
+ def ==(other)
188
+ other.is_a?(Placement) &&
189
+ ranks == other.ranks &&
190
+ separators == other.separators &&
191
+ dimension == other.dimension
192
+ end
193
+
194
+ alias eql? ==
195
+
196
+ # Generate hash code for placement.
197
+ #
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)
205
+ def 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
304
+ end
29
305
  end
30
306
  end
31
307
  end
@@ -2,25 +2,76 @@
2
2
 
3
3
  module Sashite
4
4
  module Feen
5
- # Immutable aggregate for a FEEN position: placement + hands + styles
5
+ # Immutable representation of a complete board game position.
6
+ #
7
+ # Combines piece placement, pieces in hand, and style-turn information
8
+ # into a single unified position object. This represents a complete
9
+ # snapshot of the game state at a given moment.
10
+ #
11
+ # @see https://sashite.dev/specs/feen/1.0.0/
6
12
  class Position
7
- attr_reader :placement, :hands, :styles
13
+ # @return [Placement] Board piece placement configuration
14
+ attr_reader :placement
8
15
 
9
- # @param placement [Sashite::Feen::Placement]
10
- # @param hands [Sashite::Feen::Hands]
11
- # @param styles [Sashite::Feen::Styles]
12
- def initialize(placement, hands, styles)
13
- unless placement.is_a?(Placement)
14
- raise TypeError, "placement must be Sashite::Feen::Placement, got #{placement.class}"
15
- end
16
- raise TypeError, "hands must be Sashite::Feen::Hands, got #{hands.class}" unless hands.is_a?(Hands)
17
- raise TypeError, "styles must be Sashite::Feen::Styles, got #{styles.class}" unless styles.is_a?(Styles)
16
+ # @return [Hands] Pieces held in hand by each player
17
+ attr_reader :hands
18
+
19
+ # @return [Styles] Game styles and active player indicator
20
+ attr_reader :styles
18
21
 
22
+ # Create a new immutable Position object.
23
+ #
24
+ # @param placement [Placement] Board configuration
25
+ # @param hands [Hands] Captured pieces in hand
26
+ # @param styles [Styles] Style-turn information
27
+ #
28
+ # @example Create a chess starting position
29
+ # position = Position.new(placement, hands, styles)
30
+ #
31
+ # @example Parse from FEEN string
32
+ # 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")
33
+ def initialize(placement, hands, styles)
19
34
  @placement = placement
20
- @hands = hands
21
- @styles = styles
35
+ @hands = hands
36
+ @styles = styles
37
+
22
38
  freeze
23
39
  end
40
+
41
+ # Convert position to its canonical FEEN string representation.
42
+ #
43
+ # Generates a deterministic FEEN string. The same position will
44
+ # always produce the same canonical string, enabling position
45
+ # equality via string comparison.
46
+ #
47
+ # @return [String] Canonical FEEN notation string
48
+ #
49
+ # @example
50
+ # position.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 / C/c"
52
+ def to_s
53
+ Dumper.dump(self)
54
+ end
55
+
56
+ # Compare two positions for equality.
57
+ #
58
+ # @param other [Position] Another position object
59
+ # @return [Boolean] True if all components are equal
60
+ def ==(other)
61
+ other.is_a?(Position) &&
62
+ placement == other.placement &&
63
+ hands == other.hands &&
64
+ styles == other.styles
65
+ end
66
+
67
+ alias eql? ==
68
+
69
+ # Generate hash code for position.
70
+ #
71
+ # @return [Integer] Hash code based on all components
72
+ def hash
73
+ [placement, hands, styles].hash
74
+ end
24
75
  end
25
76
  end
26
77
  end
@@ -1,74 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "sashite/sin"
4
-
5
3
  module Sashite
6
4
  module Feen
7
- # Immutable styles descriptor for FEEN style/turn field:
8
- # - first_family : one-letter SIN family (Symbol, :A..:Z)
9
- # - second_family : one-letter SIN family (Symbol, :A..:Z)
10
- # - turn : :first or :second (uppercase on dumper for the side to move)
5
+ # Immutable representation of game styles and active player.
6
+ #
7
+ # Stores the style identifiers (SIN) for both players, with the active
8
+ # player's style indicating whose turn it is to move. The case of each
9
+ # style identifier indicates which player uses it (uppercase = first player,
10
+ # lowercase = second player).
11
+ #
12
+ # @see https://sashite.dev/specs/feen/1.0.0/
13
+ # @see https://sashite.dev/specs/sin/1.0.0/
11
14
  class Styles
12
- attr_reader :first_family, :second_family, :turn
13
-
14
- VALID_TURNS = %i[first second].freeze
15
+ # @return [Object] Style identifier of the active player (to move)
16
+ attr_reader :active
17
+
18
+ # @return [Object] Style identifier of the inactive player (waiting)
19
+ attr_reader :inactive
20
+
21
+ # Create a new immutable Styles object.
22
+ #
23
+ # @param active [Object] SIN identifier for active player's style
24
+ # @param inactive [Object] SIN identifier for inactive player's style
25
+ #
26
+ # @example Chess game, white to move
27
+ # styles = Styles.new(sin_C, sin_c)
28
+ #
29
+ # @example Chess game, black to move
30
+ # styles = Styles.new(sin_c, sin_C)
31
+ #
32
+ # @example Cross-style game, first player to move
33
+ # styles = Styles.new(sin_C, sin_m)
34
+ def initialize(active, inactive)
35
+ @active = active
36
+ @inactive = inactive
15
37
 
16
- # @param first_family [Symbol, String, Sashite::Sin::Identifier]
17
- # @param second_family [Symbol, String, Sashite::Sin::Identifier]
18
- # @param turn [:first, :second]
19
- def initialize(first_family, second_family, turn)
20
- @first_family = _coerce_family(first_family)
21
- @second_family = _coerce_family(second_family)
22
- @turn = _coerce_turn(turn)
23
38
  freeze
24
39
  end
25
40
 
26
- # Helpers for dumper -----------------------------------------------------
27
-
28
- # Return single-letter uppercase string for first/second family
29
- def first_letter_uc
30
- _family_letter_uc(@first_family)
31
- end
32
-
33
- def second_letter_uc
34
- _family_letter_uc(@second_family)
41
+ # Convert styles to their FEEN string representation.
42
+ #
43
+ # @return [String] FEEN style-turn field
44
+ #
45
+ # @example
46
+ # styles.to_s
47
+ # # => "C/c"
48
+ def to_s
49
+ Dumper::StyleTurn.dump(self)
35
50
  end
36
51
 
37
- private
38
-
39
- def _coerce_turn(t)
40
- raise ArgumentError, "turn must be :first or :second, got #{t.inspect}" unless VALID_TURNS.include?(t)
41
-
42
- t
52
+ # Compare two styles for equality.
53
+ #
54
+ # @param other [Styles] Another styles object
55
+ # @return [Boolean] True if active and inactive styles are equal
56
+ def ==(other)
57
+ other.is_a?(Styles) &&
58
+ active == other.active &&
59
+ inactive == other.inactive
43
60
  end
44
61
 
45
- # Accepts SIN Identifier, Symbol, or String
46
- # Canonical storage is a Symbol in :A..:Z (uppercase)
47
- def _coerce_family(x)
48
- family_sym =
49
- case x
50
- when ::Sashite::Sin::Identifier
51
- x.family
52
- when Symbol
53
- x
54
- else
55
- s = String(x)
56
- raise ArgumentError, "invalid SIN family #{x.inspect}" unless s.match?(/\A[A-Za-z]\z/)
57
-
58
- s.upcase.to_sym
59
- end
60
-
61
- raise ArgumentError, "Family must be :A..:Z, got #{family_sym.inspect}" unless (:A..:Z).cover?(family_sym)
62
-
63
- # Validate via SIN once (ensures family is recognized by sashite-sin)
64
- raise Error::Style, "Unknown SIN family #{family_sym.inspect}" unless ::Sashite::Sin.valid?(family_sym.to_s)
65
-
66
- family_sym
67
- end
62
+ alias eql? ==
68
63
 
69
- def _family_letter_uc(family_sym)
70
- # Build a canonical SIN identifier to get the letter; side doesn't matter for uc
71
- ::Sashite::Sin.identifier(family_sym, :first).to_s # uppercase
64
+ # Generate hash code for styles.
65
+ #
66
+ # @return [Integer] Hash code based on active and inactive styles
67
+ def hash
68
+ [active, inactive].hash
72
69
  end
73
70
  end
74
71
  end