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.
Files changed (8) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +321 -67
  3. data/lib/qi/board.rb +129 -0
  4. data/lib/qi/hands.rb +80 -0
  5. data/lib/qi/styles.rb +74 -0
  6. data/lib/qi.rb +370 -90
  7. metadata +10 -11
  8. data/LICENSE.md +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a98ed653f1c1d21ae215f4a8ab481a0a19dd93551a9a3ba0faf9ee8e541291f
4
- data.tar.gz: d1291d436f366c70e07a64a3a6390a3a9d99d56cfeb8b0b85c1002addbe7b540
3
+ metadata.gz: c916e1be3a5a9e6c40fe3fad735488b69a4663607d55f80989a8daf023fd84b5
4
+ data.tar.gz: d4ba55868fa1dbfce8cf9d46dde4ed3be9d839cf59320c7f9f90ee9973c60393
5
5
  SHA512:
6
- metadata.gz: 154aa839e292e2cadfbafe8c64e324285216da01a491ef98815479bf1bd735dc2ec747ad31e6ef479ea4d913b262dbcb9ad6399f13cdf5199dddb3403d7091ee
7
- data.tar.gz: 52c5ca680fb11577fc2aa84931900b9f8c712e6c300ca5774f918f144fd1c999f1e34387e8827fe5f7f3d55ed192d04dabb310acc538c19bee3b941effb68637
6
+ metadata.gz: a0bd0d10e698c1f80c2cd6d501bb5d5be865f9b4e53de224be4fb4b7b84a0cf1d6ee22428c1c01c5f0904a867923f294bc2beba21701d729d294e39e4323d504
7
+ data.tar.gz: df8c064144e6194743d3a9bf86f78d958c748d3e6ce8bbdeb792768a3852ce1ba15b3bababb2ce948b7cc8e12be91563b8cfd1e6dfcc5c4b5302bfc0c15777e9
data/README.md CHANGED
@@ -1,108 +1,362 @@
1
- # Qi.rb
1
+ # qi.rb
2
2
 
3
- [![Version](https://img.shields.io/github/v/tag/sashite/qi.rb?label=Version&logo=github)](https://github.com/sashite/qi.rb/releases)
4
- [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/sashite/qi.rb/main)
5
- [![CI](https://github.com/sashite/qi.rb/workflows/CI/badge.svg?branch=main)](https://github.com/sashite/qi.rb/actions?query=workflow%3Aci+branch%3Amain)
6
- [![RuboCop](https://github.com/sashite/qi.rb/workflows/RuboCop/badge.svg?branch=main)](https://github.com/sashite/qi.rb/actions?query=workflow%3Arubocop+branch%3Amain)
7
- [![License](https://img.shields.io/github/license/sashite/qi.rb?label=License&logo=github)](https://github.com/sashite/qi.rb/raw/main/LICENSE.md)
3
+ [![Version](https://img.shields.io/gem/v/qi.svg)](https://rubygems.org/gems/qi)
4
+ [![Documentation](https://img.shields.io/badge/yard-docs-blue.svg)](https://rubydoc.info/gems/qi)
5
+ [![CI](https://github.com/sashite/qi.rb/actions/workflows/ruby.yml/badge.svg?branch=main)](https://github.com/sashite/qi.rb/actions)
6
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/sashite/qi.rb/blob/main/LICENSE)
8
7
 
9
- **Qi** (Chinese: 棋; pinyin: _qí_) is a lightweight, flexible, and adaptable tool for representing board game positions, built in Ruby. It is designed to be game-agnostic and can be used with a variety of board games such as Chess, Four-Player Chess, Go, Makruk, Shogi, and Xiangqi.
8
+ > An immutable, format-agnostic position model for two-player board games.
10
9
 
11
- Qi uses a unique approach where the state of a game is represented through capturing the pieces in play, the arrangement of pieces on the board, the sequence of turns, and other possible states that a game can have.
10
+ ## Quick Start
12
11
 
13
- ## Features
12
+ ```ruby
13
+ gem "qi", "~> 12.0"
14
+ ```
15
+
16
+ ```ruby
17
+ require "qi"
14
18
 
15
- 1. **Game Agnostic:** Qi can be used to represent board game positions for a wide variety of games. Whether you are playing Chess, Makruk, Shogi, or Xiangqi, Qi's flexible structure allows you to accurately capture the state of your game.
16
- 2. **Flexible Position Representation:** Qi captures the state of the game by recording the pieces in play, their arrangement on the board, the sequence of turns, and other additional states of the game. This enables a comprehensive view of the game at any given point.
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
- While `Qi` does not generate game moves itself, it serves as a solid foundation upon which game engines can be built. Its design is focused on providing a robust and adaptable representation of game states, paving the way for the development of diverse board game applications.
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
- ## Installation
28
+ pos2.turn #=> :second
29
+ ```
30
+
31
+ Every transformation returns a **new frozen instance**. The original is never modified.
26
32
 
27
- Add this line to your application's Gemfile:
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
- gem "qi"
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
- And then execute:
55
+ ## Installation
34
56
 
35
- ```sh
36
- bundle install
57
+ ```ruby
58
+ # In your Gemfile
59
+ gem "qi", "~> 12.0"
37
60
  ```
38
61
 
39
- Or install it yourself as:
62
+ Or install manually:
40
63
 
41
64
  ```sh
42
65
  gem install qi
43
66
  ```
44
67
 
45
- ## Usage
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
- The following usage example is derived from a classic _tsume shogi_ (詰将棋) problem, which translates to _mate shogi_ - a popular genre of shogi problems where the goal is to checkmate the opponent's king.
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
- On the defending side, there is a king (k) situated on square 4, surrounded by two silver generals (s) on squares 3 and 5 respectively.
74
+ ### Construction
51
75
 
52
- In this scenario, `Qi` allows us to represent the state of the game and apply changes as moves are made. Please follow the given example to understand how to create such a representation and how to update it:
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
- require "qi"
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
- # Initialize an array for each player's captured pieces
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
- # Combine and count each player's captured pieces
62
- captures = Hash.new(0)
63
- (north_captures + south_captures).each { |piece| captures[piece] += 1 }
96
+ ### Constants
64
97
 
65
- # Define the squares occupied by each piece on the board
66
- squares = { 3 => "s", 4 => "k", 5 => "s", 22 => "+P", 43 => "+B" }
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
- # Create a new game position
69
- qi0 = Qi.new(captures, squares, [0, 1])
103
+ ### Accessors
70
104
 
71
- # Verify the properties of the game position
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
- # Move a piece on the board and check the game state
82
- qi1 = qi0.commit([], [], { 43 => nil, 13 => "+B" }, in_check: true)
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
- qi1.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"]
85
- qi1.captures_hash # => {"r"=>2, "b"=>1, "g"=>4, "s"=>1, "n"=>4, "p"=>17, "S"=>1}
86
- qi1.squares_hash # => {3=>"s", 4=>"k", 5=>"s", 22=>"+P", 13=>"+B"}
87
- qi1.state # => {:in_check=>true}
88
- qi1.turn # => 1
89
- qi1.turns # => [1, 0]
90
- qi1.eql?(Qi.new(captures, squares, [0, 1])) # => false
91
- qi1.eql?(Qi.new(captures, squares, [1, 0])) # => false
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
- In this example, we first create a `Qi` object to represent a game position with `Qi.new`. Then, we check various aspects of the game state using the methods provided by `Qi`. After that, we create a new game state `qi1` by committing changes to the existing state `qi0`. Finally, we again check various aspects of the new game state.
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
- ## License
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
- The code is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
145
+ **Raises** `ArgumentError` if an index is out of range or if the resulting total piece count exceeds the board size.
99
146
 
100
- ## About Sashité
147
+ See [Flat Indexing](#flat-indexing) for computing flat indices from coordinates.
101
148
 
102
- This [gem](https://rubygems.org/gems/qi) is proudly maintained and developed by [Sashité](https://sashite.com/). Our mission is to promote intercultural understanding and appreciation through the universal language of board games.
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
- At Sashité, we believe in the power of games as a medium for sharing and appreciating the richness of different cultures. From Chinese to Japanese, and Western traditions, every culture has its unique representation in the world of board games, particularly in chess.
220
+ **2D board** with shape `[R, F]` (R ranks, F files):
105
221
 
106
- Our `Qi` gem is a testament to this belief - a flexible, efficient, and inclusive software that allows for the representation and interaction of diverse chess systems. This piece of software is not just a tool; it is a bridge connecting different cultures under the love of strategic play.
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
- Join us in our journey as we continue to write [code](https://github.com/sashite/) to share the beauty of these cultures, one game at a time.
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
- # The Qi class provides a consistent representation of a game state
4
- # and supports changes in the game state through the commit method.
5
- # It is designed to be used in board games such as chess, makruk, shogi, xiangqi.
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
- # @!attribute [r] captures_hash
8
- # @return [Hash<Object, Integer>] a hash of captured pieces
9
- # @example
10
- # {"r"=>2, "b"=>1, "g"=>4, "s"=>1, "n"=>4, "p"=>17, "S"=>1}
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
- # @example
39
- # captures = Hash.new(0)
40
- # 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]
41
- # south_captures = %w[S]
42
- # (north_captures + south_captures).each { |piece| captures[piece] += 1 }
43
- # squares = { 3 => "s", 4 => "k", 5 => "s", 22 => "+P", 43 => "+B" }
44
- # Qi.new(captures, squares, [0, 1])
45
- def initialize(captures_hash, squares_hash, turns, **state)
46
- @captures_hash = ::Hash.new(0).merge(captures_hash.select { |_, v| v > 0 })
47
- @squares_hash = squares_hash.compact
48
- @turns = turns
49
- @state = state.transform_keys(&:to_sym)
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
- # Return an array of captures containing piece names.
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
- # ["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"]
59
- def captures_array
60
- captures_hash.flat_map { |piece, count| ::Array.new(count, piece) }.sort
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
- # Commit a change to the game state and return a new Qi object.
116
+ # Returns the pieces held by the first player.
64
117
  #
65
- # @param add_captures_array [Array<Object>] an array of pieces to be added to captures
66
- # @param del_captures_array [Array<Object>] an array of pieces to be deleted from captures
67
- # @param edit_squares_hash [Hash<Object, Object>] A hash where the keys represent square
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
- # Check if the current Qi object is equal to another Qi object.
123
+ # Returns the pieces held by the second player.
84
124
  #
85
- # @param other [Qi] another Qi object
86
- # @return [Boolean] returns true if the captures_hash, squares_hash, turn, and state of both Qi objects are equal, false otherwise
87
- def eql?(other)
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
- # Get the current turn.
130
+ # Returns the active player's side.
98
131
  #
99
- # @return [Object] the current turn
132
+ # @return [Symbol] +:first+ or +:second+.
100
133
  def turn
101
- turns.fetch(0)
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
- # Edits the captures hash and returns it.
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
- # @param add_captures_array [Array<Object>] an array of pieces to be added to captures
109
- # @param del_captures_array [Array<Object>] an array of pieces to be deleted from captures
110
- # @param hash [Hash<Object, Integer>] the current captures hash
111
- # @return [Hash<Object, Integer>] the updated captures hash
112
- def edit_captures_hash(add_captures_array, del_captures_array, **hash)
113
- add_captures_array.each { |piece_name| hash[piece_name] += 1 }
114
- del_captures_array.each { |piece_name| hash[piece_name] -= 1 }
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
- hash
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: 10.0.0
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: 2023-05-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
- description: A flexible and customizable library for representing and manipulating
14
- game states, ideal for developing board games like chess, shogi, or xiangqi.
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
- - MIT
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: 3.4.10
44
- signing_key:
43
+ rubygems_version: 4.0.5
45
44
  specification_version: 4
46
- summary: Versatile Board Game Position Representation
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.