qi 10.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 +321 -67
- data/lib/qi/board.rb +129 -0
- data/lib/qi/hands.rb +80 -0
- data/lib/qi/styles.rb +74 -0
- data/lib/qi.rb +370 -90
- metadata +10 -11
- data/LICENSE.md +0 -21
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,108 +1,362 @@
|
|
|
1
|
-
#
|
|
1
|
+
# qi.rb
|
|
2
2
|
|
|
3
|
-
[](https://github.com/sashite/qi.rb/raw/main/LICENSE.md)
|
|
3
|
+
[](https://rubygems.org/gems/qi)
|
|
4
|
+
[](https://rubydoc.info/gems/qi)
|
|
5
|
+
[](https://github.com/sashite/qi.rb/actions)
|
|
6
|
+
[](https://github.com/sashite/qi.rb/blob/main/LICENSE)
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
> An immutable, format-agnostic position model for two-player board games.
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
## Quick Start
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
```ruby
|
|
13
|
+
gem "qi", "~> 12.0"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
require "qi"
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
3. **State Manipulation:** Qi allows for manipulation and update of game states through the `commit` method, allowing transitions between game states.
|
|
18
|
-
4. **Equality Checks:** With the `eql?` method, Qi allows for comparisons between different game states, which can be useful for tracking game progress, detecting repeats, or even in creating AI for your games.
|
|
19
|
-
5. **Turn Management:** Qi keeps track of the sequence of turns allowing users to identify whose turn it is currently.
|
|
20
|
-
6. **Access to Game Data:** Qi provides methods to access the current arrangement of pieces on the board (`squares_hash`) and the pieces captured by each player (`captures_hash`), helping users understand the current status of the game. It also allows access to a list of captured pieces (`captures_array`).
|
|
21
|
-
7. **Customizability:** Qi is flexible and allows for customization as per your needs. The keys and values of the `captures_hash` and `squares_hash` can be any kind of object, as well as the items from `turns` and values from `state`.
|
|
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
|
-
|
|
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`.
|
|
28
47
|
|
|
29
48
|
```ruby
|
|
30
|
-
|
|
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"
|
|
31
53
|
```
|
|
32
54
|
|
|
33
|
-
|
|
55
|
+
## Installation
|
|
34
56
|
|
|
35
|
-
```
|
|
36
|
-
|
|
57
|
+
```ruby
|
|
58
|
+
# In your Gemfile
|
|
59
|
+
gem "qi", "~> 12.0"
|
|
37
60
|
```
|
|
38
61
|
|
|
39
|
-
Or install
|
|
62
|
+
Or install manually:
|
|
40
63
|
|
|
41
64
|
```sh
|
|
42
65
|
gem install qi
|
|
43
66
|
```
|
|
44
67
|
|
|
45
|
-
|
|
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**.
|
|
46
71
|
|
|
47
|
-
|
|
48
|
-
In the provided setup, the attacking side is in possession of a silver general (S), a promoted bishop (+B) positioned on square 43, and a promoted pawn (+P) on square 22.
|
|
72
|
+
## API Reference
|
|
49
73
|
|
|
50
|
-
|
|
74
|
+
### Construction
|
|
51
75
|
|
|
52
|
-
|
|
76
|
+
#### `Qi.new(*shape, first_player_style:, second_player_style:)` → `Qi`
|
|
77
|
+
|
|
78
|
+
Creates an immutable position with an empty board.
|
|
79
|
+
|
|
80
|
+
**Parameters:**
|
|
81
|
+
|
|
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`).
|
|
85
|
+
|
|
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.
|
|
53
87
|
|
|
54
88
|
```ruby
|
|
55
|
-
|
|
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
|
+
```
|
|
56
93
|
|
|
57
|
-
|
|
58
|
-
north_captures = %w[r r b g g g g s n n n n p p p p p p p p p p p p p p p p p]
|
|
59
|
-
south_captures = %w[S]
|
|
94
|
+
**Raises** `ArgumentError` if shape constraints are violated or if a style is `nil` (see [Validation Errors](#validation-errors)).
|
|
60
95
|
|
|
61
|
-
|
|
62
|
-
captures = Hash.new(0)
|
|
63
|
-
(north_captures + south_captures).each { |piece| captures[piece] += 1 }
|
|
96
|
+
### Constants
|
|
64
97
|
|
|
65
|
-
|
|
66
|
-
|
|
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 |
|
|
67
102
|
|
|
68
|
-
|
|
69
|
-
qi0 = Qi.new(captures, squares, [0, 1])
|
|
103
|
+
### Accessors
|
|
70
104
|
|
|
71
|
-
|
|
72
|
-
qi0.captures_array # => ["S", "b", "g", "g", "g", "g", "n", "n", "n", "n", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "r", "r", "s"]
|
|
73
|
-
qi0.captures_hash # => {"r"=>2, "b"=>1, "g"=>4, "s"=>1, "n"=>4, "p"=>17, "S"=>1}
|
|
74
|
-
qi0.squares_hash # => {3=>"s", 4=>"k", 5=>"s", 22=>"+P", 43=>"+B"}
|
|
75
|
-
qi0.state # => {}
|
|
76
|
-
qi0.turn # => 0
|
|
77
|
-
qi0.turns # => [0, 1]
|
|
78
|
-
qi0.eql?(Qi.new(captures, squares, [0, 1])) # => true
|
|
79
|
-
qi0.eql?(Qi.new(captures, squares, [1, 0])) # => false
|
|
105
|
+
All accessors are safe to call from any thread. Collections return independent copies; styles and turn return immutable values.
|
|
80
106
|
|
|
81
|
-
|
|
82
|
-
|
|
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. |
|
|
83
117
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
118
|
+
```ruby
|
|
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>"
|
|
92
127
|
```
|
|
93
128
|
|
|
94
|
-
|
|
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.
|
|
95
130
|
|
|
96
|
-
|
|
131
|
+
### Transformations
|
|
132
|
+
|
|
133
|
+
All transformation methods return a **new frozen `Qi` instance**. The original is never modified.
|
|
134
|
+
|
|
135
|
+
#### `board_diff(**squares)` → `Qi`
|
|
136
|
+
|
|
137
|
+
Returns a new position with modified squares.
|
|
138
|
+
|
|
139
|
+
Keys are flat indices (`Integer`, 0-based, row-major order). Values are pieces (normalized to `String`) or `nil` (empty square).
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
pos2 = pos.board_diff(12 => nil, 28 => "P")
|
|
143
|
+
```
|
|
97
144
|
|
|
98
|
-
|
|
145
|
+
**Raises** `ArgumentError` if an index is out of range or if the resulting total piece count exceeds the board size.
|
|
99
146
|
|
|
100
|
-
|
|
147
|
+
See [Flat Indexing](#flat-indexing) for computing flat indices from coordinates.
|
|
101
148
|
|
|
102
|
-
|
|
149
|
+
#### `first_player_hand_diff(**pieces)` → `Qi`
|
|
150
|
+
#### `second_player_hand_diff(**pieces)` → `Qi`
|
|
151
|
+
|
|
152
|
+
Returns a new position with a modified hand.
|
|
153
|
+
|
|
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** (`==`).
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
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.
|
|
165
|
+
|
|
166
|
+
#### `toggle` → `Qi`
|
|
167
|
+
|
|
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
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
#### Chaining
|
|
176
|
+
|
|
177
|
+
Transformations compose naturally. A typical move involves modifying the board, optionally updating a hand, and toggling the turn:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
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, ...], ...], ...]` |
|
|
205
|
+
|
|
206
|
+
Each `square` is either `nil` (empty) or a `String` (a piece).
|
|
207
|
+
|
|
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
|
|
218
|
+
```
|
|
103
219
|
|
|
104
|
-
|
|
220
|
+
**2D board** with shape `[R, F]` (R ranks, F files):
|
|
105
221
|
|
|
106
|
-
|
|
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**.
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
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)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Validation Errors
|
|
263
|
+
|
|
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 |
|
|
293
|
+
|
|
294
|
+
## Design Principles
|
|
295
|
+
|
|
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.
|
|
355
|
+
|
|
356
|
+
### Duplicate Key Policy
|
|
357
|
+
|
|
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.
|
|
359
|
+
|
|
360
|
+
## License
|
|
107
361
|
|
|
108
|
-
|
|
362
|
+
Available as open source under the [Apache License 2.0](https://opensource.org/licenses/Apache-2.0).
|
data/lib/qi/board.rb
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Qi
|
|
4
|
+
# Pure validation functions for multi-dimensional board structures.
|
|
5
|
+
#
|
|
6
|
+
# A board is represented as a nested Array where:
|
|
7
|
+
#
|
|
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.
|
|
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.
|
|
15
|
+
#
|
|
16
|
+
# == Constraints
|
|
17
|
+
#
|
|
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).
|
|
23
|
+
#
|
|
24
|
+
# @example Validate a 2D board
|
|
25
|
+
# Qi::Board.validate([["a", nil], [nil, "b"]]) #=> [4, 2]
|
|
26
|
+
#
|
|
27
|
+
# @example Validate an empty board
|
|
28
|
+
# Qi::Board.validate([[nil, nil, nil], [nil, nil, nil], [nil, nil, nil]]) #=> [9, 0]
|
|
29
|
+
module Board
|
|
30
|
+
MAX_DIMENSIONS = 3
|
|
31
|
+
MAX_DIMENSION_SIZE = 255
|
|
32
|
+
|
|
33
|
+
# Validates a board and returns its square and piece counts.
|
|
34
|
+
#
|
|
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.
|
|
38
|
+
#
|
|
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"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if board.empty?
|
|
61
|
+
raise ::ArgumentError, "board must not be empty"
|
|
62
|
+
end
|
|
63
|
+
|
|
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
|
|
82
|
+
|
|
83
|
+
if node.size > MAX_DIMENSION_SIZE
|
|
84
|
+
raise ::ArgumentError, "dimension size #{node.size} exceeds maximum of #{MAX_DIMENSION_SIZE}"
|
|
85
|
+
end
|
|
86
|
+
|
|
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"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
inner_size = node.first.size
|
|
94
|
+
|
|
95
|
+
if inner_size == 0
|
|
96
|
+
raise ::ArgumentError, "board must not be empty"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
total_squares = 0
|
|
100
|
+
total_pieces = 0
|
|
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
|
|
106
|
+
|
|
107
|
+
sq, pc = validate_recursive(sub, inner_size, depth + 1)
|
|
108
|
+
total_squares += sq
|
|
109
|
+
total_pieces += pc
|
|
110
|
+
end
|
|
111
|
+
|
|
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
|
|
120
|
+
|
|
121
|
+
[node.size, node.count { |sq| !sq.nil? }]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private_class_method :validate_recursive
|
|
126
|
+
|
|
127
|
+
freeze
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/qi/hands.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Qi
|
|
4
|
+
# Pure validation functions for player hands.
|
|
5
|
+
#
|
|
6
|
+
# Hands are represented as a Hash with exactly two keys:
|
|
7
|
+
#
|
|
8
|
+
# - +:first+ — array of pieces held by the first player.
|
|
9
|
+
# - +:second+ — array of pieces held by the second player.
|
|
10
|
+
#
|
|
11
|
+
# Each piece in a hand can be any non-nil object. String normalization
|
|
12
|
+
# of pieces is the responsibility of the +Qi+ class, not of this module.
|
|
13
|
+
# The ordering of pieces within a hand carries no semantic meaning.
|
|
14
|
+
#
|
|
15
|
+
# @example Validate hands with pieces
|
|
16
|
+
# Qi::Hands.validate({ first: ["+P", "+P"], second: ["b"] }) #=> 3
|
|
17
|
+
#
|
|
18
|
+
# @example Validate empty hands
|
|
19
|
+
# Qi::Hands.validate({ first: [], second: [] }) #=> 0
|
|
20
|
+
module Hands
|
|
21
|
+
# Validates hands structure and returns the total piece count.
|
|
22
|
+
#
|
|
23
|
+
# Validation checks shape (exactly two keys), type (both values are
|
|
24
|
+
# arrays), and rejects +nil+ elements in each hand.
|
|
25
|
+
#
|
|
26
|
+
# @param hands [Object] the hands structure to validate.
|
|
27
|
+
# @return [Integer] the total number of pieces across both hands.
|
|
28
|
+
# @raise [ArgumentError] if the hands structure is invalid.
|
|
29
|
+
#
|
|
30
|
+
# @example Valid hands
|
|
31
|
+
# Qi::Hands.validate({ first: ["P", "B"], second: ["p"] }) #=> 3
|
|
32
|
+
#
|
|
33
|
+
# @example Nil piece rejected
|
|
34
|
+
# Qi::Hands.validate({ first: [nil], second: [] })
|
|
35
|
+
# # => ArgumentError: hand pieces must not be nil
|
|
36
|
+
#
|
|
37
|
+
# @example Missing key
|
|
38
|
+
# Qi::Hands.validate({ first: [] })
|
|
39
|
+
# # => ArgumentError: hands must have exactly keys :first and :second
|
|
40
|
+
def self.validate(hands)
|
|
41
|
+
validate_shape(hands)
|
|
42
|
+
validate_arrays(hands)
|
|
43
|
+
validate_hand(hands[:first])
|
|
44
|
+
validate_hand(hands[:second])
|
|
45
|
+
hands[:first].size + hands[:second].size
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- Shape validation -----------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def self.validate_shape(hands)
|
|
51
|
+
unless hands.is_a?(::Hash)
|
|
52
|
+
raise ::ArgumentError, "hands must be a Hash with keys :first and :second"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
return if hands.size == 2 && hands.key?(:first) && hands.key?(:second)
|
|
56
|
+
|
|
57
|
+
raise ::ArgumentError, "hands must have exactly keys :first and :second"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.validate_arrays(hands)
|
|
61
|
+
return if hands[:first].is_a?(::Array) && hands[:second].is_a?(::Array)
|
|
62
|
+
|
|
63
|
+
raise ::ArgumentError, "each hand must be an Array"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# --- Piece validation -----------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def self.validate_hand(pieces)
|
|
69
|
+
pieces.each do |piece|
|
|
70
|
+
raise ::ArgumentError, "hand pieces must not be nil" if piece.nil?
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private_class_method :validate_shape,
|
|
75
|
+
:validate_arrays,
|
|
76
|
+
:validate_hand
|
|
77
|
+
|
|
78
|
+
freeze
|
|
79
|
+
end
|
|
80
|
+
end
|
data/lib/qi/styles.rb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Qi
|
|
4
|
+
# Pure validation functions for player styles.
|
|
5
|
+
#
|
|
6
|
+
# Styles are represented as a Hash with exactly two keys:
|
|
7
|
+
#
|
|
8
|
+
# - +:first+ — the style associated with the first player side.
|
|
9
|
+
# - +:second+ — the style associated with the second player side.
|
|
10
|
+
#
|
|
11
|
+
# Style values can be any non-nil object. String normalization is the
|
|
12
|
+
# responsibility of the +Qi+ class, not of this module. Semantic
|
|
13
|
+
# validation (e.g., SIN compliance) is the responsibility of the
|
|
14
|
+
# encoding layer (FEEN, PON, etc.).
|
|
15
|
+
#
|
|
16
|
+
# @example Validate string styles
|
|
17
|
+
# Qi::Styles.validate({ first: "C", second: "c" }) #=> nil
|
|
18
|
+
#
|
|
19
|
+
# @example Validate symbol styles
|
|
20
|
+
# Qi::Styles.validate({ first: :chess, second: :shogi }) #=> nil
|
|
21
|
+
module Styles
|
|
22
|
+
# Validates the styles structure.
|
|
23
|
+
#
|
|
24
|
+
# Returns +nil+ if the Hash has exactly keys +:first+ and +:second+ with
|
|
25
|
+
# non-nil values, or raises +ArgumentError+ otherwise.
|
|
26
|
+
#
|
|
27
|
+
# @param styles [Object] the styles structure to validate.
|
|
28
|
+
# @return [nil]
|
|
29
|
+
# @raise [ArgumentError] if the styles structure is invalid.
|
|
30
|
+
#
|
|
31
|
+
# @example Valid styles
|
|
32
|
+
# Qi::Styles.validate({ first: "S", second: "s" }) #=> nil
|
|
33
|
+
#
|
|
34
|
+
# @example Nil first style
|
|
35
|
+
# Qi::Styles.validate({ first: nil, second: "c" })
|
|
36
|
+
# # => ArgumentError: first player style must not be nil
|
|
37
|
+
#
|
|
38
|
+
# @example Nil second style
|
|
39
|
+
# Qi::Styles.validate({ first: "C", second: nil })
|
|
40
|
+
# # => ArgumentError: second player style must not be nil
|
|
41
|
+
#
|
|
42
|
+
# @example Missing key
|
|
43
|
+
# Qi::Styles.validate({ first: "C" })
|
|
44
|
+
# # => ArgumentError: styles must have exactly keys :first and :second
|
|
45
|
+
#
|
|
46
|
+
# @example Not a Hash
|
|
47
|
+
# Qi::Styles.validate("not a hash")
|
|
48
|
+
# # => ArgumentError: styles must be a Hash with keys :first and :second
|
|
49
|
+
def self.validate(styles)
|
|
50
|
+
validate_shape(styles)
|
|
51
|
+
validate_non_nil(styles)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.validate_shape(styles)
|
|
55
|
+
unless styles.is_a?(::Hash)
|
|
56
|
+
raise ::ArgumentError, "styles must be a Hash with keys :first and :second"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
return if styles.size == 2 && styles.key?(:first) && styles.key?(:second)
|
|
60
|
+
|
|
61
|
+
raise ::ArgumentError, "styles must have exactly keys :first and :second"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.validate_non_nil(styles)
|
|
65
|
+
raise ::ArgumentError, "first player style must not be nil" if styles[:first].nil?
|
|
66
|
+
raise ::ArgumentError, "second player style must not be nil" if styles[:second].nil?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private_class_method :validate_shape,
|
|
70
|
+
:validate_non_nil
|
|
71
|
+
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/qi.rb
CHANGED
|
@@ -1,118 +1,398 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
require_relative "qi/board"
|
|
4
|
+
require_relative "qi/hands"
|
|
5
|
+
require_relative "qi/styles"
|
|
6
|
+
|
|
7
|
+
# A minimal, format-agnostic library for representing positions in
|
|
8
|
+
# two-player, turn-based board games.
|
|
9
|
+
#
|
|
10
|
+
# Qi models the components of a position as defined by the
|
|
11
|
+
# Sashité Game Protocol:
|
|
12
|
+
#
|
|
13
|
+
# - *Board* — a multi-dimensional rectangular grid (1D, 2D, or 3D)
|
|
14
|
+
# where each square is either empty (+nil+) or occupied by a piece
|
|
15
|
+
# (+String+).
|
|
16
|
+
# - *Hands* — collections of off-board pieces (+String+) held by each player.
|
|
17
|
+
# - *Styles* — one style +String+ per player side.
|
|
18
|
+
# - *Turn* — which player is active (+:first+ or +:second+).
|
|
19
|
+
#
|
|
20
|
+
# Pieces and styles are normalized to +String+ via interpolation
|
|
21
|
+
# (<tt>"#{value}"</tt>), aligning naturally with the notation formats
|
|
22
|
+
# in the Sashité ecosystem (FEEN, EPIN, PON, SIN). Qi validates
|
|
23
|
+
# structural integrity, not game semantics.
|
|
24
|
+
#
|
|
25
|
+
# == Construction
|
|
26
|
+
#
|
|
27
|
+
# A position is constructed from the board shape and player styles.
|
|
28
|
+
# The board starts empty (all squares +nil+), both hands start empty,
|
|
29
|
+
# and the turn starts as +:first+.
|
|
30
|
+
#
|
|
31
|
+
# pos = Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
|
|
32
|
+
#
|
|
33
|
+
# == Accessors
|
|
34
|
+
#
|
|
35
|
+
# Use +board+, +first_player_hand+, +second_player_hand+, +turn+,
|
|
36
|
+
# +first_player_style+, +second_player_style+, and +shape+ to read
|
|
37
|
+
# field values. Mutable fields return defensive copies.
|
|
38
|
+
#
|
|
39
|
+
# == Transformations
|
|
40
|
+
#
|
|
41
|
+
# Use +board_diff+, +first_player_hand_diff+, +second_player_hand_diff+,
|
|
42
|
+
# and +toggle+ to derive new positions. Transformation methods return
|
|
43
|
+
# a new frozen +Qi+ instance and can be chained:
|
|
44
|
+
#
|
|
45
|
+
# pos2 = pos.board_diff(12 => nil, 28 => "C:P").first_player_hand_diff("c:p": 1).toggle
|
|
46
|
+
#
|
|
47
|
+
# @example A chess starting position
|
|
48
|
+
# pos = Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
|
|
49
|
+
# .board_diff(
|
|
50
|
+
# 0 => "r", 1 => "n", 2 => "b", 3 => "q", 4 => "k", 5 => "b", 6 => "n", 7 => "r",
|
|
51
|
+
# 8 => "p", 9 => "p", 10 => "p", 11 => "p", 12 => "p", 13 => "p", 14 => "p", 15 => "p",
|
|
52
|
+
# 48 => "P", 49 => "P", 50 => "P", 51 => "P", 52 => "P", 53 => "P", 54 => "P", 55 => "P",
|
|
53
|
+
# 56 => "R", 57 => "N", 58 => "B", 59 => "Q", 60 => "K", 61 => "B", 62 => "N", 63 => "R"
|
|
54
|
+
# )
|
|
55
|
+
# pos.turn #=> :first
|
|
56
|
+
# pos.first_player_hand #=> []
|
|
6
57
|
class Qi
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
# @!attribute [r] squares_hash
|
|
13
|
-
# @return [Hash<Object, Object>] A hash where the keys represent square
|
|
14
|
-
# identifiers and the values represent the piece that will occupy each square.
|
|
15
|
-
# Both the keys and values can be any type of Ruby object, such as integers, strings, symbols, etc.
|
|
16
|
-
# @example
|
|
17
|
-
# {A3: "s", E4: "k", B5: "s", C22: "+P", D43: "+B"}
|
|
18
|
-
|
|
19
|
-
# @!attribute [r] state
|
|
20
|
-
# @return [Hash<Symbol, Object>] a hash of game states
|
|
21
|
-
# @example
|
|
22
|
-
# {:in_check=>true}
|
|
23
|
-
|
|
24
|
-
# @!attribute [r] turns
|
|
25
|
-
# @return [Array<Object>] a rotation of turns
|
|
26
|
-
# @example
|
|
27
|
-
# ["Sente", "Gote"]
|
|
28
|
-
|
|
29
|
-
attr_reader :captures_hash, :squares_hash, :state, :turns
|
|
30
|
-
|
|
31
|
-
# @param captures_hash [Hash<Object, Integer>] a hash of captured pieces
|
|
32
|
-
# @param squares_hash [Hash<Object, Object>] A hash where the keys represent square
|
|
33
|
-
# identifiers and the values represent the piece that will occupy each square.
|
|
34
|
-
# Both the keys and values can be any type of Ruby object, such as integers, strings, symbols, etc.
|
|
35
|
-
# @param turns [Array<Object>] a rotation of turns
|
|
36
|
-
# @param state [Hash<Symbol, Object>] a hash of game states
|
|
58
|
+
MAX_DIMENSIONS = Board::MAX_DIMENSIONS
|
|
59
|
+
MAX_DIMENSION_SIZE = Board::MAX_DIMENSION_SIZE
|
|
60
|
+
|
|
61
|
+
# Creates a validated, immutable position with an empty board.
|
|
37
62
|
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
# The board starts with all squares empty (+nil+), both hands empty,
|
|
64
|
+
# and the turn set to +:first+. Styles are normalized to +String+
|
|
65
|
+
# and frozen.
|
|
66
|
+
#
|
|
67
|
+
# @param shape [Array<Integer>] dimension sizes (1 to 3 integers, each 1–255).
|
|
68
|
+
# @param first_player_style [#to_s] style for the first player (non-nil,
|
|
69
|
+
# normalized to +String+).
|
|
70
|
+
# @param second_player_style [#to_s] style for the second player (non-nil,
|
|
71
|
+
# normalized to +String+).
|
|
72
|
+
# @return [Qi] an immutable, validated position.
|
|
73
|
+
# @raise [ArgumentError] if any constraint is violated.
|
|
74
|
+
#
|
|
75
|
+
# @example 2D chess board
|
|
76
|
+
# Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
|
|
77
|
+
#
|
|
78
|
+
# @example 3D board
|
|
79
|
+
# Qi.new(5, 5, 5, first_player_style: "R", second_player_style: "r")
|
|
80
|
+
def initialize(*shape, first_player_style:, second_player_style:)
|
|
81
|
+
validate_shape(shape)
|
|
82
|
+
validate_not_nil(:first, first_player_style)
|
|
83
|
+
validate_not_nil(:second, second_player_style)
|
|
84
|
+
|
|
85
|
+
@shape = shape.freeze
|
|
86
|
+
@chunk_sizes = compute_chunk_sizes(shape).freeze
|
|
87
|
+
@square_count = shape.reduce(:*)
|
|
88
|
+
@board = ::Array.new(@square_count)
|
|
89
|
+
@first_hand = []
|
|
90
|
+
@second_hand = []
|
|
91
|
+
@first_player_style = "#{first_player_style}".freeze
|
|
92
|
+
@second_player_style = "#{second_player_style}".freeze
|
|
93
|
+
@turn = :first
|
|
94
|
+
@board_piece_count = 0
|
|
50
95
|
|
|
51
96
|
freeze
|
|
52
97
|
end
|
|
53
98
|
|
|
54
|
-
#
|
|
99
|
+
# --- Accessors ---------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
# Returns the board as a nested array matching the board shape.
|
|
102
|
+
#
|
|
103
|
+
# Each leaf element is +nil+ (empty square) or a +String+ (a piece).
|
|
104
|
+
# The returned structure is an independent copy.
|
|
105
|
+
#
|
|
106
|
+
# @return [Array] nested array (1D, 2D, or 3D depending on shape).
|
|
55
107
|
#
|
|
56
|
-
# @return [Array<Object>] an array of captures
|
|
57
108
|
# @example
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
109
|
+
# pos = Qi.new(2, 3, first_player_style: "C", second_player_style: "c")
|
|
110
|
+
# .board_diff(0 => "a", 5 => "b")
|
|
111
|
+
# pos.board #=> [["a", nil, nil], [nil, nil, "b"]]
|
|
112
|
+
def board
|
|
113
|
+
unflatten(@board, @chunk_sizes, 0)
|
|
61
114
|
end
|
|
62
115
|
|
|
63
|
-
#
|
|
116
|
+
# Returns the pieces held by the first player.
|
|
64
117
|
#
|
|
65
|
-
# @
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# identifiers and the values represent the piece that will occupy each square.
|
|
69
|
-
# Both the keys and values can be any type of Ruby object, such as integers, strings, symbols, etc.
|
|
70
|
-
# @param state [Hash<Symbol, Object>] a hash of new game states
|
|
71
|
-
# @return [Qi] a new Qi object representing the updated game state
|
|
72
|
-
# @example
|
|
73
|
-
# qi0.commit([], [], { D43: nil, B13: "+B" }, in_check: true)
|
|
74
|
-
def commit(add_captures_array, del_captures_array, edit_squares_hash, **state)
|
|
75
|
-
self.class.new(
|
|
76
|
-
edit_captures_hash(add_captures_array.compact, del_captures_array.compact, **captures_hash),
|
|
77
|
-
squares_hash.merge(edit_squares_hash),
|
|
78
|
-
turns.rotate,
|
|
79
|
-
**state
|
|
80
|
-
)
|
|
118
|
+
# @return [Array<String>] independent copy of the first player's hand.
|
|
119
|
+
def first_player_hand
|
|
120
|
+
@first_hand.dup
|
|
81
121
|
end
|
|
82
122
|
|
|
83
|
-
#
|
|
123
|
+
# Returns the pieces held by the second player.
|
|
84
124
|
#
|
|
85
|
-
# @
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return false unless other.captures_hash == captures_hash
|
|
89
|
-
return false unless other.squares_hash == squares_hash
|
|
90
|
-
return false unless other.turn == turn
|
|
91
|
-
return false unless other.state == state
|
|
92
|
-
|
|
93
|
-
true
|
|
125
|
+
# @return [Array<String>] independent copy of the second player's hand.
|
|
126
|
+
def second_player_hand
|
|
127
|
+
@second_hand.dup
|
|
94
128
|
end
|
|
95
|
-
alias == eql?
|
|
96
129
|
|
|
97
|
-
#
|
|
130
|
+
# Returns the active player's side.
|
|
98
131
|
#
|
|
99
|
-
# @return [
|
|
132
|
+
# @return [Symbol] +:first+ or +:second+.
|
|
100
133
|
def turn
|
|
101
|
-
|
|
134
|
+
@turn
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Returns the first player's style.
|
|
138
|
+
#
|
|
139
|
+
# @return [String] frozen style value.
|
|
140
|
+
def first_player_style
|
|
141
|
+
@first_player_style
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Returns the second player's style.
|
|
145
|
+
#
|
|
146
|
+
# @return [String] frozen style value.
|
|
147
|
+
def second_player_style
|
|
148
|
+
@second_player_style
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns the board dimensions.
|
|
152
|
+
#
|
|
153
|
+
# @return [Array<Integer>] independent copy of the shape (e.g., +[8, 8]+).
|
|
154
|
+
def shape
|
|
155
|
+
@shape.dup
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# --- Transformations ---------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
# Returns a new position with modified squares on the board.
|
|
161
|
+
#
|
|
162
|
+
# Accepts keyword arguments where each key is a flat index (Integer,
|
|
163
|
+
# 0-based, row-major order) and each value is a piece (normalized to
|
|
164
|
+
# +String+) or +nil+ (empty square).
|
|
165
|
+
#
|
|
166
|
+
# @param squares [Hash{Integer => #to_s, nil}] flat index to piece mapping.
|
|
167
|
+
# @return [Qi] a new immutable position with the updated board.
|
|
168
|
+
# @raise [ArgumentError] if a key is not a valid flat index.
|
|
169
|
+
# @raise [ArgumentError] if the resulting piece count exceeds the board size.
|
|
170
|
+
#
|
|
171
|
+
# @example Move a piece from index 12 to index 28
|
|
172
|
+
# pos2 = pos.board_diff(12 => nil, 28 => "C:P")
|
|
173
|
+
def board_diff(**squares)
|
|
174
|
+
new_board = @board.dup
|
|
175
|
+
delta = 0
|
|
176
|
+
|
|
177
|
+
squares.each do |flat_index, value|
|
|
178
|
+
unless flat_index.is_a?(::Integer) && flat_index >= 0 && flat_index < @square_count
|
|
179
|
+
raise ::ArgumentError, "invalid flat index: #{flat_index} (board has #{@square_count} squares)"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
value = "#{value}" unless value.nil?
|
|
183
|
+
|
|
184
|
+
old_value = new_board[flat_index]
|
|
185
|
+
# Track net change in piece count: +1 if filling, -1 if emptying, 0 if replacing.
|
|
186
|
+
delta += (value.nil? ? 0 : 1) - (old_value.nil? ? 0 : 1)
|
|
187
|
+
new_board[flat_index] = value
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
derive(new_board, @first_hand, @second_hand, @turn, @board_piece_count + delta)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Returns a new position with the first player's hand modified.
|
|
194
|
+
#
|
|
195
|
+
# Accepts keyword arguments where each key is a piece identifier
|
|
196
|
+
# (normalized to +String+) and each value is an integer delta:
|
|
197
|
+
# positive adds copies, negative removes matching pieces (by value
|
|
198
|
+
# equality). A delta of zero is a no-op.
|
|
199
|
+
#
|
|
200
|
+
# @param pieces [Hash{#to_s => Integer}] piece to delta mapping.
|
|
201
|
+
# @return [Qi] a new immutable position with the updated hand.
|
|
202
|
+
# @raise [ArgumentError] if a delta is not an Integer.
|
|
203
|
+
# @raise [ArgumentError] if removing more pieces than present.
|
|
204
|
+
# @raise [ArgumentError] if the resulting piece count exceeds the board size.
|
|
205
|
+
#
|
|
206
|
+
# @example Add a pawn and remove a bishop
|
|
207
|
+
# pos2 = pos.first_player_hand_diff("S:P": 1, "S:B": -1)
|
|
208
|
+
def first_player_hand_diff(**pieces)
|
|
209
|
+
new_hand = apply_hand_changes(@first_hand, pieces)
|
|
210
|
+
derive(@board, new_hand, @second_hand, @turn, @board_piece_count)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Returns a new position with the second player's hand modified.
|
|
214
|
+
#
|
|
215
|
+
# Accepts keyword arguments where each key is a piece identifier
|
|
216
|
+
# (normalized to +String+) and each value is an integer delta:
|
|
217
|
+
# positive adds copies, negative removes matching pieces (by value
|
|
218
|
+
# equality). A delta of zero is a no-op.
|
|
219
|
+
#
|
|
220
|
+
# @param pieces [Hash{#to_s => Integer}] piece to delta mapping.
|
|
221
|
+
# @return [Qi] a new immutable position with the updated hand.
|
|
222
|
+
# @raise [ArgumentError] if a delta is not an Integer.
|
|
223
|
+
# @raise [ArgumentError] if removing more pieces than present.
|
|
224
|
+
# @raise [ArgumentError] if the resulting piece count exceeds the board size.
|
|
225
|
+
#
|
|
226
|
+
# @example Add a captured pawn
|
|
227
|
+
# pos2 = pos.second_player_hand_diff("c:p": 1)
|
|
228
|
+
def second_player_hand_diff(**pieces)
|
|
229
|
+
new_hand = apply_hand_changes(@second_hand, pieces)
|
|
230
|
+
derive(@board, @first_hand, new_hand, @turn, @board_piece_count)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Returns a new position with the active player swapped.
|
|
234
|
+
#
|
|
235
|
+
# All other fields (board, hands, styles) are preserved unchanged.
|
|
236
|
+
#
|
|
237
|
+
# @return [Qi] a new immutable position with the opposite turn.
|
|
238
|
+
#
|
|
239
|
+
# @example
|
|
240
|
+
# pos = Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
|
|
241
|
+
# pos.turn #=> :first
|
|
242
|
+
# pos.toggle.turn #=> :second
|
|
243
|
+
def toggle
|
|
244
|
+
derive(@board, @first_hand, @second_hand, other_turn, @board_piece_count)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Returns a developer-friendly string representation.
|
|
248
|
+
#
|
|
249
|
+
# The format is not stable and should not be parsed.
|
|
250
|
+
#
|
|
251
|
+
# @return [String]
|
|
252
|
+
def inspect
|
|
253
|
+
"#<#{self.class} shape=#{@shape.inspect} turn=#{@turn.inspect}>"
|
|
102
254
|
end
|
|
103
255
|
|
|
104
256
|
private
|
|
105
257
|
|
|
106
|
-
|
|
258
|
+
def other_turn
|
|
259
|
+
@turn == :first ? :second : :first
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Fast-path constructor for derived positions.
|
|
263
|
+
#
|
|
264
|
+
# Skips shape and style validation since the source position already
|
|
265
|
+
# guarantees these invariants. Only checks cardinality.
|
|
266
|
+
#
|
|
267
|
+
# Unchanged fields are shared by reference — safe because both positions
|
|
268
|
+
# are frozen and accessors return defensive copies.
|
|
269
|
+
def derive(board, first_hand, second_hand, turn, board_piece_count)
|
|
270
|
+
hand_piece_count = first_hand.size + second_hand.size
|
|
271
|
+
validate_cardinality(@square_count, board_piece_count + hand_piece_count)
|
|
272
|
+
|
|
273
|
+
instance = self.class.allocate
|
|
274
|
+
instance.send(:init_derived, board, first_hand, second_hand, turn,
|
|
275
|
+
@shape, @chunk_sizes, @square_count, board_piece_count,
|
|
276
|
+
@first_player_style, @second_player_style)
|
|
277
|
+
instance
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Assigns instance variables for a derived position and freezes it.
|
|
281
|
+
def init_derived(board, first_hand, second_hand, turn, shape, chunk_sizes,
|
|
282
|
+
square_count, board_piece_count, first_player_style,
|
|
283
|
+
second_player_style)
|
|
284
|
+
@board = board
|
|
285
|
+
@first_hand = first_hand
|
|
286
|
+
@second_hand = second_hand
|
|
287
|
+
@turn = turn
|
|
288
|
+
@shape = shape
|
|
289
|
+
@chunk_sizes = chunk_sizes
|
|
290
|
+
@square_count = square_count
|
|
291
|
+
@board_piece_count = board_piece_count
|
|
292
|
+
@first_player_style = first_player_style
|
|
293
|
+
@second_player_style = second_player_style
|
|
294
|
+
|
|
295
|
+
freeze
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# --- Validation --------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def validate_shape(shape)
|
|
301
|
+
if shape.empty?
|
|
302
|
+
raise ::ArgumentError, "at least one dimension is required"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
if shape.size > MAX_DIMENSIONS
|
|
306
|
+
raise ::ArgumentError, "board exceeds #{MAX_DIMENSIONS} dimensions (got #{shape.size})"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
shape.each do |dim|
|
|
310
|
+
unless dim.is_a?(::Integer)
|
|
311
|
+
raise ::ArgumentError, "dimension size must be an Integer, got #{dim.class}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
if dim < 1
|
|
315
|
+
raise ::ArgumentError, "dimension size must be at least 1, got #{dim}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
if dim > MAX_DIMENSION_SIZE
|
|
319
|
+
raise ::ArgumentError, "dimension size #{dim} exceeds maximum of #{MAX_DIMENSION_SIZE}"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def validate_not_nil(side, style)
|
|
325
|
+
raise ::ArgumentError, "#{side} player style must not be nil" if style.nil?
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def validate_cardinality(square_count, piece_count)
|
|
329
|
+
return if piece_count <= square_count
|
|
330
|
+
|
|
331
|
+
raise ::ArgumentError, "too many pieces for board size (#{piece_count} pieces, #{square_count} squares)"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# --- Hand helpers ------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
# Applies delta changes to a hand array, returning a new array.
|
|
107
337
|
#
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
338
|
+
# Piece keys are normalized to String via interpolation.
|
|
339
|
+
def apply_hand_changes(hand, changes)
|
|
340
|
+
result = hand.dup
|
|
341
|
+
|
|
342
|
+
changes.each do |piece_key, delta|
|
|
343
|
+
unless delta.is_a?(::Integer)
|
|
344
|
+
raise ::ArgumentError, "delta must be an Integer, got #{delta.class} for piece #{piece_key.inspect}"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
next if delta == 0
|
|
115
348
|
|
|
116
|
-
|
|
349
|
+
piece = "#{piece_key}"
|
|
350
|
+
|
|
351
|
+
if delta > 0
|
|
352
|
+
delta.times { result << piece }
|
|
353
|
+
else
|
|
354
|
+
(-delta).times do
|
|
355
|
+
idx = result.index(piece)
|
|
356
|
+
|
|
357
|
+
unless idx
|
|
358
|
+
raise ::ArgumentError, "cannot remove #{piece.inspect}: not found in hand"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
result.delete_at(idx)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
result
|
|
117
367
|
end
|
|
368
|
+
|
|
369
|
+
# --- Board helpers -----------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
# Pre-computes chunk sizes for each dimension level.
|
|
372
|
+
# For shape [8, 8], returns [8, 1].
|
|
373
|
+
# For shape [5, 5, 5], returns [25, 5, 1].
|
|
374
|
+
# For shape [8], returns [1].
|
|
375
|
+
def compute_chunk_sizes(shape)
|
|
376
|
+
sizes = ::Array.new(shape.size)
|
|
377
|
+
sizes[-1] = 1
|
|
378
|
+
(shape.size - 2).downto(0) do |i|
|
|
379
|
+
sizes[i] = sizes[i + 1] * shape[i + 1]
|
|
380
|
+
end
|
|
381
|
+
sizes
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Reconstructs a nested board structure from a flat Array and
|
|
385
|
+
# pre-computed chunk sizes. Returns an independent copy.
|
|
386
|
+
def unflatten(flat, chunk_sizes, dim)
|
|
387
|
+
if dim == chunk_sizes.size - 1
|
|
388
|
+
return flat.dup
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
chunk = chunk_sizes[dim]
|
|
392
|
+
flat.each_slice(chunk).map do |slice|
|
|
393
|
+
unflatten(slice, chunk_sizes, dim + 1)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
freeze
|
|
118
398
|
end
|
metadata
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: qi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 12.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cyril Kato
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies: []
|
|
13
|
-
description: A
|
|
14
|
-
|
|
12
|
+
description: A minimal, format-agnostic library for representing positions in two-player,
|
|
13
|
+
turn-based board games (chess, shogi, xiangqi, and variants).
|
|
15
14
|
email: contact@cyril.email
|
|
16
15
|
executables: []
|
|
17
16
|
extensions: []
|
|
18
17
|
extra_rdoc_files: []
|
|
19
18
|
files:
|
|
20
|
-
- LICENSE.md
|
|
21
19
|
- README.md
|
|
22
20
|
- lib/qi.rb
|
|
21
|
+
- lib/qi/board.rb
|
|
22
|
+
- lib/qi/hands.rb
|
|
23
|
+
- lib/qi/styles.rb
|
|
23
24
|
homepage: https://github.com/sashite/qi.rb
|
|
24
25
|
licenses:
|
|
25
|
-
-
|
|
26
|
+
- Apache-2.0
|
|
26
27
|
metadata:
|
|
27
28
|
rubygems_mfa_required: 'true'
|
|
28
|
-
post_install_message:
|
|
29
29
|
rdoc_options: []
|
|
30
30
|
require_paths:
|
|
31
31
|
- lib
|
|
@@ -40,8 +40,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
40
40
|
- !ruby/object:Gem::Version
|
|
41
41
|
version: '0'
|
|
42
42
|
requirements: []
|
|
43
|
-
rubygems_version:
|
|
44
|
-
signing_key:
|
|
43
|
+
rubygems_version: 4.0.5
|
|
45
44
|
specification_version: 4
|
|
46
|
-
summary:
|
|
45
|
+
summary: A minimal, format-agnostic position model for two-player board games.
|
|
47
46
|
test_files: []
|
data/LICENSE.md
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# The MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2015-2023 Sashité
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in
|
|
13
|
-
all copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
-
THE SOFTWARE.
|