qi 11.0.0 → 12.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 +294 -120
- data/lib/qi/board.rb +63 -109
- data/lib/qi/hands.rb +15 -19
- data/lib/qi/styles.rb +7 -4
- data/lib/qi.rb +385 -37
- metadata +1 -2
- data/lib/qi/position.rb +0 -122
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c916e1be3a5a9e6c40fe3fad735488b69a4663607d55f80989a8daf023fd84b5
|
|
4
|
+
data.tar.gz: d4ba55868fa1dbfce8cf9d46dde4ed3be9d839cf59320c7f9f90ee9973c60393
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a0bd0d10e698c1f80c2cd6d501bb5d5be865f9b4e53de224be4fb4b7b84a0cf1d6ee22428c1c01c5f0904a867923f294bc2beba21701d729d294e39e4323d504
|
|
7
|
+
data.tar.gz: df8c064144e6194743d3a9bf86f78d958c748d3e6ce8bbdeb792768a3852ce1ba15b3bababb2ce948b7cc8e12be91563b8cfd1e6dfcc5c4b5302bfc0c15777e9
|
data/README.md
CHANGED
|
@@ -1,41 +1,62 @@
|
|
|
1
|
-
#
|
|
1
|
+
# qi.rb
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/qi)
|
|
4
4
|
[](https://rubydoc.info/gems/qi)
|
|
5
5
|
[](https://github.com/sashite/qi.rb/actions)
|
|
6
|
-
[](https://github.com/sashite/qi.rb/blob/main/LICENSE)
|
|
7
7
|
|
|
8
|
-
>
|
|
8
|
+
> An immutable, format-agnostic position model for two-player board games.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Quick Start
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
```ruby
|
|
13
|
+
gem "qi", "~> 12.0"
|
|
14
|
+
```
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
```ruby
|
|
17
|
+
require "qi"
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
| `board` | nested `Array` (1D to 3D) | Board structure and occupancy |
|
|
19
|
-
| `hands` | `Hash` with `:first` and `:second` keys | Off-board pieces held by each player |
|
|
20
|
-
| `styles` | `Hash` with `:first` and `:second` keys | Player style for each side |
|
|
21
|
-
| `turn` | `:first` or `:second` | The active player's side |
|
|
19
|
+
# Create an empty 8×8 board with player styles
|
|
20
|
+
pos = Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
# Place pieces and switch turn
|
|
23
|
+
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
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
pos2.turn #=> :second
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every transformation returns a **new frozen instance**. The original is never modified.
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
|
32
|
-
|
|
33
|
+
## Overview
|
|
34
|
+
|
|
35
|
+
`Qi` models a board game position as defined by the [Sashité Game Protocol](https://sashite.dev/game-protocol/). A position encodes exactly four things:
|
|
36
|
+
|
|
37
|
+
| Component | Accessors | Description |
|
|
38
|
+
|-----------|-----------|-------------|
|
|
39
|
+
| Board | `board` | Multi-dimensional grid (1D, 2D, or 3D) of squares |
|
|
40
|
+
| Hands | `first_player_hand`, `second_player_hand` | Off-board pieces held by each player |
|
|
41
|
+
| Styles | `first_player_style`, `second_player_style` | One style String per player side |
|
|
42
|
+
| Turn | `turn` | The active player (`:first` or `:second`) |
|
|
43
|
+
|
|
44
|
+
**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
|
+
|
|
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`.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
pos.board_diff(0 => "K") # String — stored as "K"
|
|
50
|
+
pos.board_diff(0 => :K) # Symbol — normalized to "K"
|
|
51
|
+
pos.board_diff(0 => "C:K") # Namespaced — stored as "C:K"
|
|
52
|
+
pos.board_diff(0 => "+P") # Promoted — stored as "+P"
|
|
53
|
+
```
|
|
33
54
|
|
|
34
55
|
## Installation
|
|
35
56
|
|
|
36
57
|
```ruby
|
|
37
58
|
# In your Gemfile
|
|
38
|
-
gem "qi", "~>
|
|
59
|
+
gem "qi", "~> 12.0"
|
|
39
60
|
```
|
|
40
61
|
|
|
41
62
|
Or install manually:
|
|
@@ -44,144 +65,297 @@ Or install manually:
|
|
|
44
65
|
gem install qi
|
|
45
66
|
```
|
|
46
67
|
|
|
47
|
-
|
|
68
|
+
### Requirements
|
|
69
|
+
|
|
70
|
+
`Qi` requires **Ruby 3.2+** (tested against 3.2, 3.3, 3.4, and 4.0) and has **zero runtime dependencies**.
|
|
71
|
+
|
|
72
|
+
## API Reference
|
|
73
|
+
|
|
74
|
+
### Construction
|
|
75
|
+
|
|
76
|
+
#### `Qi.new(*shape, first_player_style:, second_player_style:)` → `Qi`
|
|
77
|
+
|
|
78
|
+
Creates an immutable position with an empty board.
|
|
48
79
|
|
|
49
|
-
|
|
80
|
+
**Parameters:**
|
|
50
81
|
|
|
51
|
-
|
|
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`).
|
|
52
85
|
|
|
53
|
-
|
|
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.
|
|
54
87
|
|
|
55
88
|
```ruby
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Raises** `ArgumentError` if shape constraints are violated or if a style is `nil` (see [Validation Errors](#validation-errors)).
|
|
95
|
+
|
|
96
|
+
### Constants
|
|
97
|
+
|
|
98
|
+
| Constant | Value | Description |
|
|
99
|
+
|----------|-------|-------------|
|
|
100
|
+
| `Qi::MAX_DIMENSIONS` | `3` | Maximum number of board dimensions |
|
|
101
|
+
| `Qi::MAX_DIMENSION_SIZE` | `255` | Maximum size of any single dimension |
|
|
102
|
+
|
|
103
|
+
### Accessors
|
|
104
|
+
|
|
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]`). |
|
|
116
|
+
| `inspect` | `String` | Developer-friendly, unstable format. Do not parse. |
|
|
77
117
|
|
|
78
118
|
```ruby
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
119
|
+
pos.board #=> [["r", "n", "b", ...], ...]
|
|
120
|
+
pos.first_player_hand #=> []
|
|
121
|
+
pos.second_player_hand #=> []
|
|
122
|
+
pos.turn #=> :first
|
|
123
|
+
pos.first_player_style #=> "C"
|
|
124
|
+
pos.second_player_style #=> "c"
|
|
125
|
+
pos.shape #=> [8, 8]
|
|
126
|
+
pos.inspect #=> "#<Qi shape=[8, 8] turn=:first>"
|
|
83
127
|
```
|
|
84
128
|
|
|
85
|
-
|
|
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.
|
|
130
|
+
|
|
131
|
+
### Transformations
|
|
132
|
+
|
|
133
|
+
All transformation methods return a **new frozen `Qi` instance**. The original is never modified.
|
|
134
|
+
|
|
135
|
+
#### `board_diff(**squares)` → `Qi`
|
|
86
136
|
|
|
87
|
-
|
|
137
|
+
Returns a new position with modified squares.
|
|
88
138
|
|
|
89
|
-
`
|
|
139
|
+
Keys are flat indices (`Integer`, 0-based, row-major order). Values are pieces (normalized to `String`) or `nil` (empty square).
|
|
90
140
|
|
|
91
141
|
```ruby
|
|
92
|
-
|
|
93
|
-
Qi.new([], { first: [], second: [] }, { first: "C", second: "c" }, :first)
|
|
94
|
-
rescue ArgumentError => e
|
|
95
|
-
e.message #=> "board must not be empty"
|
|
96
|
-
end
|
|
142
|
+
pos2 = pos.board_diff(12 => nil, 28 => "P")
|
|
97
143
|
```
|
|
98
144
|
|
|
99
|
-
|
|
145
|
+
**Raises** `ArgumentError` if an index is out of range or if the resulting total piece count exceeds the board size.
|
|
146
|
+
|
|
147
|
+
See [Flat Indexing](#flat-indexing) for computing flat indices from coordinates.
|
|
148
|
+
|
|
149
|
+
#### `first_player_hand_diff(**pieces)` → `Qi`
|
|
150
|
+
#### `second_player_hand_diff(**pieces)` → `Qi`
|
|
151
|
+
|
|
152
|
+
Returns a new position with a modified hand.
|
|
100
153
|
|
|
101
|
-
|
|
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** (`==`).
|
|
102
155
|
|
|
103
156
|
```ruby
|
|
104
|
-
#
|
|
105
|
-
|
|
157
|
+
pos2 = pos.first_player_hand_diff("P": 1) # Add one "P"
|
|
158
|
+
pos3 = pos.first_player_hand_diff("B": -1, "P": 1) # Remove one "B", add one "P"
|
|
159
|
+
pos4 = pos.second_player_hand_diff("p": 1) # Add one "p" to second hand
|
|
160
|
+
```
|
|
161
|
+
|
|
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.
|
|
163
|
+
|
|
164
|
+
**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.
|
|
106
165
|
|
|
107
|
-
|
|
108
|
-
Qi.new([["K^", nil], [nil, "k^"]], { first: [], second: [] }, { first: "C", second: "c" }, :first)
|
|
166
|
+
#### `toggle` → `Qi`
|
|
109
167
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
:first
|
|
116
|
-
)
|
|
168
|
+
Returns a new position with the active player swapped. All other fields are preserved.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
pos.turn #=> :first
|
|
172
|
+
pos.toggle.turn #=> :second
|
|
117
173
|
```
|
|
118
174
|
|
|
119
|
-
|
|
175
|
+
#### Chaining
|
|
176
|
+
|
|
177
|
+
Transformations compose naturally. A typical move involves modifying the board, optionally updating a hand, and toggling the turn:
|
|
120
178
|
|
|
121
179
|
```ruby
|
|
122
|
-
#
|
|
123
|
-
|
|
180
|
+
# Simple move: slide a piece from index 12 to index 28
|
|
181
|
+
pos2 = pos
|
|
182
|
+
.board_diff(12 => nil, 28 => "P")
|
|
183
|
+
.toggle
|
|
184
|
+
|
|
185
|
+
# Capture: overwrite defender, add captured piece to hand, toggle
|
|
186
|
+
pos3 = pos
|
|
187
|
+
.board_diff(12 => nil, 28 => "P") # Attacker replaces defender
|
|
188
|
+
.first_player_hand_diff("p": 1) # Captured piece goes to hand
|
|
189
|
+
.toggle
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The Protocol does not prescribe how captures are modeled. In the example above, `board_diff(12 => nil, 28 => "P")` simultaneously vacates the source and overwrites the destination. The captured piece must be added to the hand separately — `board_diff` does not track what was previously on a square.
|
|
193
|
+
|
|
194
|
+
## Board Structure
|
|
195
|
+
|
|
196
|
+
### Shape and Dimensionality
|
|
197
|
+
|
|
198
|
+
The nesting depth of the array returned by `board` matches the number of dimensions:
|
|
199
|
+
|
|
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, ...], ...], ...]` |
|
|
124
205
|
|
|
125
|
-
|
|
126
|
-
Qi.new([[nil, nil], [nil, nil]], { first: [], second: [] }, { first: "C", second: "c" }, :first)
|
|
206
|
+
Each `square` is either `nil` (empty) or a `String` (a piece).
|
|
127
207
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
208
|
+
For a shape `[D1, D2, ..., DN]`, the total number of squares is `D1 × D2 × ... × DN`.
|
|
209
|
+
|
|
210
|
+
### Flat Indexing
|
|
211
|
+
|
|
212
|
+
`board_diff` addresses squares by **flat index** — a single integer in **row-major order** (C order).
|
|
213
|
+
|
|
214
|
+
**1D board** with shape `[F]`:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
flat_index = f
|
|
134
218
|
```
|
|
135
219
|
|
|
136
|
-
|
|
220
|
+
**2D board** with shape `[R, F]` (R ranks, F files):
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
flat_index = r × F + f
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
For example, on a 3×3 board (shape `[3, 3]`):
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
file
|
|
230
|
+
0 1 2
|
|
231
|
+
┌────┬────┬────┐
|
|
232
|
+
rank 0 │ 0 │ 1 │ 2 │
|
|
233
|
+
├────┼────┼────┤
|
|
234
|
+
rank 1 │ 3 │ 4 │ 5 │
|
|
235
|
+
├────┼────┼────┤
|
|
236
|
+
rank 2 │ 6 │ 7 │ 8 │
|
|
237
|
+
└────┴────┴────┘
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Square `(rank=1, file=2)` → flat index `1 × 3 + 2 = 5`.
|
|
241
|
+
|
|
242
|
+
**3D board** with shape `[L, R, F]` (L layers, R ranks, F files):
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
flat_index = l × R × F + r × F + f
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Piece Cardinality
|
|
249
|
+
|
|
250
|
+
The total number of pieces across all locations (board squares + both hands) must never exceed the number of squares on the board. This invariant is enforced on every transformation.
|
|
251
|
+
|
|
252
|
+
For a board with *n* squares and *p* total pieces: **0 ≤ p ≤ n**.
|
|
137
253
|
|
|
138
254
|
```ruby
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
:first
|
|
145
|
-
)
|
|
255
|
+
pos = Qi.new(2, first_player_style: "C", second_player_style: "c")
|
|
256
|
+
.board_diff(0 => "a", 1 => "b") # 2 pieces on 2 squares: OK
|
|
257
|
+
|
|
258
|
+
pos.first_player_hand_diff("c": 1)
|
|
259
|
+
# => ArgumentError: too many pieces for board size (3 pieces, 2 squares)
|
|
146
260
|
```
|
|
147
261
|
|
|
148
262
|
## Validation Errors
|
|
149
263
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
|
162
|
-
|
|
163
|
-
| `"
|
|
164
|
-
| `"
|
|
165
|
-
| `"
|
|
166
|
-
| `"
|
|
167
|
-
| `"
|
|
168
|
-
| `"
|
|
264
|
+
### Validation Order
|
|
265
|
+
|
|
266
|
+
Construction validates fields in a guaranteed order. When multiple errors exist, the **first** failing check determines the error message:
|
|
267
|
+
|
|
268
|
+
1. **Shape** — dimension count, types, and bounds
|
|
269
|
+
2. **Styles** — nil checks (first, then second)
|
|
270
|
+
|
|
271
|
+
This order is part of the public API contract.
|
|
272
|
+
|
|
273
|
+
### Construction Errors
|
|
274
|
+
|
|
275
|
+
| Error message | Cause |
|
|
276
|
+
|---------------|-------|
|
|
277
|
+
| `"at least one dimension is required"` | No dimension sizes provided |
|
|
278
|
+
| `"board exceeds 3 dimensions (got N)"` | More than 3 dimension sizes |
|
|
279
|
+
| `"dimension size must be an Integer, got C"` | Non-integer dimension size |
|
|
280
|
+
| `"dimension size must be at least 1, got N"` | Dimension size is zero or negative |
|
|
281
|
+
| `"dimension size N exceeds maximum of 255"` | Dimension size exceeds 255 |
|
|
282
|
+
| `"first player style must not be nil"` | First style is `nil` |
|
|
283
|
+
| `"second player style must not be nil"` | Second style is `nil` |
|
|
284
|
+
|
|
285
|
+
### Transformation Errors
|
|
286
|
+
|
|
287
|
+
| Error message | Method | Cause |
|
|
288
|
+
|---------------|--------|-------|
|
|
289
|
+
| `"invalid flat index: I (board has N squares)"` | `board_diff` | Index out of range or non-integer key |
|
|
290
|
+
| `"delta must be an Integer, got C for piece P"` | hand diffs | Non-integer delta |
|
|
291
|
+
| `"cannot remove P: not found in hand"` | hand diffs | Removing more pieces than present |
|
|
292
|
+
| `"too many pieces for board size (P pieces, N squares)"` | all | Total pieces would exceed board capacity |
|
|
169
293
|
|
|
170
294
|
## Design Principles
|
|
171
295
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
-
|
|
175
|
-
|
|
176
|
-
|
|
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.
|
|
297
|
+
|
|
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.
|
|
299
|
+
|
|
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.
|
|
301
|
+
|
|
302
|
+
**Zero dependencies.** `Qi` relies only on the Ruby standard library. No transitive dependency tree to audit, no version conflicts to resolve.
|
|
303
|
+
|
|
304
|
+
## Thread Safety
|
|
305
|
+
|
|
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.
|
|
307
|
+
|
|
308
|
+
## Ecosystem
|
|
309
|
+
|
|
310
|
+
`Qi` is the positional core of the [Sashité](https://sashite.dev/) ecosystem. It models *what a position is* (board, hands, styles, turn) without prescribing *how positions are serialized* or *what moves are legal*.
|
|
311
|
+
|
|
312
|
+
Other libraries in the ecosystem build on `Qi` to provide those capabilities: [FEEN](https://sashite.dev/specs/feen/1.0.0/) defines a canonical string encoding for positions, [PON](https://sashite.dev/specs/pon/1.0.0/) provides a JSON-based position format, [EPIN](https://sashite.dev/specs/epin/1.0.0/) specifies piece token syntax, and [SIN](https://sashite.dev/specs/sin/1.0.0/) specifies style token syntax. The [Game Protocol](https://sashite.dev/game-protocol/) describes the conceptual foundation that all these specifications share.
|
|
313
|
+
|
|
314
|
+
## Notes for Reimplementors
|
|
315
|
+
|
|
316
|
+
This section provides guidance for porting `Qi` to other languages.
|
|
317
|
+
|
|
318
|
+
### API Surface
|
|
319
|
+
|
|
320
|
+
The complete public API consists of:
|
|
321
|
+
|
|
322
|
+
- **1 constructor** — `Qi.new(*shape, first_player_style:, second_player_style:)`
|
|
323
|
+
- **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`
|
|
325
|
+
- **1 debug** — `inspect`
|
|
326
|
+
- **2 constants** — `MAX_DIMENSIONS`, `MAX_DIMENSION_SIZE`
|
|
327
|
+
|
|
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
|
+
### Key Semantic Contracts
|
|
333
|
+
|
|
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.
|
|
335
|
+
|
|
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).
|
|
337
|
+
|
|
338
|
+
**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
|
+
|
|
340
|
+
**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.
|
|
341
|
+
|
|
342
|
+
**Validation order is guaranteed**: shape → styles. Tests assert which error is reported when multiple inputs are invalid simultaneously.
|
|
343
|
+
|
|
344
|
+
**Positions are immutable**: all transformation methods return a new instance. The original is never modified.
|
|
345
|
+
|
|
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.
|
|
347
|
+
|
|
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.
|
|
349
|
+
|
|
350
|
+
### Hand Diff and String Normalization
|
|
351
|
+
|
|
352
|
+
In 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.
|
|
353
|
+
|
|
354
|
+
This is a Ruby-specific concern. In other languages, hand diff methods should accept string keys directly. The important contract is that the hand always contains strings, matching the board's piece type.
|
|
177
355
|
|
|
178
|
-
|
|
356
|
+
### Duplicate Key Policy
|
|
179
357
|
|
|
180
|
-
|
|
181
|
-
- [PON Specification](https://sashite.dev/specs/pon/1.0.0/) — JSON-based position format
|
|
182
|
-
- [FEEN Specification](https://sashite.dev/specs/feen/1.0.0/) — Canonical string-based position format
|
|
183
|
-
- [EPIN Specification](https://sashite.dev/specs/epin/1.0.0/) — Piece token format
|
|
184
|
-
- [SIN Specification](https://sashite.dev/specs/sin/1.0.0/) — Style token format
|
|
358
|
+
In Ruby, passing the same keyword argument twice keeps only the last value (`board_diff(0 => "a", 0 => "b")` is equivalent to `board_diff(0 => "b")`). Reimplementations should define their own policy: last-write-wins, first-write-wins, or rejection.
|
|
185
359
|
|
|
186
360
|
## License
|
|
187
361
|
|