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.
- checksums.yaml +4 -4
- data/README.md +436 -42
- data/lib/sashite/feen/dumper/piece_placement.rb +107 -50
- data/lib/sashite/feen/dumper/pieces_in_hand.rb +1 -0
- data/lib/sashite/feen/hands.rb +2 -2
- data/lib/sashite/feen/parser/piece_placement.rb +190 -176
- data/lib/sashite/feen/parser/pieces_in_hand.rb +7 -13
- data/lib/sashite/feen/parser.rb +11 -2
- data/lib/sashite/feen/placement.rb +260 -30
- metadata +1 -1
|
@@ -4,46 +4,167 @@ module Sashite
|
|
|
4
4
|
module Feen
|
|
5
5
|
# Immutable representation of board piece placement.
|
|
6
6
|
#
|
|
7
|
-
# Stores
|
|
8
|
-
#
|
|
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>]
|
|
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 [
|
|
16
|
-
|
|
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 [
|
|
19
|
-
|
|
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
|
|
24
|
-
# @param
|
|
25
|
-
# @param
|
|
26
|
-
#
|
|
27
|
-
# @
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# @example Create
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
#
|
|
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,
|
|
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
|