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.
- checksums.yaml +4 -4
- data/README.md +81 -70
- data/lib/qi/board.rb +128 -93
- data/lib/qi/hands.rb +67 -55
- data/lib/qi/styles.rb +28 -56
- data/lib/qi.rb +114 -202
- 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: b37a16ba39200581ce95bbc044a21d9e94ef05f4adfdaa46ba43e638dc15a1ea
|
|
4
|
+
data.tar.gz: 3c0a40366819743667499b384b05dc8437280e2786f8de390a27e581b4fb2f33
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
20
|
-
|
|
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
|
|
19
|
+
# Place some pieces using flat indices (row-major order)
|
|
23
20
|
pos2 = pos
|
|
24
|
-
.board_diff(
|
|
25
|
-
.board_diff(
|
|
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
|
|
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` |
|
|
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
|
-
**
|
|
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", "~>
|
|
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(
|
|
74
|
+
#### `Qi.new(shape, first_player_style:, second_player_style:)` → `Qi`
|
|
77
75
|
|
|
78
|
-
Creates
|
|
76
|
+
Creates a position with an empty board.
|
|
79
77
|
|
|
80
78
|
**Parameters:**
|
|
81
79
|
|
|
82
|
-
-
|
|
83
|
-
- `first_player_style:` — style for the first player (
|
|
84
|
-
- `second_player_style:` — style for the second player (
|
|
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`.
|
|
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")
|
|
90
|
-
Qi.new(8, first_player_style: "G", second_player_style: "g")
|
|
91
|
-
Qi.new(5, 5, 5, first_player_style: "R", second_player_style: "r")
|
|
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
|
|
106
|
-
|
|
107
|
-
| Method | Returns |
|
|
108
|
-
|
|
109
|
-
| `board` | `Array` |
|
|
110
|
-
| `first_player_hand` | `
|
|
111
|
-
| `second_player_hand` | `
|
|
112
|
-
| `turn` | `Symbol` | `:first` or `:second
|
|
113
|
-
| `first_player_style` | `String` |
|
|
114
|
-
| `second_player_style` | `String` |
|
|
115
|
-
| `shape` | `Array<Integer>` |
|
|
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 #=> [
|
|
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
|
-
**
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
208
|
+
The `board` accessor always returns a flat array. Use `to_nested` when a nested structure is needed:
|
|
199
209
|
|
|
200
|
-
| Dimensionality | Constructor | `
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
##
|
|
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
|
-
|
|
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(
|
|
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
|
-
- **
|
|
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.
|
|
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
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
|
4
|
+
# Pure functions for flat board operations.
|
|
5
5
|
#
|
|
6
|
-
# A board is
|
|
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
|
-
#
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
#
|
|
17
|
+
# All functions are stateless and side-effect-free.
|
|
17
18
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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
|
|
25
|
-
#
|
|
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
|
|
28
|
-
# Qi::Board.
|
|
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
|
|
31
|
+
MAX_DIMENSIONS = 3
|
|
31
32
|
MAX_DIMENSION_SIZE = 255
|
|
32
33
|
|
|
33
|
-
# Validates
|
|
34
|
+
# Validates board dimensions and returns the total square count.
|
|
34
35
|
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
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
|
-
# @
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
61
|
-
raise ::ArgumentError, "board
|
|
49
|
+
if shape.size > MAX_DIMENSIONS
|
|
50
|
+
raise ::ArgumentError, "board exceeds #{MAX_DIMENSIONS} dimensions (got #{shape.size})"
|
|
62
51
|
end
|
|
63
52
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
58
|
+
if dim < 1
|
|
59
|
+
raise ::ArgumentError, "dimension size must be at least 1, got #{dim}"
|
|
60
|
+
end
|
|
86
61
|
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
67
|
+
shape.reduce(:*)
|
|
68
|
+
end
|
|
94
69
|
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
98
|
+
unless piece.nil? || piece.is_a?(::String)
|
|
99
|
+
raise ::ArgumentError, "piece must be a String, got #{piece.class}"
|
|
100
|
+
end
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
total_pieces += pc
|
|
110
|
-
end
|
|
107
|
+
[new_board, board_piece_count + delta]
|
|
108
|
+
end
|
|
111
109
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
+
private_class_method :compute_chunk_sizes,
|
|
162
|
+
:nest
|
|
128
163
|
end
|
|
129
164
|
end
|