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.
Files changed (7) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +98 -76
  3. data/lib/qi/board.rb +140 -91
  4. data/lib/qi/hands.rb +73 -54
  5. data/lib/qi/styles.rb +38 -54
  6. data/lib/qi.rb +126 -206
  7. 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 multi-dimensional rectangular grid (1D, 2D, or 3D)
14
- # where each square is either empty (+nil+) or occupied by a piece
15
- # (+String+).
16
- # - *Hands* — collections of off-board pieces (+String+) held by each player.
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 are normalized to +String+ via interpolation
21
- # (<tt>"#{value}"</tt>), aligning naturally with the notation formats
22
- # in the Sashité ecosystem (FEEN, EPIN, PON, SIN). Qi validates
23
- # structural integrity, not game semantics.
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. Mutable fields return defensive copies.
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 frozen +Qi+ instance and can be chained:
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, immutable position with an empty board.
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 are normalized to +String+
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 [#to_s] style for the first player (non-nil,
69
- # normalized to +String+).
70
- # @param second_player_style [#to_s] style for the second player (non-nil,
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(*shape, first_player_style:, second_player_style:)
81
- validate_shape(shape)
82
- validate_not_nil(:first, first_player_style)
83
- validate_not_nil(:second, second_player_style)
84
-
85
- @shape = shape.freeze
86
- @chunk_sizes = compute_chunk_sizes(shape).freeze
87
- @square_count = shape.reduce(:*)
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
- freeze
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 nested array matching the board shape.
97
+ # Returns the board as a flat array in row-major order.
102
98
  #
103
- # Each leaf element is +nil+ (empty square) or a +String+ (a piece).
104
- # The returned structure is an independent copy.
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] nested array (1D, 2D, or 3D depending on shape).
103
+ # @return [Array<String, nil>] the flat board (frozen).
107
104
  #
108
105
  # @example
109
- # pos = Qi.new(2, 3, first_player_style: "C", second_player_style: "c")
110
- # .board_diff(0 => "a", 5 => "b")
111
- # pos.board #=> [["a", nil, nil], [nil, nil, "b"]]
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
- unflatten(@board, @chunk_sizes, 0)
110
+ @board
114
111
  end
115
112
 
116
113
  # Returns the pieces held by the first player.
117
114
  #
118
- # @return [Array<String>] independent copy of the first player's hand.
115
+ # @return [Hash{String => Integer}] piece to count map (frozen).
119
116
  def first_player_hand
120
- @first_hand.dup
117
+ @first_hand
121
118
  end
122
119
 
123
120
  # Returns the pieces held by the second player.
124
121
  #
125
- # @return [Array<String>] independent copy of the second player's hand.
122
+ # @return [Hash{String => Integer}] piece to count map (frozen).
126
123
  def second_player_hand
127
- @second_hand.dup
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] frozen style value.
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] frozen style value.
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>] independent copy of the shape (e.g., +[8, 8]+).
150
+ # @return [Array<Integer>] the shape (e.g., +[8, 8]+) (frozen).
154
151
  def shape
155
- @shape.dup
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 (normalized to
164
- # +String+) or +nil+ (empty square).
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 => #to_s, nil}] flat index to piece mapping.
167
- # @return [Qi] a new immutable position with the updated board.
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 = @board.dup
175
- delta = 0
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
- old_value = new_board[flat_index]
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, @board_piece_count + delta)
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
- # (normalized to +String+) and each value is an integer delta:
197
- # positive adds copies, negative removes matching pieces (by value
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{#to_s => Integer}] piece to delta mapping.
201
- # @return [Qi] a new immutable position with the updated hand.
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 = apply_hand_changes(@first_hand, pieces)
210
- derive(@board, new_hand, @second_hand, @turn, @board_piece_count)
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
- # (normalized to +String+) and each value is an integer delta:
217
- # positive adds copies, negative removes matching pieces (by value
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{#to_s => Integer}] piece to delta mapping.
221
- # @return [Qi] a new immutable position with the updated hand.
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 = apply_hand_changes(@second_hand, pieces)
230
- derive(@board, @first_hand, new_hand, @turn, @board_piece_count)
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 immutable position with the opposite turn.
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, @board_piece_count)
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 both positions
268
- # are frozen and accessors return defensive copies.
269
- def derive(board, first_hand, second_hand, turn, board_piece_count)
270
- hand_piece_count = first_hand.size + second_hand.size
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, @chunk_sizes, @square_count, board_piece_count,
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 and freezes it.
281
- def init_derived(board, first_hand, second_hand, turn, shape, chunk_sizes,
282
- square_count, board_piece_count, first_player_style,
283
- second_player_style)
284
- @board = board
285
- @first_hand = first_hand
286
- @second_hand = second_hand
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(square_count, piece_count)
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qi
3
3
  version: !ruby/object:Gem::Version
4
- version: 12.0.0
4
+ version: 14.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato