sashite-feen 0.3.0 → 0.5.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,307 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sashite
4
- module Feen
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/
41
- class Placement
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
60
-
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)
90
-
91
- validate!
92
- freeze
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
305
- end
306
- end
307
- end
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sashite
4
- module Feen
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/
12
- class Position
13
- # @return [Placement] Board piece placement configuration
14
- attr_reader :placement
15
-
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
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)
34
- @placement = placement
35
- @hands = hands
36
- @styles = styles
37
-
38
- freeze
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
75
- end
76
- end
77
- end
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sashite
4
- module Feen
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/
14
- class Styles
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
37
-
38
- freeze
39
- end
40
-
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)
50
- end
51
-
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
60
- end
61
-
62
- alias eql? ==
63
-
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
69
- end
70
- end
71
- end
72
- end