# sashite-qi
[](https://crates.io/crates/sashite-qi)
[](https://docs.rs/sashite-qi)
[](https://github.com/sashite/qi.rs/actions/workflows/ci.yml)
[](https://github.com/sashite/qi.rs/blob/main/LICENSE)
> An immutable, format-agnostic position model for two-player board games.
## Overview
`Qi` models a board-game **position** as defined by the
[Sashité Game Protocol](https://sashite.dev/game-protocol/). A position encodes
exactly four things:
| Board | `board` | Flat slice of squares, indexed in row-major order |
| Hands | `first_hand`, `second_hand` | Off-board pieces held by each player, as `piece → count` |
| Styles | `first_style`, `second_style` | One style value per player side |
| Turn | `turn` | The active player (`First` or `Second`) |
The type is **generic over the piece type `P` and the style type `S`**, so it is
independent of any notation. You choose how a piece and a style are represented —
a typed identifier, an interned id, a `&str`, a `String`, … Empty squares are
`None`.
This generality is what lets the Sashité notation crates build on it: the
[FEEN](https://sashite.dev/specs/feen/1.0.0/) position format plugs in
[EPIN](https://sashite.dev/specs/epin/1.0.0/) pieces and
[SIN](https://sashite.dev/specs/sin/1.0.0/) styles, with no string round-tripping
and no loss of type information.
## Quick Start
```rust
use sashite_qi::{Player, Qi};
// An empty 8×8 board. "C" / "c" are style identifiers (here: Chess for each side).
let position = Qi::new(&[8, 8], "C", "c")?
.board_diff([(4, Some("K")), (60, Some("k"))])? // kings
.board_diff([(0, Some("R")), (63, Some("r"))])? // rooks in the corners
.toggle(); // hand over to the second player
assert_eq!(position.turn(), Player::Second);
assert_eq!(position.square(4), Some(&"K"));
assert_eq!(position.square(60), Some(&"k"));
assert_eq!(position.piece_count(), 4);
# Ok::<(), sashite_qi::Error>(())
```
Each transformation **consumes the position and returns a new one**, moving — not
copying — the storage. To keep a previous state, `clone()` it explicitly.
## Installation
```sh
cargo add sashite-qi
```
Or add it to `Cargo.toml`:
```toml
[dependencies]
sashite-qi = "0.1"
```
`sashite-qi` is `no_std` (it links only `core` and `alloc`), forbids `unsafe`,
and has **no required dependencies**. The minimum supported Rust version is
**1.81**.
## Cargo features
- **`serde`** *(off by default)* — implements `Serialize` / `Deserialize` for
`Qi<P, S>` (requires `P` and `S` to be (de)serializable). The wire form is the
logical position — shape, board, hands, styles, turn — with each hand written
as a sequence of `(piece, count)` pairs, so the output is portable across
formats that restrict map keys to strings (JSON included). Decoding rebuilds
the position through the validating constructor, so a deserialized `Qi` upholds
the same invariants as one built by hand. Enabling the feature keeps the crate
`no_std`.
```toml
[dependencies]
sashite-qi = { version = "0.1", features = ["serde"] }
```
## Usage
### Construction
```rust
use sashite_qi::Qi;
let _2d = Qi::new(&[8, 8], "C", "c")?; // 8×8
let _1d = Qi::new(&[8], "G", "g")?; // 1D
let _3d = Qi::new(&[5, 5, 5], "R", "r")?; // 3D
# Ok::<(), sashite_qi::Error>(())
```
`new` starts every square empty (`None`), both hands empty, and the first player
to move. It validates the shape only (the piece and style types are checked by the
compiler, so there are no string-length or nil checks).
### Placing and clearing pieces
`board_diff` addresses squares by **flat index** (see [Board structure](#board-structure)).
Each change is `(index, Some(piece))` or `(index, None)`.
```rust
use sashite_qi::Qi;
let position = Qi::new(&[3, 3], "C", "c")?
.board_diff([(4, Some("K"))])? // place a king on the centre square
.board_diff([(4, None), (0, Some("Q"))])?; // move it off, place a queen on a1
assert_eq!(position.square(0), Some(&"Q"));
assert_eq!(position.square(4), None);
assert_eq!(position.piece_count(), 1);
# Ok::<(), sashite_qi::Error>(())
```
Piece counts are tracked incrementally, so a diff costs time proportional to the
number of changes, not to the board size.
### Hands
Hands are `piece → count` maps. Each change is `(piece, delta)`: a positive delta
adds copies, a negative delta removes them, zero is a no-op.
```rust
use sashite_qi::Qi;
let position = Qi::new(&[8, 8], "C", "c")?
.first_hand_diff([("P", 2), ("B", 1)])?
.first_hand_diff([("B", -1), ("P", 1)])?; // remove a B, add a P
assert_eq!(position.first_hand_count(&"P"), 3);
assert_eq!(position.first_hand_count(&"B"), 0); // removed entirely
assert_eq!(position.hand_piece_count(), 3);
# Ok::<(), sashite_qi::Error>(())
```
### A move, end to end
The protocol does not prescribe how captures are modelled. `board_diff` does not
track what was previously on a square, so a captured piece is added to a hand
separately.
```rust
use sashite_qi::Qi;
let start = Qi::new(&[8, 8], "C", "c")?
.board_diff([(12, Some("P")), (28, Some("p"))])?;
// First player captures on square 28 and slides their pawn there.
let after = start
.board_diff([(28, Some("P")), (12, None)])? // overwrite defender, vacate source
.first_hand_diff([("p", 1)])? // pocket the captured piece
.toggle(); // hand over the turn
# Ok::<(), sashite_qi::Error>(())
```
### Accessors
| `shape()` | `&[usize]` | Dimension sizes, outermost first |
| `dimension_count()` | `usize` | Number of dimensions |
| `square_count()` | `usize` | Total squares on the board |
| `piece_count()` | `usize` | Pieces on the board plus both hands |
| `board_piece_count()` | `usize` | Pieces on the board |
| `hand_piece_count()` | `usize` | Pieces across both hands |
| `turn()` | `Player` | The active player |
| `first_style()` / `second_style()` | `&S` | Each player's style |
| `board()` | `&[Option<P>]` | The board as a flat slice |
| `square(index)` | `Option<&P>` | The piece at an index (`None` if empty/invalid) |
| `first_hand()` / `second_hand()` | `impl Iterator<Item = (&P, usize)>` | Hand items in key order |
| `first_hand_count(&P)` / `second_hand_count(&P)` | `usize` | Copies of a piece held (0 if absent) |
### Constants
| `MAX_DIMENSIONS` | `3` | Maximum number of board dimensions |
| `MAX_DIMENSION_SIZE` | `255` | Maximum size of any single dimension |
| `MAX_SQUARE_COUNT` | `65_025` | Maximum total squares (`255 × 255`) |
## Board structure
### Shape and dimensionality
`shape()` returns the dimension sizes, outermost first. The number of squares is
their product, which must not exceed `MAX_SQUARE_COUNT`.
| 1D | `Qi::new(&[8], …)` | `[8]` |
| 2D | `Qi::new(&[8, 8], …)` | `[8, 8]` |
| 3D | `Qi::new(&[5, 5, 5], …)`| `[5, 5, 5]` |
The total-square cap is independent of dimensionality: 3D boards are fully
supported as long as the product stays within the limit (e.g. `40×40×40 = 64_000`
is fine; `255×255×2` is not).
### Flat indexing
Squares are addressed by a single integer in **row-major order**.
**1D** with shape `[f]`: `index = file`.
**2D** with shape `[r, f]` (`r` ranks, `f` files): `index = rank × f + file`.
For a 3×3 board (shape `[3, 3]`):
```text
file
0 1 2
┌────┬────┬────┐
rank 0 │ 0 │ 1 │ 2 │
├────┼────┼────┤
rank 1 │ 3 │ 4 │ 5 │
├────┼────┼────┤
rank 2 │ 6 │ 7 │ 8 │
└────┴────┴────┘
```
Square `(rank = 1, file = 2)` → index `1 × 3 + 2 = 5`.
**3D** with shape `[l, r, f]` (`l` layers, `r` ranks, `f` files):
`index = layer × (r × f) + rank × f + file`.
### Piece cardinality
The total number of pieces — board squares plus both hands — must never exceed the
number of squares. For a board of *n* squares and *p* pieces: **0 ≤ p ≤ n**. This
invariant is checked on every transformation.
```rust
use sashite_qi::{Error, Qi};
let full = Qi::new(&[2], "C", "c")?
.board_diff([(0, Some("a")), (1, Some("b"))])?; // 2 pieces on 2 squares: OK
// A third piece (here in hand) would exceed the board.
assert_eq!(full.first_hand_diff([("c", 1)]), Err(Error::TooManyPieces));
# Ok::<(), sashite_qi::Error>(())
```
## Errors
All fallible operations return [`Error`]:
| `EmptyShape` | The shape had no dimensions |
| `TooManyDimensions` | More than `MAX_DIMENSIONS` dimensions |
| `DimensionTooSmall` | A dimension of size 0 |
| `DimensionTooLarge` | A dimension larger than `MAX_DIMENSION_SIZE` |
| `TooManySquares` | The product of the dimensions exceeds `MAX_SQUARE_COUNT` |
| `IndexOutOfRange` | A `board_diff` index is not a square on the board |
| `HandUnderflow` | Removing more copies of a piece than are held |
| `TooManyPieces` | The piece count would exceed the square count |
Construction validates the shape in order: dimension count, then each dimension
size, then the total square count.
## Generic over pieces and styles
`P` (piece) and `S` (style) are type parameters. The only trait bound is `P: Ord`,
required by the two hand-diff methods and the hand-count lookups (hands are
ordered maps); construction, `board_diff`, `toggle`, and the board accessors place
no bound on `P` or `S`. `Debug`, `Clone`, `PartialEq`, `Eq`, and `Hash` are derived
conditionally, so a `Qi` is comparable and hashable — usable as a position-identity
key — whenever its `P` and `S` are.
Any type works as a piece or style:
```rust
use sashite_qi::Qi;
// Integer pieces, byte styles.
let position = Qi::new(&[4], 1u8, 2u8)?
.board_diff([(0, Some(42u32)), (3, Some(7u32))])?;
assert_eq!(position.square(0), Some(&42));
assert_eq!(position.first_style(), &1u8);
# Ok::<(), sashite_qi::Error>(())
```
One ergonomic note: because `new` does not take a piece argument, the piece type
`P` cannot be inferred for a position that is never given a piece. In that case,
annotate it (`let position: Qi<&str, &str> = Qi::new(&[8, 8], "C", "c")?;`). In
normal use the first `board_diff` fixes `P`.
## Design
- **Immutable, move-based.** Transformations consume the position and return a new
one, giving value semantics with zero-copy moves. Clone for a snapshot. A `Qi`
is safe to use as a map key, cache entry, or history record.
- **Bounded by construction.** Dimensions, dimension sizes, total squares, and the
piece count are all bounded and checked, so a `Qi` is safe to build from
untrusted input with no extra sanitization.
- **Performance-oriented internals.** The board is a flat `Vec` for O(1) indexed
access; hands are `piece → count` maps; piece totals are maintained
incrementally, so a diff costs O(changes), not O(board size).
- **`no_std`, no `unsafe`, no required dependencies.** Only `core` and `alloc`;
built under a forbid-`unsafe` lint policy; `serde` is an optional add-on.
## Ecosystem
`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*:
- [FEEN](https://sashite.dev/specs/feen/1.0.0/) — a canonical string encoding for positions
- [EPIN](https://sashite.dev/specs/epin/1.0.0/) — piece token syntax
- [SIN](https://sashite.dev/specs/sin/1.0.0/) — style token syntax
- [Game Protocol](https://sashite.dev/game-protocol/) — the shared conceptual foundation
## License
Available as open source under the terms of the
[Apache License 2.0](https://opensource.org/licenses/Apache-2.0).