Skip to content

sashite/qi.rs

Repository files navigation

sashite-qi

Crates.io Docs.rs CI 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. A position encodes exactly four things:

Component Field(s) Description
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 position format plugs in EPIN pieces and SIN styles, with no string round-tripping and no loss of type information.

Quick Start

use sashite_qi::{Player, Qi};

// An empty 8×8 board. "C"/"c" are arbitrary style identifiers, one per side
// (the type is generic over the style, so any value works).
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

cargo add sashite-qi

Or add it to Cargo.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.
[dependencies]
sashite-qi = { version = "0.1", features = ["serde"] }

Usage

Construction

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). Each change is (index, Some(piece)) or (index, None).

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.

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.

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

Method Returns Description
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

Constant Value Description
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.

Dimensionality Constructor shape()
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]):

             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.

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]:

Variant Cause
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:

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é ecosystem. It models what a position is (board, hands, styles, turn) without prescribing how positions are serialized or what moves are legal:

  • FEEN — a canonical string encoding for positions
  • EPIN — piece token syntax
  • SIN — style token syntax
  • Game Protocol — the shared conceptual foundation

License

Available as open source under the terms of the Apache License 2.0.

About

A minimal, format-agnostic Rust library for representing positions in two-player, turn-based board games (chess, shogi, xiangqi, and variants).

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages