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 +4 -4
- data/README.md +36 -25
- data/lib/qi/board.rb +15 -1
- data/lib/qi/hands.rb +7 -0
- data/lib/qi/styles.rb +14 -2
- data/lib/qi.rb +25 -17
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d616536a97f64f4010976e9b6f8f80fbe1fdc61f1211ef95a17e98ebbbc1d2e8
|
|
4
|
+
data.tar.gz: 25b6ce774a649d36fa10f48733d680afd9ef8c93d90844d6fd2d6357726e3dab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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", "~>
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
**
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
17
|
-
#
|
|
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
|
|
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
|
|
96
|
-
#
|
|
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
|
|
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
|
|
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]+)
|
|
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
|
|
276
|
-
#
|
|
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
|