qi 13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b37a16ba39200581ce95bbc044a21d9e94ef05f4adfdaa46ba43e638dc15a1ea
4
- data.tar.gz: 3c0a40366819743667499b384b05dc8437280e2786f8de390a27e581b4fb2f33
3
+ metadata.gz: d616536a97f64f4010976e9b6f8f80fbe1fdc61f1211ef95a17e98ebbbc1d2e8
4
+ data.tar.gz: 25b6ce774a649d36fa10f48733d680afd9ef8c93d90844d6fd2d6357726e3dab
5
5
  SHA512:
6
- metadata.gz: 1b5ce7e1160532c9b3e6b1f49cd82ba5f0a70d58d6049f8c17023181faeabbe095167e2b0c87886a25b32ac9f18dce7b3fccca3af66d3f42e66dde0ea6cedd74
7
- data.tar.gz: 154cc1881f5d539f1ba00c80ed57592f2a19889d28e89bf98311ba89015df6e2435bd9fed6c3dc3aef9483ad06c3a2f059b6c20f7939a872f290201e633565e9
6
+ metadata.gz: f5ac9f630af9039dcf96a8a56558dc6acbfec5c6f0836132d2e95cb6a132dcf8c23e520fd8101e3a810f330b5aacd7077461d068a6b65b9396ae093fa95f7e48
7
+ data.tar.gz: 3dde63bd8f57f6901f597046fbe749c7695c2b5b6e77fad2a101f2598eeff7fead2630c42b46d5f552207a6d248ba9548f8c0b94be1d645e568365b351f776e5
data/README.md CHANGED
@@ -27,7 +27,7 @@ pos2.board[4] #=> "K"
27
27
  pos2.board[60] #=> "k"
28
28
  ```
29
29
 
30
- Every transformation returns a **new instance**. The original is never modified.
30
+ Every transformation returns a **new instance**. The original is never modified. All returned internal state is frozen — callers cannot corrupt positions through accessors.
31
31
 
32
32
  ## Overview
33
33
 
@@ -54,7 +54,7 @@ pos.board_diff(0 => "+P") # Promoted — stored as "+P"
54
54
 
55
55
  ```ruby
56
56
  # In your Gemfile
57
- gem "qi", "~> 13.0"
57
+ gem "qi", "~> 14.0"
58
58
  ```
59
59
 
60
60
  Or install manually:
@@ -77,9 +77,9 @@ Creates a position with an empty board.
77
77
 
78
78
  **Parameters:**
79
79
 
80
- - `shape` — an `Array` of one to three `Integer` dimension sizes (each 1–255).
81
- - `first_player_style:` — style for the first player (non-nil string).
82
- - `second_player_style:` — style for the second player (non-nil string).
80
+ - `shape` — an `Array` of one to three `Integer` dimension sizes (each 1–255). The total number of squares (product of dimensions) must not exceed 65,025.
81
+ - `first_player_style:` — style for the first player (non-nil string, at most 255 bytes).
82
+ - `second_player_style:` — style for the second player (non-nil string, at most 255 bytes).
83
83
 
84
84
  The board starts with all squares empty (`nil`), both hands start empty, and the turn defaults to `:first`.
85
85
 
@@ -89,7 +89,7 @@ Qi.new([8], first_player_style: "G", second_player_style: "g") # 1D
89
89
  Qi.new([5, 5, 5], first_player_style: "R", second_player_style: "r") # 3D
90
90
  ```
91
91
 
92
- **Raises** `ArgumentError` if shape constraints are violated or if a style is `nil` (see [Validation Errors](#validation-errors)).
92
+ **Raises** `ArgumentError` if shape constraints are violated, if total squares exceed the limit, or if a style is `nil` or oversized (see [Validation Errors](#validation-errors)).
93
93
 
94
94
  ### Constants
95
95
 
@@ -97,20 +97,23 @@ Qi.new([5, 5, 5], first_player_style: "R", second_player_style: "r") # 3D
97
97
  |----------|-------|-------------|
98
98
  | `Qi::MAX_DIMENSIONS` | `3` | Maximum number of board dimensions |
99
99
  | `Qi::MAX_DIMENSION_SIZE` | `255` | Maximum size of any single dimension |
100
+ | `Qi::MAX_SQUARE_COUNT` | `65025` | Maximum total number of squares on a board |
101
+ | `Qi::MAX_PIECE_BYTESIZE` | `255` | Maximum bytesize of a piece string |
102
+ | `Qi::MAX_STYLE_BYTESIZE` | `255` | Maximum bytesize of a style string |
100
103
 
101
104
  ### Accessors
102
105
 
103
- All accessors return internal state directly — no copies, no allocations.
106
+ All accessors return frozen internal state — no copies, no allocations. Attempting to mutate a returned object raises `FrozenError`.
104
107
 
105
108
  | Method | Returns | Description |
106
109
  |--------|---------|-------------|
107
- | `board` | `Array` | Flat array of `nil` or `String`. Indexed in row-major order. |
108
- | `first_player_hand` | `Hash{String => Integer}` | First player's held pieces as piece → count map. |
109
- | `second_player_hand` | `Hash{String => Integer}` | Second player's held pieces as piece → count map. |
110
+ | `board` | `Array` | Flat array of `nil` or `String` (frozen). Indexed in row-major order. |
111
+ | `first_player_hand` | `Hash{String => Integer}` | First player's held pieces as piece → count map (frozen). |
112
+ | `second_player_hand` | `Hash{String => Integer}` | Second player's held pieces as piece → count map (frozen). |
110
113
  | `turn` | `Symbol` | `:first` or `:second`. |
111
114
  | `first_player_style` | `String` | First player's style. |
112
115
  | `second_player_style` | `String` | Second player's style. |
113
- | `shape` | `Array<Integer>` | Board dimensions (e.g., `[8, 8]`). |
116
+ | `shape` | `Array<Integer>` | Board dimensions (e.g., `[8, 8]`) (frozen). |
114
117
  | `inspect` | `String` | Developer-friendly, unstable format. Do not parse. |
115
118
 
116
119
  ```ruby
@@ -144,13 +147,13 @@ All transformation methods return a **new `Qi` instance**. The original is never
144
147
 
145
148
  Returns a new position with modified squares.
146
149
 
147
- Keys are flat indices (`Integer`, 0-based, row-major order). Values are pieces (`String`) or `nil` (empty square).
150
+ Keys are flat indices (`Integer`, 0-based, row-major order). Values are pieces (`String`, at most 255 bytes) or `nil` (empty square).
148
151
 
149
152
  ```ruby
150
153
  pos2 = pos.board_diff(12 => nil, 28 => "P")
151
154
  ```
152
155
 
153
- **Raises** `ArgumentError` if an index is out of range, if a piece is not a `String`, or if the resulting total piece count exceeds the board size.
156
+ **Raises** `ArgumentError` if an index is out of range, if a piece is not a `String`, if a piece exceeds 255 bytes, or if the resulting total piece count exceeds the board size.
154
157
 
155
158
  See [Flat Indexing](#flat-indexing) for computing flat indices from coordinates.
156
159
 
@@ -159,7 +162,7 @@ See [Flat Indexing](#flat-indexing) for computing flat indices from coordinates.
159
162
 
160
163
  Returns a new position with a modified hand.
161
164
 
162
- Keys are piece identifiers; values are integer deltas (positive to add, negative to remove, zero is a no-op).
165
+ Keys are piece identifiers (at most 255 bytes after string normalization); values are integer deltas (positive to add, negative to remove, zero is a no-op).
163
166
 
164
167
  ```ruby
165
168
  pos2 = pos.first_player_hand_diff("P": 1) # Add one "P"
@@ -171,7 +174,7 @@ Internally, hands are stored as `{piece => count}` hashes. Adding and removing p
171
174
 
172
175
  **String normalization of keys:** Ruby keyword arguments produce Symbol keys, so `first_player_hand_diff("P": 1)` passes `{P: 1}` with key `:P` (a Symbol). The implementation normalizes this to the String `"P"` before storing. This is a Ruby-specific concern — the important contract is that the hand always contains strings, matching the board's piece type.
173
176
 
174
- **Raises** `ArgumentError` if a delta is not an `Integer`, if removing a piece not present, or if the resulting total piece count exceeds the board size.
177
+ **Raises** `ArgumentError` if a delta is not an `Integer`, if a piece exceeds 255 bytes, if removing a piece not present, or if the resulting total piece count exceeds the board size.
175
178
 
176
179
  #### `toggle` → `Qi`
177
180
 
@@ -215,7 +218,7 @@ The `board` accessor always returns a flat array. Use `to_nested` when a nested
215
218
 
216
219
  Each `square` is either `nil` (empty) or a `String` (a piece).
217
220
 
218
- For a shape `[D1, D2, ..., DN]`, the total number of squares is `D1 × D2 × ... × DN`.
221
+ For a shape `[D1, D2, ..., DN]`, the total number of squares is `D1 × D2 × ... × DN`. This total must not exceed 65,025 (`MAX_SQUARE_COUNT`).
219
222
 
220
223
  ### Flat Indexing
221
224
 
@@ -275,8 +278,8 @@ pos.first_player_hand_diff("c": 1)
275
278
 
276
279
  Construction validates fields in a guaranteed order. When multiple errors exist, the **first** failing check determines the error message:
277
280
 
278
- 1. **Shape** — dimension count, types, and bounds
279
- 2. **Styles** — nil checks (first, then second), then type checks
281
+ 1. **Shape** — dimension count, types, bounds, then total square count
282
+ 2. **Styles** — nil checks (first, then second), then type checks, then bytesize checks
280
283
 
281
284
  This order is part of the public API contract.
282
285
 
@@ -289,10 +292,13 @@ This order is part of the public API contract.
289
292
  | `"dimension size must be an Integer, got C"` | Non-integer dimension size |
290
293
  | `"dimension size must be at least 1, got N"` | Dimension size is zero or negative |
291
294
  | `"dimension size N exceeds maximum of 255"` | Dimension size exceeds 255 |
295
+ | `"board exceeds 65025 squares (got N)"` | Total square count exceeds limit |
292
296
  | `"first player style must not be nil"` | First style is `nil` |
293
297
  | `"second player style must not be nil"` | Second style is `nil` |
294
298
  | `"first player style must be a String"` | First style is not a String |
295
299
  | `"second player style must be a String"` | Second style is not a String |
300
+ | `"first player style exceeds 255 bytes"` | First style is too large |
301
+ | `"second player style exceeds 255 bytes"` | Second style is too large |
296
302
 
297
303
  ### Transformation Errors
298
304
 
@@ -300,6 +306,7 @@ This order is part of the public API contract.
300
306
  |---------------|--------|-------|
301
307
  | `"invalid flat index: I (board has N squares)"` | `board_diff` | Index out of range or non-integer key |
302
308
  | `"piece must be a String, got C"` | `board_diff` | Non-string piece value |
309
+ | `"piece exceeds 255 bytes (got N)"` | `board_diff`, hand diffs | Piece string too large |
303
310
  | `"delta must be an Integer, got C for piece P"` | hand diffs | Non-integer delta |
304
311
  | `"cannot remove P: not found in hand"` | hand diffs | Removing more pieces than present |
305
312
  | `"too many pieces for board size (P pieces, N squares)"` | all | Total pieces would exceed board capacity |
@@ -308,7 +315,11 @@ This order is part of the public API contract.
308
315
 
309
316
  **Purely functional.** Every transformation method returns a new `Qi` instance. The original is never modified. This eliminates an entire class of bugs around shared mutable state and makes positions safe to use as hash keys, cache entries, or history snapshots.
310
317
 
311
- **Performance-oriented internals.** The board is stored as a flat array for O(1) random access via `board[index]`. Hands are stored as `{piece => count}` hashes for O(1) additions and removals. Accessors return internal state directly — no defensive copies, no freezing, no allocation overhead. String validation replaces coercion to avoid per-operation interpolation.
318
+ **Frozen by default.** All mutable containers (board arrays, hand hashes, shape arrays) are frozen after construction. Accessors return these frozen objects directly — no defensive copies, no allocation overhead. Attempting to mutate a returned object raises `FrozenError`, making invariant violations impossible rather than merely documented.
319
+
320
+ **Performance-oriented internals.** The board is stored as a flat array for O(1) random access via `board[index]`. Hands are stored as `{piece => count}` hashes for O(1) additions and removals. Freezing is O(1) per object (a bit flag in Ruby) and happens only in constructors, never on the hot path. String validation replaces coercion to avoid per-operation interpolation.
321
+
322
+ **Bounded resource consumption.** All inputs are bounded: board dimensions (1–255 per axis, 65,025 total squares), piece strings (255 bytes), style strings (255 bytes). No input can trigger unbounded memory allocation. The library is safe to use in an internet-facing service with zero additional sanitization by the caller.
312
323
 
313
324
  **Diff-based transformations.** Rather than rebuilding a full position from scratch, `board_diff` and hand diff methods express changes as deltas against the current state. This keeps the API surface small (four transformation methods cover all possible state transitions) while making the intent of each operation explicit.
314
325
 
@@ -316,9 +327,7 @@ This order is part of the public API contract.
316
327
 
317
328
  ## Concurrency
318
329
 
319
- `Qi` instances are never mutated after creation. Transformation methods allocate new instances and share unchanged internal structures by reference. This makes positions safe to share across threads and Ractors without synchronization.
320
-
321
- Callers should treat returned accessors as read-only. Mutating the array returned by `board` or the hash returned by a hand accessor corrupts the position. If you need a mutable working copy, call `.dup` on the returned value.
330
+ `Qi` instances are never mutated after creation. All internal containers are frozen at construction time. Transformation methods allocate new instances and share unchanged frozen structures by reference. This makes positions safe to share across threads and Ractors without synchronization.
322
331
 
323
332
  ## Ecosystem
324
333
 
@@ -338,25 +347,27 @@ The complete public API consists of:
338
347
  - **7 accessors** — `board`, `first_player_hand`, `second_player_hand`, `turn`, `first_player_style`, `second_player_style`, `shape`
339
348
  - **5 methods** — `board_diff`, `first_player_hand_diff`, `second_player_hand_diff`, `toggle`, `to_nested`
340
349
  - **1 debug** — `inspect`
341
- - **2 constants** — `MAX_DIMENSIONS`, `MAX_DIMENSION_SIZE`
350
+ - **5 constants** — `MAX_DIMENSIONS`, `MAX_DIMENSION_SIZE`, `MAX_SQUARE_COUNT`, `MAX_PIECE_BYTESIZE`, `MAX_STYLE_BYTESIZE`
342
351
 
343
352
  ### Key Semantic Contracts
344
353
 
345
354
  **Pieces and styles are strings.** Board squares, hand contents, and style values are all stored as strings. Non-string inputs are rejected at the boundary.
346
355
 
356
+ **All inputs are bounded.** Dimensions are capped at 255, total squares at 65,025, piece strings at 255 bytes, style strings at 255 bytes. No input can trigger unbounded memory allocation. Reimplementations must enforce these same limits to maintain the security properties.
357
+
347
358
  **Piece equality is by value**, not by identity. Hand operations use standard Ruby `==` for piece matching. Use the equivalent in your language (`Eq` in Rust, `__eq__` in Python, `equals()` in Java).
348
359
 
349
360
  **Piece cardinality is global.** The constraint `p ≤ n` counts pieces across all locations: board squares plus both hands. A transformation that adds a piece to a hand can exceed the limit even if the board has empty squares.
350
361
 
351
362
  **Nil means empty.** On the board, `nil` (or the language equivalent) represents an empty square. It is never coerced to a string. Styles must not be nil — this is the only nil-related error at construction.
352
363
 
353
- **Validation order is guaranteed**: shape → styles. Tests assert which error is reported when multiple inputs are invalid simultaneously.
364
+ **Validation order is guaranteed**: shape (dimensions total square count) → styles (nil → type → bytesize). Tests assert which error is reported when multiple inputs are invalid simultaneously.
354
365
 
355
366
  **Hands are piece → count maps.** Internally, hands use `{"P" => 2, "B" => 1}` rather than flat lists. This gives O(1) add/remove and makes count queries trivial. Empty entries (count reaching zero) are removed from the map.
356
367
 
357
368
  **The constructor creates an empty position**: board all nil, hands empty, turn is first player. Pieces are added via `board_diff` and hand diff methods.
358
369
 
359
- **Accessors return shared state**: callers must not mutate returned arrays or hashes. Languages with immutable data structures (Elixir, Haskell, Clojure) get this for free. In Ruby, this is a documented contract.
370
+ **Accessors return frozen shared state.** In Ruby, all returned arrays and hashes are frozen, making mutation impossible. Languages with immutable data structures (Elixir, Haskell, Clojure) get this for free. In languages with mutable defaults (Python, Java, JavaScript), reimplementations should either freeze/lock returned structures or return defensive copies.
360
371
 
361
372
  ### Hand Diff and String Normalization
362
373
 
data/lib/qi/board.rb CHANGED
@@ -30,12 +30,15 @@ class Qi
30
30
  module Board
31
31
  MAX_DIMENSIONS = 3
32
32
  MAX_DIMENSION_SIZE = 255
33
+ MAX_SQUARE_COUNT = 65_025
34
+ MAX_PIECE_BYTESIZE = 255
33
35
 
34
36
  # Validates board dimensions and returns the total square count.
35
37
  #
36
38
  # @param shape [Array<Integer>] dimension sizes (1 to 3 integers, each 1–255).
37
39
  # @return [Integer] the total number of squares.
38
40
  # @raise [ArgumentError] if the shape is invalid.
41
+ # @raise [ArgumentError] if the total square count exceeds {MAX_SQUARE_COUNT}.
39
42
  #
40
43
  # @example
41
44
  # Qi::Board.validate_shape([8, 8]) #=> 64
@@ -64,7 +67,13 @@ class Qi
64
67
  end
65
68
  end
66
69
 
67
- shape.reduce(:*)
70
+ total = shape.reduce(:*)
71
+
72
+ if total > MAX_SQUARE_COUNT
73
+ raise ::ArgumentError, "board exceeds #{MAX_SQUARE_COUNT} squares (got #{total})"
74
+ end
75
+
76
+ total
68
77
  end
69
78
 
70
79
  # Applies changes to a flat board, returning a new array and updated piece count.
@@ -78,6 +87,7 @@ class Qi
78
87
  # @param changes [Hash{Integer => String, nil}] flat index to piece mapping.
79
88
  # @return [Array(Array, Integer)] +[new_board, new_board_piece_count]+.
80
89
  # @raise [ArgumentError] if an index is invalid or a piece is not a String.
90
+ # @raise [ArgumentError] if a piece exceeds {MAX_PIECE_BYTESIZE} bytes.
81
91
  #
82
92
  # @example Place two pieces
83
93
  # Qi::Board.apply_diff(Array.new(4), 4, 0, { 0 => "K", 3 => "k" })
@@ -99,6 +109,10 @@ class Qi
99
109
  raise ::ArgumentError, "piece must be a String, got #{piece.class}"
100
110
  end
101
111
 
112
+ if piece.is_a?(::String) && piece.bytesize > MAX_PIECE_BYTESIZE
113
+ raise ::ArgumentError, "piece exceeds #{MAX_PIECE_BYTESIZE} bytes (got #{piece.bytesize})"
114
+ end
115
+
102
116
  old = new_board[index]
103
117
  delta += (piece.nil? ? 0 : 1) - (old.nil? ? 0 : 1)
104
118
  new_board[index] = piece
data/lib/qi/hands.rb CHANGED
@@ -24,6 +24,8 @@ class Qi
24
24
  # new_hand #=> { "P" => 1 }
25
25
  # count #=> 1
26
26
  module Hands
27
+ MAX_PIECE_BYTESIZE = 255
28
+
27
29
  # Applies delta changes to a hand, returning the new hash and its
28
30
  # piece count.
29
31
  #
@@ -44,6 +46,7 @@ class Qi
44
46
  # @return [Array(Hash{String => Integer}, Integer)]
45
47
  # +[new_hand, new_piece_count]+.
46
48
  # @raise [ArgumentError] if a delta is not an Integer.
49
+ # @raise [ArgumentError] if a piece exceeds {MAX_PIECE_BYTESIZE} bytes.
47
50
  # @raise [ArgumentError] if removing more pieces than present.
48
51
  #
49
52
  # @example Add pieces
@@ -70,6 +73,10 @@ class Qi
70
73
 
71
74
  piece = piece_key.is_a?(::Symbol) ? piece_key.name : piece_key
72
75
 
76
+ if piece.bytesize > MAX_PIECE_BYTESIZE
77
+ raise ::ArgumentError, "piece exceeds #{MAX_PIECE_BYTESIZE} bytes (got #{piece.bytesize})"
78
+ end
79
+
73
80
  current = result[piece] || 0
74
81
  new_count = current + delta
75
82
 
data/lib/qi/styles.rb CHANGED
@@ -11,15 +11,19 @@ class Qi
11
11
  # @example Validate a style
12
12
  # Qi::Styles.validate(:first, "C") #=> "C"
13
13
  module Styles
14
+ MAX_STYLE_BYTESIZE = 255
15
+
14
16
  # Validates a single player style and returns it.
15
17
  #
16
- # The style must not be +nil+ and must be a +String+. The validated
17
- # value is returned as-is (no coercion, no allocation).
18
+ # The style must not be +nil+, must be a +String+, and must not
19
+ # exceed {MAX_STYLE_BYTESIZE} bytes. The validated value is
20
+ # returned as-is (no coercion, no allocation).
18
21
  #
19
22
  # @param side [Symbol] +:first+ or +:second+, used in error messages.
20
23
  # @param style [Object] the style value to validate.
21
24
  # @return [String] the validated style.
22
25
  # @raise [ArgumentError] if the style is nil or not a String.
26
+ # @raise [ArgumentError] if the style exceeds {MAX_STYLE_BYTESIZE} bytes.
23
27
  #
24
28
  # @example Valid style
25
29
  # Qi::Styles.validate(:first, "C") #=> "C"
@@ -31,6 +35,10 @@ class Qi
31
35
  # @example Non-string style
32
36
  # Qi::Styles.validate(:second, :chess)
33
37
  # # => ArgumentError: second player style must be a String
38
+ #
39
+ # @example Oversized style
40
+ # Qi::Styles.validate(:first, "A" * 256)
41
+ # # => ArgumentError: first player style exceeds 255 bytes
34
42
  def self.validate(side, style)
35
43
  if style.nil?
36
44
  raise ::ArgumentError, "#{side} player style must not be nil"
@@ -40,6 +48,10 @@ class Qi
40
48
  raise ::ArgumentError, "#{side} player style must be a String"
41
49
  end
42
50
 
51
+ if style.bytesize > MAX_STYLE_BYTESIZE
52
+ raise ::ArgumentError, "#{side} player style exceeds #{MAX_STYLE_BYTESIZE} bytes"
53
+ end
54
+
43
55
  style
44
56
  end
45
57
  end
data/lib/qi.rb CHANGED
@@ -20,6 +20,9 @@ require_relative "qi/styles"
20
20
  # Pieces and styles must be +String+ values. Non-string inputs are
21
21
  # rejected at the boundary to avoid per-operation coercion overhead.
22
22
  #
23
+ # All returned internal state is frozen. Callers cannot mutate
24
+ # positions through accessors.
25
+ #
23
26
  # == Construction
24
27
  #
25
28
  # A position is constructed from the board shape and player styles.
@@ -32,8 +35,7 @@ require_relative "qi/styles"
32
35
  #
33
36
  # Use +board+, +first_player_hand+, +second_player_hand+, +turn+,
34
37
  # +first_player_style+, +second_player_style+, and +shape+ to read
35
- # field values. Accessors return internal state directly — callers
36
- # must not mutate the returned objects.
38
+ # field values. Accessors return frozen internal state.
37
39
  #
38
40
  # == Transformations
39
41
  #
@@ -56,6 +58,9 @@ require_relative "qi/styles"
56
58
  class Qi
57
59
  MAX_DIMENSIONS = Board::MAX_DIMENSIONS
58
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
59
64
 
60
65
  # Creates a validated position with an empty board.
61
66
  #
@@ -77,10 +82,10 @@ class Qi
77
82
  @square_count = Board.validate_shape(shape)
78
83
  @first_player_style = Styles.validate(:first, first_player_style)
79
84
  @second_player_style = Styles.validate(:second, second_player_style)
80
- @shape = shape
81
- @board = ::Array.new(@square_count)
82
- @first_hand = {}
83
- @second_hand = {}
85
+ @shape = shape.dup.freeze
86
+ @board = ::Array.new(@square_count).freeze
87
+ @first_hand = {}.freeze
88
+ @second_hand = {}.freeze
84
89
  @turn = :first
85
90
  @board_piece_count = 0
86
91
  @first_hand_count = 0
@@ -92,10 +97,10 @@ class Qi
92
97
  # Returns the board as a flat array in row-major order.
93
98
  #
94
99
  # Each element is +nil+ (empty square) or a +String+ (a piece).
95
- # The returned array is the internal structure do not mutate it.
96
- # Use +to_nested+ when a nested structure is needed.
100
+ # The returned array is frozen. Use +to_nested+ when a nested
101
+ # structure is needed.
97
102
  #
98
- # @return [Array<String, nil>] the flat board.
103
+ # @return [Array<String, nil>] the flat board (frozen).
99
104
  #
100
105
  # @example
101
106
  # pos = Qi.new([4], first_player_style: "C", second_player_style: "c")
@@ -107,14 +112,14 @@ class Qi
107
112
 
108
113
  # Returns the pieces held by the first player.
109
114
  #
110
- # @return [Hash{String => Integer}] piece to count map. Do not mutate.
115
+ # @return [Hash{String => Integer}] piece to count map (frozen).
111
116
  def first_player_hand
112
117
  @first_hand
113
118
  end
114
119
 
115
120
  # Returns the pieces held by the second player.
116
121
  #
117
- # @return [Hash{String => Integer}] piece to count map. Do not mutate.
122
+ # @return [Hash{String => Integer}] piece to count map (frozen).
118
123
  def second_player_hand
119
124
  @second_hand
120
125
  end
@@ -142,7 +147,7 @@ class Qi
142
147
 
143
148
  # Returns the board dimensions.
144
149
  #
145
- # @return [Array<Integer>] the shape (e.g., +[8, 8]+). Do not mutate.
150
+ # @return [Array<Integer>] the shape (e.g., +[8, 8]+) (frozen).
146
151
  def shape
147
152
  @shape
148
153
  end
@@ -272,8 +277,8 @@ class Qi
272
277
  # Skips shape and style validation since the source position already
273
278
  # guarantees these invariants. Only checks cardinality (done by caller).
274
279
  #
275
- # Unchanged fields are shared by reference — safe because transformation
276
- # methods never mutate existing state.
280
+ # Unchanged fields are shared by reference — safe because all internal
281
+ # state is frozen.
277
282
  def derive(board, first_hand, second_hand, turn,
278
283
  board_piece_count, first_hand_count, second_hand_count)
279
284
  instance = self.class.allocate
@@ -285,13 +290,16 @@ class Qi
285
290
  end
286
291
 
287
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).
288
296
  def init_derived(board, first_hand, second_hand, turn, shape,
289
297
  square_count, board_piece_count,
290
298
  first_hand_count, second_hand_count,
291
299
  first_player_style, second_player_style)
292
- @board = board
293
- @first_hand = first_hand
294
- @second_hand = second_hand
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
295
303
  @turn = turn
296
304
  @shape = shape
297
305
  @square_count = square_count
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: 13.0.0
4
+ version: 14.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato