qi 12.0.0 → 14.0.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 +98 -76
- data/lib/qi/board.rb +140 -91
- data/lib/qi/hands.rb +73 -54
- data/lib/qi/styles.rb +38 -54
- data/lib/qi.rb +126 -206
- metadata +1 -1
data/lib/qi.rb
CHANGED
|
@@ -10,17 +10,18 @@ require_relative "qi/styles"
|
|
|
10
10
|
# Qi models the components of a position as defined by the
|
|
11
11
|
# Sashité Game Protocol:
|
|
12
12
|
#
|
|
13
|
-
# - *Board* — a
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
13
|
+
# - *Board* — a flat array in row-major order (1D, 2D, or 3D) where
|
|
14
|
+
# each element is either empty (+nil+) or occupied by a piece (+String+).
|
|
15
|
+
# - *Hands* — piece-to-count hashes (String keys, Integer values) for
|
|
16
|
+
# each player.
|
|
17
17
|
# - *Styles* — one style +String+ per player side.
|
|
18
18
|
# - *Turn* — which player is active (+:first+ or +:second+).
|
|
19
19
|
#
|
|
20
|
-
# Pieces and styles
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
20
|
+
# Pieces and styles must be +String+ values. Non-string inputs are
|
|
21
|
+
# rejected at the boundary to avoid per-operation coercion overhead.
|
|
22
|
+
#
|
|
23
|
+
# All returned internal state is frozen. Callers cannot mutate
|
|
24
|
+
# positions through accessors.
|
|
24
25
|
#
|
|
25
26
|
# == Construction
|
|
26
27
|
#
|
|
@@ -28,24 +29,24 @@ require_relative "qi/styles"
|
|
|
28
29
|
# The board starts empty (all squares +nil+), both hands start empty,
|
|
29
30
|
# and the turn starts as +:first+.
|
|
30
31
|
#
|
|
31
|
-
# pos = Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
|
|
32
|
+
# pos = Qi.new([8, 8], first_player_style: "C", second_player_style: "c")
|
|
32
33
|
#
|
|
33
34
|
# == Accessors
|
|
34
35
|
#
|
|
35
36
|
# Use +board+, +first_player_hand+, +second_player_hand+, +turn+,
|
|
36
37
|
# +first_player_style+, +second_player_style+, and +shape+ to read
|
|
37
|
-
# field values.
|
|
38
|
+
# field values. Accessors return frozen internal state.
|
|
38
39
|
#
|
|
39
40
|
# == Transformations
|
|
40
41
|
#
|
|
41
42
|
# Use +board_diff+, +first_player_hand_diff+, +second_player_hand_diff+,
|
|
42
43
|
# and +toggle+ to derive new positions. Transformation methods return
|
|
43
|
-
# a new
|
|
44
|
+
# a new +Qi+ instance and can be chained:
|
|
44
45
|
#
|
|
45
46
|
# pos2 = pos.board_diff(12 => nil, 28 => "C:P").first_player_hand_diff("c:p": 1).toggle
|
|
46
47
|
#
|
|
47
48
|
# @example A chess starting position
|
|
48
|
-
# pos = Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
|
|
49
|
+
# pos = Qi.new([8, 8], first_player_style: "C", second_player_style: "c")
|
|
49
50
|
# .board_diff(
|
|
50
51
|
# 0 => "r", 1 => "n", 2 => "b", 3 => "q", 4 => "k", 5 => "b", 6 => "n", 7 => "r",
|
|
51
52
|
# 8 => "p", 9 => "p", 10 => "p", 11 => "p", 12 => "p", 13 => "p", 14 => "p", 15 => "p",
|
|
@@ -53,78 +54,74 @@ require_relative "qi/styles"
|
|
|
53
54
|
# 56 => "R", 57 => "N", 58 => "B", 59 => "Q", 60 => "K", 61 => "B", 62 => "N", 63 => "R"
|
|
54
55
|
# )
|
|
55
56
|
# pos.turn #=> :first
|
|
56
|
-
# pos.first_player_hand #=>
|
|
57
|
+
# pos.first_player_hand #=> {}
|
|
57
58
|
class Qi
|
|
58
59
|
MAX_DIMENSIONS = Board::MAX_DIMENSIONS
|
|
59
60
|
MAX_DIMENSION_SIZE = Board::MAX_DIMENSION_SIZE
|
|
61
|
+
MAX_SQUARE_COUNT = Board::MAX_SQUARE_COUNT
|
|
62
|
+
MAX_PIECE_BYTESIZE = Board::MAX_PIECE_BYTESIZE
|
|
63
|
+
MAX_STYLE_BYTESIZE = Styles::MAX_STYLE_BYTESIZE
|
|
60
64
|
|
|
61
|
-
# Creates a validated
|
|
65
|
+
# Creates a validated position with an empty board.
|
|
62
66
|
#
|
|
63
67
|
# The board starts with all squares empty (+nil+), both hands empty,
|
|
64
|
-
# and the turn set to +:first+. Styles
|
|
65
|
-
# and frozen.
|
|
68
|
+
# and the turn set to +:first+. Styles must be +String+ values.
|
|
66
69
|
#
|
|
67
70
|
# @param shape [Array<Integer>] dimension sizes (1 to 3 integers, each 1–255).
|
|
68
|
-
# @param first_player_style [
|
|
69
|
-
#
|
|
70
|
-
# @
|
|
71
|
-
# normalized to +String+).
|
|
72
|
-
# @return [Qi] an immutable, validated position.
|
|
71
|
+
# @param first_player_style [String] style for the first player (non-nil string).
|
|
72
|
+
# @param second_player_style [String] style for the second player (non-nil string).
|
|
73
|
+
# @return [Qi] a validated position.
|
|
73
74
|
# @raise [ArgumentError] if any constraint is violated.
|
|
74
75
|
#
|
|
75
76
|
# @example 2D chess board
|
|
76
|
-
# Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
|
|
77
|
+
# Qi.new([8, 8], first_player_style: "C", second_player_style: "c")
|
|
77
78
|
#
|
|
78
79
|
# @example 3D board
|
|
79
|
-
# Qi.new(5, 5, 5, first_player_style: "R", second_player_style: "r")
|
|
80
|
-
def initialize(
|
|
81
|
-
validate_shape(shape)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
@
|
|
86
|
-
@
|
|
87
|
-
@
|
|
88
|
-
@board = ::Array.new(@square_count)
|
|
89
|
-
@first_hand = []
|
|
90
|
-
@second_hand = []
|
|
91
|
-
@first_player_style = "#{first_player_style}".freeze
|
|
92
|
-
@second_player_style = "#{second_player_style}".freeze
|
|
80
|
+
# Qi.new([5, 5, 5], first_player_style: "R", second_player_style: "r")
|
|
81
|
+
def initialize(shape, first_player_style:, second_player_style:)
|
|
82
|
+
@square_count = Board.validate_shape(shape)
|
|
83
|
+
@first_player_style = Styles.validate(:first, first_player_style)
|
|
84
|
+
@second_player_style = Styles.validate(:second, second_player_style)
|
|
85
|
+
@shape = shape.dup.freeze
|
|
86
|
+
@board = ::Array.new(@square_count).freeze
|
|
87
|
+
@first_hand = {}.freeze
|
|
88
|
+
@second_hand = {}.freeze
|
|
93
89
|
@turn = :first
|
|
94
90
|
@board_piece_count = 0
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
@first_hand_count = 0
|
|
92
|
+
@second_hand_count = 0
|
|
97
93
|
end
|
|
98
94
|
|
|
99
95
|
# --- Accessors ---------------------------------------------------------------
|
|
100
96
|
|
|
101
|
-
# Returns the board as a
|
|
97
|
+
# Returns the board as a flat array in row-major order.
|
|
102
98
|
#
|
|
103
|
-
# Each
|
|
104
|
-
# The returned
|
|
99
|
+
# Each element is +nil+ (empty square) or a +String+ (a piece).
|
|
100
|
+
# The returned array is frozen. Use +to_nested+ when a nested
|
|
101
|
+
# structure is needed.
|
|
105
102
|
#
|
|
106
|
-
# @return [Array
|
|
103
|
+
# @return [Array<String, nil>] the flat board (frozen).
|
|
107
104
|
#
|
|
108
105
|
# @example
|
|
109
|
-
# pos = Qi.new(
|
|
110
|
-
# .board_diff(0 => "
|
|
111
|
-
# pos.board #=> [
|
|
106
|
+
# pos = Qi.new([4], first_player_style: "C", second_player_style: "c")
|
|
107
|
+
# .board_diff(0 => "k", 3 => "K")
|
|
108
|
+
# pos.board #=> ["k", nil, nil, "K"]
|
|
112
109
|
def board
|
|
113
|
-
|
|
110
|
+
@board
|
|
114
111
|
end
|
|
115
112
|
|
|
116
113
|
# Returns the pieces held by the first player.
|
|
117
114
|
#
|
|
118
|
-
# @return [
|
|
115
|
+
# @return [Hash{String => Integer}] piece to count map (frozen).
|
|
119
116
|
def first_player_hand
|
|
120
|
-
@first_hand
|
|
117
|
+
@first_hand
|
|
121
118
|
end
|
|
122
119
|
|
|
123
120
|
# Returns the pieces held by the second player.
|
|
124
121
|
#
|
|
125
|
-
# @return [
|
|
122
|
+
# @return [Hash{String => Integer}] piece to count map (frozen).
|
|
126
123
|
def second_player_hand
|
|
127
|
-
@second_hand
|
|
124
|
+
@second_hand
|
|
128
125
|
end
|
|
129
126
|
|
|
130
127
|
# Returns the active player's side.
|
|
@@ -136,23 +133,40 @@ class Qi
|
|
|
136
133
|
|
|
137
134
|
# Returns the first player's style.
|
|
138
135
|
#
|
|
139
|
-
# @return [String]
|
|
136
|
+
# @return [String] style value.
|
|
140
137
|
def first_player_style
|
|
141
138
|
@first_player_style
|
|
142
139
|
end
|
|
143
140
|
|
|
144
141
|
# Returns the second player's style.
|
|
145
142
|
#
|
|
146
|
-
# @return [String]
|
|
143
|
+
# @return [String] style value.
|
|
147
144
|
def second_player_style
|
|
148
145
|
@second_player_style
|
|
149
146
|
end
|
|
150
147
|
|
|
151
148
|
# Returns the board dimensions.
|
|
152
149
|
#
|
|
153
|
-
# @return [Array<Integer>]
|
|
150
|
+
# @return [Array<Integer>] the shape (e.g., +[8, 8]+) (frozen).
|
|
154
151
|
def shape
|
|
155
|
-
@shape
|
|
152
|
+
@shape
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- Conversions --------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
# Returns the board as a nested array matching the shape.
|
|
158
|
+
#
|
|
159
|
+
# This is an O(n) operation intended for display or serialization,
|
|
160
|
+
# not for the hot path.
|
|
161
|
+
#
|
|
162
|
+
# @return [Array] nested array (1D, 2D, or 3D depending on shape).
|
|
163
|
+
#
|
|
164
|
+
# @example
|
|
165
|
+
# pos = Qi.new([2, 3], first_player_style: "C", second_player_style: "c")
|
|
166
|
+
# .board_diff(0 => "a", 5 => "b")
|
|
167
|
+
# pos.to_nested #=> [["a", nil, nil], [nil, nil, "b"]]
|
|
168
|
+
def to_nested
|
|
169
|
+
Board.to_nested(@board, @shape)
|
|
156
170
|
end
|
|
157
171
|
|
|
158
172
|
# --- Transformations ---------------------------------------------------------
|
|
@@ -160,45 +174,36 @@ class Qi
|
|
|
160
174
|
# Returns a new position with modified squares on the board.
|
|
161
175
|
#
|
|
162
176
|
# Accepts keyword arguments where each key is a flat index (Integer,
|
|
163
|
-
# 0-based, row-major order) and each value is a piece (
|
|
164
|
-
# +
|
|
177
|
+
# 0-based, row-major order) and each value is a piece (+String+) or
|
|
178
|
+
# +nil+ (empty square).
|
|
165
179
|
#
|
|
166
|
-
# @param squares [Hash{Integer =>
|
|
167
|
-
# @return [Qi] a new
|
|
180
|
+
# @param squares [Hash{Integer => String, nil}] flat index to piece mapping.
|
|
181
|
+
# @return [Qi] a new position with the updated board.
|
|
168
182
|
# @raise [ArgumentError] if a key is not a valid flat index.
|
|
183
|
+
# @raise [ArgumentError] if a piece is not a String.
|
|
169
184
|
# @raise [ArgumentError] if the resulting piece count exceeds the board size.
|
|
170
185
|
#
|
|
171
186
|
# @example Move a piece from index 12 to index 28
|
|
172
187
|
# pos2 = pos.board_diff(12 => nil, 28 => "C:P")
|
|
173
188
|
def board_diff(**squares)
|
|
174
|
-
new_board =
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
squares.each do |flat_index, value|
|
|
178
|
-
unless flat_index.is_a?(::Integer) && flat_index >= 0 && flat_index < @square_count
|
|
179
|
-
raise ::ArgumentError, "invalid flat index: #{flat_index} (board has #{@square_count} squares)"
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
value = "#{value}" unless value.nil?
|
|
189
|
+
new_board, new_board_piece_count = Board.apply_diff(
|
|
190
|
+
@board, @square_count, @board_piece_count, squares
|
|
191
|
+
)
|
|
183
192
|
|
|
184
|
-
|
|
185
|
-
# Track net change in piece count: +1 if filling, -1 if emptying, 0 if replacing.
|
|
186
|
-
delta += (value.nil? ? 0 : 1) - (old_value.nil? ? 0 : 1)
|
|
187
|
-
new_board[flat_index] = value
|
|
188
|
-
end
|
|
193
|
+
validate_cardinality(new_board_piece_count + @first_hand_count + @second_hand_count)
|
|
189
194
|
|
|
190
|
-
derive(new_board, @first_hand, @second_hand, @turn,
|
|
195
|
+
derive(new_board, @first_hand, @second_hand, @turn,
|
|
196
|
+
new_board_piece_count, @first_hand_count, @second_hand_count)
|
|
191
197
|
end
|
|
192
198
|
|
|
193
199
|
# Returns a new position with the first player's hand modified.
|
|
194
200
|
#
|
|
195
201
|
# Accepts keyword arguments where each key is a piece identifier
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
# equality). A delta of zero is a no-op.
|
|
202
|
+
# and each value is an integer delta: positive adds copies, negative
|
|
203
|
+
# removes. A delta of zero is a no-op.
|
|
199
204
|
#
|
|
200
|
-
# @param pieces [Hash{
|
|
201
|
-
# @return [Qi] a new
|
|
205
|
+
# @param pieces [Hash{Symbol => Integer}] piece to delta mapping.
|
|
206
|
+
# @return [Qi] a new position with the updated hand.
|
|
202
207
|
# @raise [ArgumentError] if a delta is not an Integer.
|
|
203
208
|
# @raise [ArgumentError] if removing more pieces than present.
|
|
204
209
|
# @raise [ArgumentError] if the resulting piece count exceeds the board size.
|
|
@@ -206,19 +211,22 @@ class Qi
|
|
|
206
211
|
# @example Add a pawn and remove a bishop
|
|
207
212
|
# pos2 = pos.first_player_hand_diff("S:P": 1, "S:B": -1)
|
|
208
213
|
def first_player_hand_diff(**pieces)
|
|
209
|
-
new_hand =
|
|
210
|
-
|
|
214
|
+
new_hand, new_count = Hands.apply_diff(@first_hand, @first_hand_count, pieces)
|
|
215
|
+
|
|
216
|
+
validate_cardinality(@board_piece_count + new_count + @second_hand_count)
|
|
217
|
+
|
|
218
|
+
derive(@board, new_hand, @second_hand, @turn,
|
|
219
|
+
@board_piece_count, new_count, @second_hand_count)
|
|
211
220
|
end
|
|
212
221
|
|
|
213
222
|
# Returns a new position with the second player's hand modified.
|
|
214
223
|
#
|
|
215
224
|
# Accepts keyword arguments where each key is a piece identifier
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
# equality). A delta of zero is a no-op.
|
|
225
|
+
# and each value is an integer delta: positive adds copies, negative
|
|
226
|
+
# removes. A delta of zero is a no-op.
|
|
219
227
|
#
|
|
220
|
-
# @param pieces [Hash{
|
|
221
|
-
# @return [Qi] a new
|
|
228
|
+
# @param pieces [Hash{Symbol => Integer}] piece to delta mapping.
|
|
229
|
+
# @return [Qi] a new position with the updated hand.
|
|
222
230
|
# @raise [ArgumentError] if a delta is not an Integer.
|
|
223
231
|
# @raise [ArgumentError] if removing more pieces than present.
|
|
224
232
|
# @raise [ArgumentError] if the resulting piece count exceeds the board size.
|
|
@@ -226,22 +234,27 @@ class Qi
|
|
|
226
234
|
# @example Add a captured pawn
|
|
227
235
|
# pos2 = pos.second_player_hand_diff("c:p": 1)
|
|
228
236
|
def second_player_hand_diff(**pieces)
|
|
229
|
-
new_hand =
|
|
230
|
-
|
|
237
|
+
new_hand, new_count = Hands.apply_diff(@second_hand, @second_hand_count, pieces)
|
|
238
|
+
|
|
239
|
+
validate_cardinality(@board_piece_count + @first_hand_count + new_count)
|
|
240
|
+
|
|
241
|
+
derive(@board, @first_hand, new_hand, @turn,
|
|
242
|
+
@board_piece_count, @first_hand_count, new_count)
|
|
231
243
|
end
|
|
232
244
|
|
|
233
245
|
# Returns a new position with the active player swapped.
|
|
234
246
|
#
|
|
235
247
|
# All other fields (board, hands, styles) are preserved unchanged.
|
|
236
248
|
#
|
|
237
|
-
# @return [Qi] a new
|
|
249
|
+
# @return [Qi] a new position with the opposite turn.
|
|
238
250
|
#
|
|
239
251
|
# @example
|
|
240
|
-
# pos = Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
|
|
252
|
+
# pos = Qi.new([8, 8], first_player_style: "C", second_player_style: "c")
|
|
241
253
|
# pos.turn #=> :first
|
|
242
254
|
# pos.toggle.turn #=> :second
|
|
243
255
|
def toggle
|
|
244
|
-
derive(@board, @first_hand, @second_hand, other_turn,
|
|
256
|
+
derive(@board, @first_hand, @second_hand, other_turn,
|
|
257
|
+
@board_piece_count, @first_hand_count, @second_hand_count)
|
|
245
258
|
end
|
|
246
259
|
|
|
247
260
|
# Returns a developer-friendly string representation.
|
|
@@ -262,137 +275,44 @@ class Qi
|
|
|
262
275
|
# Fast-path constructor for derived positions.
|
|
263
276
|
#
|
|
264
277
|
# Skips shape and style validation since the source position already
|
|
265
|
-
# guarantees these invariants. Only checks cardinality.
|
|
278
|
+
# guarantees these invariants. Only checks cardinality (done by caller).
|
|
266
279
|
#
|
|
267
|
-
# Unchanged fields are shared by reference — safe because
|
|
268
|
-
#
|
|
269
|
-
def derive(board, first_hand, second_hand, turn,
|
|
270
|
-
|
|
271
|
-
validate_cardinality(@square_count, board_piece_count + hand_piece_count)
|
|
272
|
-
|
|
280
|
+
# Unchanged fields are shared by reference — safe because all internal
|
|
281
|
+
# state is frozen.
|
|
282
|
+
def derive(board, first_hand, second_hand, turn,
|
|
283
|
+
board_piece_count, first_hand_count, second_hand_count)
|
|
273
284
|
instance = self.class.allocate
|
|
274
285
|
instance.send(:init_derived, board, first_hand, second_hand, turn,
|
|
275
|
-
@shape, @
|
|
286
|
+
@shape, @square_count, board_piece_count,
|
|
287
|
+
first_hand_count, second_hand_count,
|
|
276
288
|
@first_player_style, @second_player_style)
|
|
277
289
|
instance
|
|
278
290
|
end
|
|
279
291
|
|
|
280
|
-
# Assigns instance variables for a derived position
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
292
|
+
# Assigns instance variables for a derived position.
|
|
293
|
+
#
|
|
294
|
+
# Freezes mutable containers (board, hands) to guarantee immutability.
|
|
295
|
+
# Shape and styles are already frozen (shared from the source position).
|
|
296
|
+
def init_derived(board, first_hand, second_hand, turn, shape,
|
|
297
|
+
square_count, board_piece_count,
|
|
298
|
+
first_hand_count, second_hand_count,
|
|
299
|
+
first_player_style, second_player_style)
|
|
300
|
+
@board = board.frozen? ? board : board.freeze
|
|
301
|
+
@first_hand = first_hand.frozen? ? first_hand : first_hand.freeze
|
|
302
|
+
@second_hand = second_hand.frozen? ? second_hand : second_hand.freeze
|
|
287
303
|
@turn = turn
|
|
288
304
|
@shape = shape
|
|
289
|
-
@chunk_sizes = chunk_sizes
|
|
290
305
|
@square_count = square_count
|
|
291
306
|
@board_piece_count = board_piece_count
|
|
307
|
+
@first_hand_count = first_hand_count
|
|
308
|
+
@second_hand_count = second_hand_count
|
|
292
309
|
@first_player_style = first_player_style
|
|
293
310
|
@second_player_style = second_player_style
|
|
294
|
-
|
|
295
|
-
freeze
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
# --- Validation --------------------------------------------------------------
|
|
299
|
-
|
|
300
|
-
def validate_shape(shape)
|
|
301
|
-
if shape.empty?
|
|
302
|
-
raise ::ArgumentError, "at least one dimension is required"
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
if shape.size > MAX_DIMENSIONS
|
|
306
|
-
raise ::ArgumentError, "board exceeds #{MAX_DIMENSIONS} dimensions (got #{shape.size})"
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
shape.each do |dim|
|
|
310
|
-
unless dim.is_a?(::Integer)
|
|
311
|
-
raise ::ArgumentError, "dimension size must be an Integer, got #{dim.class}"
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
if dim < 1
|
|
315
|
-
raise ::ArgumentError, "dimension size must be at least 1, got #{dim}"
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
if dim > MAX_DIMENSION_SIZE
|
|
319
|
-
raise ::ArgumentError, "dimension size #{dim} exceeds maximum of #{MAX_DIMENSION_SIZE}"
|
|
320
|
-
end
|
|
321
|
-
end
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
def validate_not_nil(side, style)
|
|
325
|
-
raise ::ArgumentError, "#{side} player style must not be nil" if style.nil?
|
|
326
311
|
end
|
|
327
312
|
|
|
328
|
-
def validate_cardinality(
|
|
329
|
-
return if piece_count <= square_count
|
|
313
|
+
def validate_cardinality(piece_count)
|
|
314
|
+
return if piece_count <= @square_count
|
|
330
315
|
|
|
331
|
-
raise ::ArgumentError, "too many pieces for board size (#{piece_count} pieces, #{square_count} squares)"
|
|
316
|
+
raise ::ArgumentError, "too many pieces for board size (#{piece_count} pieces, #{@square_count} squares)"
|
|
332
317
|
end
|
|
333
|
-
|
|
334
|
-
# --- Hand helpers ------------------------------------------------------------
|
|
335
|
-
|
|
336
|
-
# Applies delta changes to a hand array, returning a new array.
|
|
337
|
-
#
|
|
338
|
-
# Piece keys are normalized to String via interpolation.
|
|
339
|
-
def apply_hand_changes(hand, changes)
|
|
340
|
-
result = hand.dup
|
|
341
|
-
|
|
342
|
-
changes.each do |piece_key, delta|
|
|
343
|
-
unless delta.is_a?(::Integer)
|
|
344
|
-
raise ::ArgumentError, "delta must be an Integer, got #{delta.class} for piece #{piece_key.inspect}"
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
next if delta == 0
|
|
348
|
-
|
|
349
|
-
piece = "#{piece_key}"
|
|
350
|
-
|
|
351
|
-
if delta > 0
|
|
352
|
-
delta.times { result << piece }
|
|
353
|
-
else
|
|
354
|
-
(-delta).times do
|
|
355
|
-
idx = result.index(piece)
|
|
356
|
-
|
|
357
|
-
unless idx
|
|
358
|
-
raise ::ArgumentError, "cannot remove #{piece.inspect}: not found in hand"
|
|
359
|
-
end
|
|
360
|
-
|
|
361
|
-
result.delete_at(idx)
|
|
362
|
-
end
|
|
363
|
-
end
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
result
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
# --- Board helpers -----------------------------------------------------------
|
|
370
|
-
|
|
371
|
-
# Pre-computes chunk sizes for each dimension level.
|
|
372
|
-
# For shape [8, 8], returns [8, 1].
|
|
373
|
-
# For shape [5, 5, 5], returns [25, 5, 1].
|
|
374
|
-
# For shape [8], returns [1].
|
|
375
|
-
def compute_chunk_sizes(shape)
|
|
376
|
-
sizes = ::Array.new(shape.size)
|
|
377
|
-
sizes[-1] = 1
|
|
378
|
-
(shape.size - 2).downto(0) do |i|
|
|
379
|
-
sizes[i] = sizes[i + 1] * shape[i + 1]
|
|
380
|
-
end
|
|
381
|
-
sizes
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
# Reconstructs a nested board structure from a flat Array and
|
|
385
|
-
# pre-computed chunk sizes. Returns an independent copy.
|
|
386
|
-
def unflatten(flat, chunk_sizes, dim)
|
|
387
|
-
if dim == chunk_sizes.size - 1
|
|
388
|
-
return flat.dup
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
chunk = chunk_sizes[dim]
|
|
392
|
-
flat.each_slice(chunk).map do |slice|
|
|
393
|
-
unflatten(slice, chunk_sizes, dim + 1)
|
|
394
|
-
end
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
freeze
|
|
398
318
|
end
|