Expand description
§Sashité Sanki Engine
Rules engine for the Sanki game suite — chess, ogi, and xiongqi, all
played on an 8×8 board — built for Sashité. A pure rules
engine with no Nostr dependency, published under the Apache-2.0 license.
The adjudication layer (race resolution, verdict assembly over an abstract event
model) lives in the companion crate
sashite-sanki-arbiter, which
depends on this one.
§Architecture
The crate is layered, each layer building only on those below it:
- L0 — geometry (
movement,position): the board, the pieces, and the attack relation every rule above is phrased in. Public and useful on its own — check detection, castling legality and uchifuzume are all written againstmovement::attack, and so is anything else that needs to know what a piece bears on. - L1 — kernel (
kernel): a pure per-ply transition (legality → apply → canonicalize → tick → terminal). No I/O, no Nostr. - The
enginemodule is the ergonomic façade over the kernel.
§Design guarantees
- Panic-free by construction. Crate lints forbid
unsafe, and denyunwrap/expect/panic, slice indexing, and overflowing arithmetic. The kernel never fails on a well-formed input: an illegal move is a structured rejection (StepResult::Illegal, with itsIllegalReason), never a panic. - One legality, everywhere. The
enginefaçade applies the full rule system — including ōgi’s uchifuzume (a Fu drop may not deliver checkmate), rejected asIllegalReason::Uchifuzumebyvalidate/apply, excluded fromlegal_moves, and reflected instatus’s checkmate/stalemate classification — exactly the legality the kernel enforces per ply. - Ten statuses, no
illegalmove. An illegal ply is a rejection, never a termination:validate/applyreturn the preciseIllegalReason, and the kernel’sStepResult::Illegalhands the untouched state back — the player keeps the turn, per statuses-sanki (an illegal Ply is skipped, never a loss). - Deterministic. Every entry point is a pure function of its inputs; the
per-session concerns (clocks, the history that repetition, the move-limit, and
the absolute 300-move cap depend on) live in the
kernel. - Three variants, one engine. Chess, ōgi, and xiongqi share a single move, capture, and hand-conversion model; cross-variant interactions follow one common model. The one deliberately variant-specific terminal rule is dead-position detection (rules-of-*.md §Dead-Position Detection): the material-only drawn configurations the engine detects depend on the session’s variant pairing — chess has four, xiongqi one, pure ōgi none.
§Usage
[dependencies]
sashite-sanki-engine = "0.10"use sashite_sanki_engine::domain::half_move::Move;
use sashite_sanki_engine::domain::outcome::Verdict;
use sashite_sanki_engine::engine;
use sashite_sanki_engine::position::Position;
// A position is parsed from its canonical FEEN.
let position = Position::parse("4k^3/8/8/8/8/8/8/R3K^3 / W/w").expect("valid Sanki FEEN");
// Its intrinsic status: no checkmate, stalemate, or dead-position draw here.
assert_eq!(engine::status(&position), Verdict::Ongoing);
// Every legal move for the side to move can be enumerated.
assert!(!engine::legal_moves(&position).is_empty());
// A move is a kind-3423 content array: [from, to, actor].
let mv = Move::parse(r#"["a1","a4",null]"#).expect("valid ply content");
assert!(engine::validate(&position, &mv).is_ok());
// Applying a legal move returns the canonical resulting position.
let next = engine::apply(&position, &mv).expect("legal move");
assert_eq!(next.to_feen(), "4k^3/8/8/8/R7/8/8/4K^3 / w/W");
// `status` also detects terminations — here a back-rank checkmate.
let mated = Position::parse("R6-k^/6pp/8/8/8/8/8/4K^3 / w/W").expect("valid FEEN");
assert!(engine::status(&mated).is_terminated());
// The façade applies the full rule system — ōgi's uchifuzume included:
// a Fu drop that would deliver checkmate is rejected, never applied.
let ogi = Position::parse("7k^/8/5N2/8/8/8/8/4K^1R1 F/ J/j").expect("valid Sanki FEEN");
let mating_drop = Move::parse(r#"[null,"h7","fu"]"#).expect("valid ply content");
assert!(engine::validate(&ogi, &mating_drop).is_err());The four entry points of engine are legal_moves, validate, apply, and
status. They are pure functions over a Position; for clocks, repetition, the
move-limit, and the absolute 300-move cap, drive the kernel directly.
The core types above can be brought into scope at once with
use sashite_sanki_engine::prelude::*;, which also re-exports the engine
module.
§Reading a position beneath the façade
status says a position is checkmate. It does not say which pieces deliver
it, and some questions turn on exactly that — a double check is two attackers, a
smothered mate is one and it is a Knight. movement::attack answers in three
readings of one relation, and none of them needs the kernel:
use sashite_sanki_engine::domain::side::Side;
use sashite_sanki_engine::domain::square::Square;
use sashite_sanki_engine::movement::attack;
use sashite_sanki_engine::position::Position;
let mated = Position::parse("R6-k^/6pp/8/8/8/8/8/4K^3 / w/W").expect("valid FEEN");
let variant = mated.variant_of(Side::First);
let royal = Square::parse("h8").expect("valid square");
let rook = Square::parse("a8").expect("valid square");
// Is the royal attacked at all? The check test — it stops at the first attacker.
assert!(attack::is_attacked(royal, Side::First, variant, |s| mated.piece_at(s)));
// By which pieces? Here exactly one, and this is the rook that mates.
let checkers = attack::attackers_of(royal, Side::First, variant, |s| mated.piece_at(s));
assert_eq!(checkers, vec![rook]);
// Does one named piece bear on one named square?
assert!(attack::attacks_from(rook, variant, royal, |s| mated.piece_at(s)));The relation ignores the occupant of the target square, so attacks_from also
answers “does this piece defend that one”. The variant parameter is the
variant of the attacking side — variant_of(side), never active_variant() —
because a foot-soldier’s attack pattern is the one geometry its letter does not
settle: a chess Pawn bears on its forward diagonals, an ōgi Fu straight ahead.
§Input formats
- A position is a FEEN string (board, hands, and styles + active player),
parsed by
Position::parse. - A move is the kind-3423
content: a three-element array[from, to, actor], e.g.["a1","a4",null]for a board move or[null,"h7","fu"]for an ōgi drop. See the Sashité specifications for the encodings.
§Adjudication
This crate validates and applies moves and reports a position’s intrinsic status.
Session-level adjudication — ruling on a game from its public events (plies,
attestations, adjudication requests), ranking termination causes by attestation
time — is provided by the companion crate
sashite-sanki-arbiter.
§Built on
sashite-feen, sashite-qi, sashite-epin, sashite-sin, with serde.
§Minimum supported Rust version
Rust 1.81.
§License
Licensed under the Apache License, Version 2.0.
Modules§
- apply
- Applying the visible mutations of a move already judged legal: the move itself, Rook relocation (castling), removing the victim (ordinary capture or en passant), crediting the capture to hand, placing the promoted piece, dropping from hand, and passing the turn to the opponent.
- canonicalize
- Canonicalization: recomposes the transient board markers on the position produced by a ply, so that the FEEN the kernel returns is canonical.
- capture
- Capture transformation —
capture_transform. - clock
- Per-ply time accounting: spend the elapsed time on the mover’s clock, apply the increment, advance or repeat quota periods, and detect time-out.
- domain
- Domain value types — pure, with no rules logic.
- engine
- Clean public API for the Sanki rules engine.
- error
- Boundary errors.
- kernel
- L1 — pure per-ply kernel:
step(state, ply, attestation_at) -> Outcome. legality -> apply -> canonicalize -> tick clock -> terminal. ALWAYS returns anOutcome(illegality encoded, never an Err). - legality
- Legality: pseudo-legal -> legal, special moves, drop restrictions.
- movement
- Movement components and dispatch (variant, letter) → rule.
- position
- Position model: a wrapper around
sashite_qi::Qi<Epin, Sin>. - prelude
- Convenience re-exports of the most commonly used items.
- terminal
- Terminations derived from the position (and history).