qi 12.0.0 → 13.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 +81 -70
  3. data/lib/qi/board.rb +128 -93
  4. data/lib/qi/hands.rb +67 -55
  5. data/lib/qi/styles.rb +28 -56
  6. data/lib/qi.rb +114 -202
  7. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c916e1be3a5a9e6c40fe3fad735488b69a4663607d55f80989a8daf023fd84b5
4
- data.tar.gz: d4ba55868fa1dbfce8cf9d46dde4ed3be9d839cf59320c7f9f90ee9973c60393
3
+ metadata.gz: b37a16ba39200581ce95bbc044a21d9e94ef05f4adfdaa46ba43e638dc15a1ea
4
+ data.tar.gz: 3c0a40366819743667499b384b05dc8437280e2786f8de390a27e581b4fb2f33
5
5
  SHA512:
6
- metadata.gz: a0bd0d10e698c1f80c2cd6d501bb5d5be865f9b4e53de224be4fb4b7b84a0cf1d6ee22428c1c01c5f0904a867923f294bc2beba21701d729d294e39e4323d504
7
- data.tar.gz: df8c064144e6194743d3a9bf86f78d958c748d3e6ce8bbdeb792768a3852ce1ba15b3bababb2ce948b7cc8e12be91563b8cfd1e6dfcc5c4b5302bfc0c15777e9
6
+ metadata.gz: 1b5ce7e1160532c9b3e6b1f49cd82ba5f0a70d58d6049f8c17023181faeabbe095167e2b0c87886a25b32ac9f18dce7b3fccca3af66d3f42e66dde0ea6cedd74
7
+ data.tar.gz: 154cc1881f5d539f1ba00c80ed57592f2a19889d28e89bf98311ba89015df6e2435bd9fed6c3dc3aef9483ad06c3a2f059b6c20f7939a872f290201e633565e9
data/README.md CHANGED
@@ -9,26 +9,25 @@
9
9
 
10
10
  ## Quick Start
11
11
 
12
- ```ruby
13
- gem "qi", "~> 12.0"
14
- ```
15
-
16
12
  ```ruby
17
13
  require "qi"
18
14
 
19
- # Create an empty 8×8 board with player styles
20
- pos = Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
15
+ # Create an empty 8×8 board "C" and "c" are style identifiers
16
+ # (here: Chess uppercase vs Chess lowercase)
17
+ pos = Qi.new([8, 8], first_player_style: "C", second_player_style: "c")
21
18
 
22
- # Place pieces and switch turn
19
+ # Place some pieces using flat indices (row-major order)
23
20
  pos2 = pos
24
- .board_diff(0 => "r", 1 => "n", 2 => "b", 3 => "q", 4 => "k")
25
- .board_diff(60 => "K", 59 => "Q", 58 => "B", 57 => "N", 56 => "R")
26
- .toggle
21
+ .board_diff(4 => "K", 60 => "k") # kings on their starting squares
22
+ .board_diff(0 => "R", 63 => "r") # rooks in the corners
23
+ .toggle # switch turn to second player
27
24
 
28
25
  pos2.turn #=> :second
26
+ pos2.board[4] #=> "K"
27
+ pos2.board[60] #=> "k"
29
28
  ```
30
29
 
31
- Every transformation returns a **new frozen instance**. The original is never modified.
30
+ Every transformation returns a **new instance**. The original is never modified.
32
31
 
33
32
  ## Overview
34
33
 
@@ -36,18 +35,17 @@ Every transformation returns a **new frozen instance**. The original is never mo
36
35
 
37
36
  | Component | Accessors | Description |
38
37
  |-----------|-----------|-------------|
39
- | Board | `board` | Multi-dimensional grid (1D, 2D, or 3D) of squares |
38
+ | Board | `board` | Flat array of squares, indexed in row-major order |
40
39
  | Hands | `first_player_hand`, `second_player_hand` | Off-board pieces held by each player |
41
40
  | Styles | `first_player_style`, `second_player_style` | One style String per player side |
42
41
  | Turn | `turn` | The active player (`:first` or `:second`) |
43
42
 
44
43
  **Pieces and styles are Strings.** Every piece — whether on the board or in a hand — and every style value is stored as a `String`. This aligns naturally with the notation formats in the Sashité ecosystem ([FEEN](https://sashite.dev/specs/feen/1.0.0/), [EPIN](https://sashite.dev/specs/epin/1.0.0/), [PON](https://sashite.dev/specs/pon/1.0.0/), [SIN](https://sashite.dev/specs/sin/1.0.0/)), which all produce string representations. Empty squares are represented by `nil`.
45
44
 
46
- **String normalization.** All inputs (pieces and styles) are normalized to `String` via interpolation (`"#{value}"`). This guarantees internal consistency without relying on type checks, and accepts any value that responds to `to_s`.
45
+ **Strings required.** Pieces and styles must be strings (`String`). Non-string values are rejected with an `ArgumentError`. This avoids per-operation coercion overhead on the hot path.
47
46
 
48
47
  ```ruby
49
48
  pos.board_diff(0 => "K") # String — stored as "K"
50
- pos.board_diff(0 => :K) # Symbol — normalized to "K"
51
49
  pos.board_diff(0 => "C:K") # Namespaced — stored as "C:K"
52
50
  pos.board_diff(0 => "+P") # Promoted — stored as "+P"
53
51
  ```
@@ -56,7 +54,7 @@ pos.board_diff(0 => "+P") # Promoted — stored as "+P"
56
54
 
57
55
  ```ruby
58
56
  # In your Gemfile
59
- gem "qi", "~> 12.0"
57
+ gem "qi", "~> 13.0"
60
58
  ```
61
59
 
62
60
  Or install manually:
@@ -73,22 +71,22 @@ gem install qi
73
71
 
74
72
  ### Construction
75
73
 
76
- #### `Qi.new(*shape, first_player_style:, second_player_style:)` → `Qi`
74
+ #### `Qi.new(shape, first_player_style:, second_player_style:)` → `Qi`
77
75
 
78
- Creates an immutable position with an empty board.
76
+ Creates a position with an empty board.
79
77
 
80
78
  **Parameters:**
81
79
 
82
- - `*shape` — one to three `Integer` dimension sizes (each 1–255).
83
- - `first_player_style:` — style for the first player (any non-nil value, normalized to `String`).
84
- - `second_player_style:` — style for the second player (any non-nil value, normalized to `String`).
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).
85
83
 
86
- The board starts with all squares empty (`nil`), both hands start empty, and the turn defaults to `:first`. Styles are normalized to `String` and frozen at construction.
84
+ The board starts with all squares empty (`nil`), both hands start empty, and the turn defaults to `:first`.
87
85
 
88
86
  ```ruby
89
- Qi.new(8, 8, first_player_style: "C", second_player_style: "c") # 2D (8×8)
90
- Qi.new(8, first_player_style: "G", second_player_style: "g") # 1D
91
- Qi.new(5, 5, 5, first_player_style: "R", second_player_style: "r") # 3D
87
+ Qi.new([8, 8], first_player_style: "C", second_player_style: "c") # 2D (8×8)
88
+ Qi.new([8], first_player_style: "G", second_player_style: "g") # 1D
89
+ Qi.new([5, 5, 5], first_player_style: "R", second_player_style: "r") # 3D
92
90
  ```
93
91
 
94
92
  **Raises** `ArgumentError` if shape constraints are violated or if a style is `nil` (see [Validation Errors](#validation-errors)).
@@ -102,47 +100,57 @@ Qi.new(5, 5, 5, first_player_style: "R", second_player_style: "r") # 3D
102
100
 
103
101
  ### Accessors
104
102
 
105
- All accessors are safe to call from any thread. Collections return independent copies; styles and turn return immutable values.
106
-
107
- | Method | Returns | Copy behavior |
108
- |--------|---------|---------------|
109
- | `board` | `Array` | Nested array (independent copy). Each leaf is `nil` or `String`. |
110
- | `first_player_hand` | `Array<String>` | Independent copy. |
111
- | `second_player_hand` | `Array<String>` | Independent copy. |
112
- | `turn` | `Symbol` | `:first` or `:second` (immutable). |
113
- | `first_player_style` | `String` | Frozen value (returned directly, no allocation). |
114
- | `second_player_style` | `String` | Frozen value (returned directly, no allocation). |
115
- | `shape` | `Array<Integer>` | Independent copy (e.g., `[8, 8]`). |
103
+ All accessors return internal state directly no copies, no allocations.
104
+
105
+ | Method | Returns | Description |
106
+ |--------|---------|-------------|
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
+ | `turn` | `Symbol` | `:first` or `:second`. |
111
+ | `first_player_style` | `String` | First player's style. |
112
+ | `second_player_style` | `String` | Second player's style. |
113
+ | `shape` | `Array<Integer>` | Board dimensions (e.g., `[8, 8]`). |
116
114
  | `inspect` | `String` | Developer-friendly, unstable format. Do not parse. |
117
115
 
118
116
  ```ruby
119
- pos.board #=> [["r", "n", "b", ...], ...]
120
- pos.first_player_hand #=> []
121
- pos.second_player_hand #=> []
117
+ pos.board #=> [nil, "r", "n", "b", "q", "k", nil, nil, ...]
118
+ pos.first_player_hand #=> {}
119
+ pos.second_player_hand #=> {}
122
120
  pos.turn #=> :first
123
121
  pos.first_player_style #=> "C"
124
122
  pos.second_player_style #=> "c"
125
123
  pos.shape #=> [8, 8]
126
- pos.inspect #=> "#<Qi shape=[8, 8] turn=:first>"
127
124
  ```
128
125
 
129
- **Note:** Styles are frozen Strings. Attempting to mutate a returned style raises `FrozenError`. If you need a mutable copy, call `.dup` on the returned value.
126
+ **Board as nested array.** Use `to_nested` to convert the flat board into a nested array matching the shape. This is an O(n) operation intended for display or serialization, not for the hot path.
127
+
128
+ ```ruby
129
+ pos.to_nested #=> [["r", "n", "b", ...], ...]
130
+ ```
131
+
132
+ **Direct square access.** Read individual squares from the flat board by index — no intermediate structure needed:
133
+
134
+ ```ruby
135
+ pos.board[0] #=> "r"
136
+ pos.board[63] #=> "R"
137
+ ```
130
138
 
131
139
  ### Transformations
132
140
 
133
- All transformation methods return a **new frozen `Qi` instance**. The original is never modified.
141
+ All transformation methods return a **new `Qi` instance**. The original is never modified.
134
142
 
135
143
  #### `board_diff(**squares)` → `Qi`
136
144
 
137
145
  Returns a new position with modified squares.
138
146
 
139
- Keys are flat indices (`Integer`, 0-based, row-major order). Values are pieces (normalized to `String`) or `nil` (empty square).
147
+ Keys are flat indices (`Integer`, 0-based, row-major order). Values are pieces (`String`) or `nil` (empty square).
140
148
 
141
149
  ```ruby
142
150
  pos2 = pos.board_diff(12 => nil, 28 => "P")
143
151
  ```
144
152
 
145
- **Raises** `ArgumentError` if an index is out of range or if the resulting total piece count exceeds the board size.
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.
146
154
 
147
155
  See [Flat Indexing](#flat-indexing) for computing flat indices from coordinates.
148
156
 
@@ -151,7 +159,7 @@ See [Flat Indexing](#flat-indexing) for computing flat indices from coordinates.
151
159
 
152
160
  Returns a new position with a modified hand.
153
161
 
154
- Keys are piece identifiers (normalized to `String`); values are integer deltas (positive to add, negative to remove, zero is a no-op). Removal uses **value equality** (`==`).
162
+ Keys are piece identifiers; values are integer deltas (positive to add, negative to remove, zero is a no-op).
155
163
 
156
164
  ```ruby
157
165
  pos2 = pos.first_player_hand_diff("P": 1) # Add one "P"
@@ -159,7 +167,9 @@ pos3 = pos.first_player_hand_diff("B": -1, "P": 1) # Remove one "B", add one "P
159
167
  pos4 = pos.second_player_hand_diff("p": 1) # Add one "p" to second hand
160
168
  ```
161
169
 
162
- **String normalization:** Ruby keyword arguments produce Symbol keys. The hand diff methods normalize these to Strings internally (`"P": 1` becomes key `:P`, stored as `"P"`). This is transparent in normal usage — the example above stores `"P"`, `"B"`, and `"p"` as Strings in the hand.
170
+ Internally, hands are stored as `{piece => count}` hashes. Adding and removing pieces is O(1) per entry.
171
+
172
+ **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.
163
173
 
164
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.
165
175
 
@@ -195,13 +205,13 @@ The Protocol does not prescribe how captures are modeled. In the example above,
195
205
 
196
206
  ### Shape and Dimensionality
197
207
 
198
- The nesting depth of the array returned by `board` matches the number of dimensions:
208
+ The `board` accessor always returns a flat array. Use `to_nested` when a nested structure is needed:
199
209
 
200
- | Dimensionality | Constructor | `board` returns |
201
- |----------------|-------------|-----------------|
202
- | 1D | `Qi.new(8, ...)` | `[square, square, ...]` |
203
- | 2D | `Qi.new(8, 8, ...)` | `[[square, ...], [square, ...], ...]` |
204
- | 3D | `Qi.new(5, 5, 5, ...)` | `[[[square, ...], ...], ...]` |
210
+ | Dimensionality | Constructor | `to_nested` returns |
211
+ |----------------|-------------|---------------------|
212
+ | 1D | `Qi.new([8], ...)` | `[square, square, ...]` |
213
+ | 2D | `Qi.new([8, 8], ...)` | `[[square, ...], [square, ...], ...]` |
214
+ | 3D | `Qi.new([5, 5, 5], ...)` | `[[[square, ...], ...], ...]` |
205
215
 
206
216
  Each `square` is either `nil` (empty) or a `String` (a piece).
207
217
 
@@ -209,7 +219,7 @@ For a shape `[D1, D2, ..., DN]`, the total number of squares is `D1 × D2 × ...
209
219
 
210
220
  ### Flat Indexing
211
221
 
212
- `board_diff` addresses squares by **flat index** — a single integer in **row-major order** (C order).
222
+ `board_diff` addresses squares by **flat index** — a single integer in **row-major order** (C order). Individual squares can also be read directly from the flat board via `board[index]`.
213
223
 
214
224
  **1D board** with shape `[F]`:
215
225
 
@@ -252,7 +262,7 @@ The total number of pieces across all locations (board squares + both hands) mus
252
262
  For a board with *n* squares and *p* total pieces: **0 ≤ p ≤ n**.
253
263
 
254
264
  ```ruby
255
- pos = Qi.new(2, first_player_style: "C", second_player_style: "c")
265
+ pos = Qi.new([2], first_player_style: "C", second_player_style: "c")
256
266
  .board_diff(0 => "a", 1 => "b") # 2 pieces on 2 squares: OK
257
267
 
258
268
  pos.first_player_hand_diff("c": 1)
@@ -266,7 +276,7 @@ pos.first_player_hand_diff("c": 1)
266
276
  Construction validates fields in a guaranteed order. When multiple errors exist, the **first** failing check determines the error message:
267
277
 
268
278
  1. **Shape** — dimension count, types, and bounds
269
- 2. **Styles** — nil checks (first, then second)
279
+ 2. **Styles** — nil checks (first, then second), then type checks
270
280
 
271
281
  This order is part of the public API contract.
272
282
 
@@ -281,29 +291,34 @@ This order is part of the public API contract.
281
291
  | `"dimension size N exceeds maximum of 255"` | Dimension size exceeds 255 |
282
292
  | `"first player style must not be nil"` | First style is `nil` |
283
293
  | `"second player style must not be nil"` | Second style is `nil` |
294
+ | `"first player style must be a String"` | First style is not a String |
295
+ | `"second player style must be a String"` | Second style is not a String |
284
296
 
285
297
  ### Transformation Errors
286
298
 
287
299
  | Error message | Method | Cause |
288
300
  |---------------|--------|-------|
289
301
  | `"invalid flat index: I (board has N squares)"` | `board_diff` | Index out of range or non-integer key |
302
+ | `"piece must be a String, got C"` | `board_diff` | Non-string piece value |
290
303
  | `"delta must be an Integer, got C for piece P"` | hand diffs | Non-integer delta |
291
304
  | `"cannot remove P: not found in hand"` | hand diffs | Removing more pieces than present |
292
305
  | `"too many pieces for board size (P pieces, N squares)"` | all | Total pieces would exceed board capacity |
293
306
 
294
307
  ## Design Principles
295
308
 
296
- **Immutable by construction.** Every `Qi` instance is frozen at creation. Transformation methods return new instances rather than mutating state. 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.
309
+ **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.
297
310
 
298
- **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.
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.
299
312
 
300
- **String normalization.** Pieces and styles are normalized to `String` via interpolation (`"#{value}"`). This aligns with the notation formats in the Sashité ecosystem (FEEN, EPIN, PON, SIN) which all produce string representations, and eliminates type conversions between layers. Rather than rejecting non-string inputs, the library coerces them guaranteeing internal consistency by construction rather than by validation.
313
+ **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.
301
314
 
302
315
  **Zero dependencies.** `Qi` relies only on the Ruby standard library. No transitive dependency tree to audit, no version conflicts to resolve.
303
316
 
304
- ## Thread Safety
317
+ ## Concurrency
318
+
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.
305
320
 
306
- `Qi` instances are frozen at construction. Accessors that return collections (`board`, hands, `shape`) produce independent copies on each call. Styles are frozen Strings returned directly. This makes positions inherently thread-safe: they can be shared freely across threads without synchronization.
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.
307
322
 
308
323
  ## Ecosystem
309
324
 
@@ -319,21 +334,17 @@ This section provides guidance for porting `Qi` to other languages.
319
334
 
320
335
  The complete public API consists of:
321
336
 
322
- - **1 constructor** — `Qi.new(*shape, first_player_style:, second_player_style:)`
337
+ - **1 constructor** — `Qi.new(shape, first_player_style:, second_player_style:)`
323
338
  - **7 accessors** — `board`, `first_player_hand`, `second_player_hand`, `turn`, `first_player_style`, `second_player_style`, `shape`
324
- - **4 transformations** — `board_diff`, `first_player_hand_diff`, `second_player_hand_diff`, `toggle`
339
+ - **5 methods** — `board_diff`, `first_player_hand_diff`, `second_player_hand_diff`, `toggle`, `to_nested`
325
340
  - **1 debug** — `inspect`
326
341
  - **2 constants** — `MAX_DIMENSIONS`, `MAX_DIMENSION_SIZE`
327
342
 
328
- ### API Naming
329
-
330
- The Ruby API separates **accessors** (read) from **transformations** (diff) using a `_diff` suffix. In languages where this convention is unusual, alternatives include `with_board(...)` / `with_first_hand(...)` for a fluent style, or `update_board(...)` / `update_hand(side, ...)` for a more imperative style.
331
-
332
343
  ### Key Semantic Contracts
333
344
 
334
- **Pieces and styles are strings.** Board squares, hand contents, and style values are all stored as strings. The Ruby implementation normalizes inputs via interpolation (`"#{value}"`), accepting any value that responds to `to_s`. Statically typed languages should accept string parameters directly.
345
+ **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.
335
346
 
336
- **Piece equality is by value**, not by identity. Hand removal uses value-based matching (Ruby's `==`). Use the equivalent in your language (`Eq` in Rust, `__eq__` in Python, `equals()` in Java).
347
+ **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).
337
348
 
338
349
  **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.
339
350
 
@@ -341,11 +352,11 @@ The Ruby API separates **accessors** (read) from **transformations** (diff) usin
341
352
 
342
353
  **Validation order is guaranteed**: shape → styles. Tests assert which error is reported when multiple inputs are invalid simultaneously.
343
354
 
344
- **Positions are immutable**: all transformation methods return a new instance. The original is never modified.
355
+ **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.
345
356
 
346
- **Accessors protect internal state**: mutable collections (`board`, hands, `shape`) return fresh copies. Styles are frozen Strings returned directly (zero-cost access). `turn` returns an immutable value.
357
+ **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.
347
358
 
348
- **The constructor creates an empty position**: board all null, hands empty, turn is first player. Pieces are added via `board_diff` and hand diff methods.
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.
349
360
 
350
361
  ### Hand Diff and String Normalization
351
362
 
data/lib/qi/board.rb CHANGED
@@ -1,129 +1,164 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Qi
4
- # Pure validation functions for multi-dimensional board structures.
4
+ # Pure functions for flat board operations.
5
5
  #
6
- # A board is represented as a nested Array where:
6
+ # A board is stored as a flat +Array+ in row-major order where each
7
+ # element is either +nil+ (empty square) or a +String+ (a piece).
8
+ # The board shape (dimensions) is maintained separately.
7
9
  #
8
- # - A *1D board* is a flat array of squares: +[nil, "K^", nil]+
9
- # - A *2D board* is an array of ranks: +[[nil, nil], ["K^", nil]]+
10
- # - A *3D board* is an array of layers, each an array of ranks.
10
+ # This module provides three categories of functions:
11
11
  #
12
- # Each leaf element (square) is either +nil+ (empty) or any non-nil
13
- # object (a piece). String normalization of pieces is the responsibility
14
- # of the +Qi+ class, not of this module.
12
+ # - *Shape validation* checks dimension count, types, and bounds.
13
+ # - *Diff application* applies index-to-piece changes to a flat board.
14
+ # - *Nested conversion* reconstructs a nested array from a flat board
15
+ # and its shape, for display or serialization.
15
16
  #
16
- # == Constraints
17
+ # All functions are stateless and side-effect-free.
17
18
  #
18
- # - Maximum dimensionality: 3
19
- # - Maximum size per dimension: 255
20
- # - At least one square (non-empty board)
21
- # - Rectangular structure: all sub-arrays at the same depth must have
22
- # identical length (enforced globally, not just per-sibling).
19
+ # @example Validate a shape
20
+ # Qi::Board.validate_shape([8, 8]) #=> 64
23
21
  #
24
- # @example Validate a 2D board
25
- # Qi::Board.validate([["a", nil], [nil, "b"]]) #=> [4, 2]
22
+ # @example Apply a diff
23
+ # board = Array.new(4)
24
+ # new_board, new_count = Qi::Board.apply_diff(board, 4, 0, { 0 => "K", 3 => "k" })
25
+ # new_board #=> ["K", nil, nil, "k"]
26
+ # new_count #=> 2
26
27
  #
27
- # @example Validate an empty board
28
- # Qi::Board.validate([[nil, nil, nil], [nil, nil, nil], [nil, nil, nil]]) #=> [9, 0]
28
+ # @example Convert to nested
29
+ # Qi::Board.to_nested(["a", nil, nil, "b"], [2, 2]) #=> [["a", nil], [nil, "b"]]
29
30
  module Board
30
- MAX_DIMENSIONS = 3
31
+ MAX_DIMENSIONS = 3
31
32
  MAX_DIMENSION_SIZE = 255
32
33
 
33
- # Validates a board and returns its square and piece counts.
34
+ # Validates board dimensions and returns the total square count.
34
35
  #
35
- # Validation is performed in a single recursive pass that simultaneously
36
- # infers the board shape, verifies structural regularity, checks dimension
37
- # limits, and counts squares and pieces.
36
+ # @param shape [Array<Integer>] dimension sizes (1 to 3 integers, each 1–255).
37
+ # @return [Integer] the total number of squares.
38
+ # @raise [ArgumentError] if the shape is invalid.
38
39
  #
39
- # @param board [Object] the board structure to validate.
40
- # @return [Array(Integer, Integer)] +[square_count, piece_count]+.
41
- # @raise [ArgumentError] if the board is structurally invalid.
42
- #
43
- # @example A 2D board
44
- # Qi::Board.validate([["r", nil, nil], [nil, nil, "R"]]) #=> [6, 2]
45
- #
46
- # @example A 1D board
47
- # Qi::Board.validate(["k", nil, nil, "K"]) #=> [4, 2]
48
- #
49
- # @example A 3D board (2 layers × 2 ranks × 2 files)
50
- # Qi::Board.validate([[["a", nil], [nil, "b"]], [[nil, "c"], ["d", nil]]]) #=> [8, 4]
51
- #
52
- # @example Non-rectangular boards are rejected
53
- # Qi::Board.validate([["a", "b"], ["c"]])
54
- # # => ArgumentError: non-rectangular board: expected 2 elements, got 1
55
- def self.validate(board)
56
- unless board.is_a?(::Array)
57
- raise ::ArgumentError, "board must be an Array"
40
+ # @example
41
+ # Qi::Board.validate_shape([8, 8]) #=> 64
42
+ # Qi::Board.validate_shape([9, 9]) #=> 81
43
+ # Qi::Board.validate_shape([5, 5, 5]) #=> 125
44
+ def self.validate_shape(shape)
45
+ if shape.empty?
46
+ raise ::ArgumentError, "at least one dimension is required"
58
47
  end
59
48
 
60
- if board.empty?
61
- raise ::ArgumentError, "board must not be empty"
49
+ if shape.size > MAX_DIMENSIONS
50
+ raise ::ArgumentError, "board exceeds #{MAX_DIMENSIONS} dimensions (got #{shape.size})"
62
51
  end
63
52
 
64
- validate_recursive(board, nil, 0)
65
- end
66
-
67
- # Single-pass recursive validation.
68
- #
69
- # At each level, determines whether this is a leaf rank (contains
70
- # non-Array elements) or an intermediate dimension (contains Arrays).
71
- # Infers expected sizes from the first element at each level,
72
- # validates all siblings match, and enforces dimension limits.
73
- #
74
- # @param node [Array] the current sub-array being validated.
75
- # @param expected_size [Integer, nil] expected length (nil if first sibling).
76
- # @param depth [Integer] current nesting depth (0-based).
77
- # @return [Array(Integer, Integer)] +[square_count, piece_count]+.
78
- def self.validate_recursive(node, expected_size, depth)
79
- if expected_size && node.size != expected_size
80
- raise ::ArgumentError, "non-rectangular board: expected #{expected_size} elements, got #{node.size}"
81
- end
53
+ shape.each do |dim|
54
+ unless dim.is_a?(::Integer)
55
+ raise ::ArgumentError, "dimension size must be an Integer, got #{dim.class}"
56
+ end
82
57
 
83
- if node.size > MAX_DIMENSION_SIZE
84
- raise ::ArgumentError, "dimension size #{node.size} exceeds maximum of #{MAX_DIMENSION_SIZE}"
85
- end
58
+ if dim < 1
59
+ raise ::ArgumentError, "dimension size must be at least 1, got #{dim}"
60
+ end
86
61
 
87
- if node.first.is_a?(::Array)
88
- # Intermediate dimension: validate depth, then recurse.
89
- if depth >= MAX_DIMENSIONS - 1
90
- raise ::ArgumentError, "board exceeds #{MAX_DIMENSIONS} dimensions"
62
+ if dim > MAX_DIMENSION_SIZE
63
+ raise ::ArgumentError, "dimension size #{dim} exceeds maximum of #{MAX_DIMENSION_SIZE}"
91
64
  end
65
+ end
92
66
 
93
- inner_size = node.first.size
67
+ shape.reduce(:*)
68
+ end
94
69
 
95
- if inner_size == 0
96
- raise ::ArgumentError, "board must not be empty"
70
+ # Applies changes to a flat board, returning a new array and updated piece count.
71
+ #
72
+ # Each change maps a flat index to a piece (+String+) or +nil+ (empty).
73
+ # The original board is not modified.
74
+ #
75
+ # @param board [Array] the current flat board.
76
+ # @param square_count [Integer] number of squares on the board.
77
+ # @param board_piece_count [Integer] current number of pieces on the board.
78
+ # @param changes [Hash{Integer => String, nil}] flat index to piece mapping.
79
+ # @return [Array(Array, Integer)] +[new_board, new_board_piece_count]+.
80
+ # @raise [ArgumentError] if an index is invalid or a piece is not a String.
81
+ #
82
+ # @example Place two pieces
83
+ # Qi::Board.apply_diff(Array.new(4), 4, 0, { 0 => "K", 3 => "k" })
84
+ # #=> [["K", nil, nil, "k"], 2]
85
+ #
86
+ # @example Move a piece
87
+ # Qi::Board.apply_diff(["K", nil, nil, nil], 4, 1, { 0 => nil, 2 => "K" })
88
+ # #=> [[nil, nil, "K", nil], 1]
89
+ def self.apply_diff(board, square_count, board_piece_count, changes)
90
+ new_board = board.dup
91
+ delta = 0
92
+
93
+ changes.each do |index, piece|
94
+ unless index.is_a?(::Integer) && index >= 0 && index < square_count
95
+ raise ::ArgumentError, "invalid flat index: #{index} (board has #{square_count} squares)"
97
96
  end
98
97
 
99
- total_squares = 0
100
- total_pieces = 0
98
+ unless piece.nil? || piece.is_a?(::String)
99
+ raise ::ArgumentError, "piece must be a String, got #{piece.class}"
100
+ end
101
101
 
102
- node.each do |sub|
103
- unless sub.is_a?(::Array)
104
- raise ::ArgumentError, "inconsistent board structure: mixed arrays and non-arrays at same level"
105
- end
102
+ old = new_board[index]
103
+ delta += (piece.nil? ? 0 : 1) - (old.nil? ? 0 : 1)
104
+ new_board[index] = piece
105
+ end
106
106
 
107
- sq, pc = validate_recursive(sub, inner_size, depth + 1)
108
- total_squares += sq
109
- total_pieces += pc
110
- end
107
+ [new_board, board_piece_count + delta]
108
+ end
111
109
 
112
- [total_squares, total_pieces]
113
- else
114
- # Leaf rank: validate structure, then count pieces.
115
- node.each do |square|
116
- if square.is_a?(::Array)
117
- raise ::ArgumentError, "inconsistent board structure: expected flat squares at this level"
118
- end
119
- end
110
+ # Converts a flat board into a nested array matching the given shape.
111
+ #
112
+ # This is an O(n) operation intended for display or serialization,
113
+ # not for the hot path.
114
+ #
115
+ # @param board [Array] the flat board.
116
+ # @param shape [Array<Integer>] the board dimensions.
117
+ # @return [Array] a nested array. For a 1D shape, returns a flat array copy.
118
+ #
119
+ # @example 1D board
120
+ # Qi::Board.to_nested(["a", nil, "b"], [3])
121
+ # #=> ["a", nil, "b"]
122
+ #
123
+ # @example 2D board
124
+ # Qi::Board.to_nested(["a", nil, nil, "b"], [2, 2])
125
+ # #=> [["a", nil], [nil, "b"]]
126
+ #
127
+ # @example 3D board (2×2×2)
128
+ # flat = ["a", nil, nil, "b", nil, "c", "d", nil]
129
+ # Qi::Board.to_nested(flat, [2, 2, 2])
130
+ # #=> [[["a", nil], [nil, "b"]], [[nil, "c"], ["d", nil]]]
131
+ def self.to_nested(board, shape)
132
+ return board.dup if shape.size == 1
133
+
134
+ nest(board, compute_chunk_sizes(shape), 0)
135
+ end
120
136
 
121
- [node.size, node.count { |sq| !sq.nil? }]
137
+ # Pre-computes chunk sizes for each dimension level.
138
+ #
139
+ # For shape [8, 8], returns [8, 1].
140
+ # For shape [5, 5, 5], returns [25, 5, 1].
141
+ # For shape [8], returns [1].
142
+ def self.compute_chunk_sizes(shape)
143
+ sizes = ::Array.new(shape.size)
144
+ sizes[-1] = 1
145
+ (shape.size - 2).downto(0) do |i|
146
+ sizes[i] = sizes[i + 1] * shape[i + 1]
122
147
  end
148
+ sizes
123
149
  end
124
150
 
125
- private_class_method :validate_recursive
151
+ # Recursively slices a flat array into nested sub-arrays.
152
+ def self.nest(flat, chunk_sizes, dim)
153
+ if dim == chunk_sizes.size - 1
154
+ return flat.dup
155
+ end
156
+
157
+ chunk = chunk_sizes[dim]
158
+ flat.each_slice(chunk).map { |slice| nest(slice, chunk_sizes, dim + 1) }
159
+ end
126
160
 
127
- freeze
161
+ private_class_method :compute_chunk_sizes,
162
+ :nest
128
163
  end
129
164
  end