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.
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.
- 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.
[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.
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.
- 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.
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.
sashite-feen, sashite-qi, sashite-epin, sashite-sin, with serde.
Rust 1.81.
Licensed under the Apache License, Version 2.0.