use crate::event::{Attestation, EventId, Ply};
use crate::selection::{select_candidate, Candidate, Selection};
use crate::session::SessionParams;
use crate::timing::canonical_timing;
use crate::verdict::Verdict;
use sashite_sanki_engine::domain::half_move::Move;
use sashite_sanki_engine::domain::time::Timestamp;
use sashite_sanki_engine::engine::validate;
use sashite_sanki_engine::kernel::state::SessionState;
use sashite_sanki_engine::kernel::step::{step, StepResult};
use std::collections::btree_map::Entry;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CanonicalPly<'a> {
pub ply: &'a Ply,
pub at: Timestamp,
}
#[derive(Debug, Clone)]
pub enum ChainEnd {
Terminal {
verdict: Verdict,
at: Timestamp,
},
Ongoing(Box<SessionState>),
Inconsistent,
}
#[derive(Debug, Clone)]
pub struct NaturalState<'a> {
pub chain: Vec<CanonicalPly<'a>>,
pub cutoff: Timestamp,
pub end: ChainEnd,
}
impl NaturalState<'_> {
#[inline]
#[must_use]
pub fn next_half_move(&self) -> u32 {
let played = u32::try_from(self.chain.len()).unwrap_or(u32::MAX);
played.saturating_add(1)
}
#[inline]
#[must_use]
pub fn is_empty(&self) -> bool {
self.chain.is_empty()
}
}
fn is_legal(state: &SessionState, mv: &Move) -> bool {
validate(state.position(), mv).is_ok()
}
struct SlotCandidate<'a> {
ply: &'a Ply,
candidate: Candidate<EventId>,
mv: Option<Move>,
}
#[must_use]
pub fn natural_state<'a>(
params: &SessionParams,
plies: &'a [Ply],
attestations: &'a [Attestation],
cutoff: Timestamp,
) -> NaturalState<'a> {
let timestamper = params.timestamper();
let session = params.session();
let start = params.start();
let mut chain: Vec<CanonicalPly<'a>> = Vec::new();
let mut state = params.initial_state();
let mut half_move: u32 = 1;
let end = loop {
let signer = params.player_at(half_move);
let step_no = params.step_at(half_move);
let timed: Vec<SlotCandidate<'a>> = plies
.iter()
.filter(|ply| ply.session == session && ply.signer == signer && ply.step == step_no)
.filter_map(|ply| {
let at = canonical_timing(attestations, ply.id, ply.created_at, timestamper)?;
(at >= start && at <= cutoff).then(|| SlotCandidate {
ply,
candidate: Candidate {
id: ply.id,
created_at: at,
},
mv: Move::parse(&ply.content).ok(),
})
})
.collect();
let boundary = state.last_attestation();
let mut representatives: BTreeMap<(&str, bool, bool), SlotCandidate<'a>> = BTreeMap::new();
for entrant in timed {
let anterior = entrant.candidate.is_anterior(boundary);
let key = (entrant.ply.content.as_str(), entrant.ply.draw, anterior);
match representatives.entry(key) {
Entry::Vacant(vacant) => {
vacant.insert(entrant);
}
Entry::Occupied(mut occupied) => {
let held = &occupied.get().candidate;
let contender = &entrant.candidate;
let held_key = (held.created_at, held.id);
let contender_key = (contender.created_at, contender.id);
let replace = if anterior {
contender_key > held_key
} else {
contender_key < held_key
};
if replace {
occupied.insert(entrant);
}
}
}
}
let slot: Vec<SlotCandidate<'a>> = representatives.into_values().collect();
let candidates: Vec<Candidate<EventId>> = slot.iter().map(|sc| sc.candidate).collect();
let probe = |id: &EventId| {
slot.iter()
.find(|sc| sc.ply.id == *id)
.and_then(|sc| sc.mv.as_ref())
.is_some_and(|mv| is_legal(&state, mv))
};
match select_candidate(boundary, &candidates, params.candidate_cap(), probe) {
Selection::Unfilled => break ChainEnd::Ongoing(Box::new(state)),
Selection::Applied(chosen) => {
let at = chosen.created_at;
let Some((ply, mv)) = slot
.iter()
.find(|sc| sc.ply.id == chosen.id)
.and_then(|sc| Some((sc.ply, sc.mv.as_ref()?)))
else {
break ChainEnd::Inconsistent;
};
let (outcome, next) = match step(state, mv, at) {
StepResult::Illegal { .. } => break ChainEnd::Inconsistent,
StepResult::Advanced { outcome, next } => (outcome, next),
};
chain.push(CanonicalPly { ply, at });
match next {
Some(successor) => {
state = successor;
half_move = half_move.saturating_add(1);
}
None => match Verdict::from_engine(outcome.verdict) {
Some(verdict) => break ChainEnd::Terminal { verdict, at },
None => break ChainEnd::Inconsistent,
},
}
}
}
};
NaturalState { chain, cutoff, end }
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
use super::{natural_state, ChainEnd};
use crate::event::{Attestation, EventId, Ply, PublicKey};
use crate::session::{Seats, SessionParams};
use sashite_sanki_engine::domain::status::Status;
use sashite_sanki_engine::domain::time::{Duration, Timestamp};
use sashite_sanki_engine::domain::time_control::{Period, TimeControl};
use sashite_sanki_engine::position::Position;
const FIRST: u8 = 10;
const SECOND: u8 = 20;
const TIMESTAMPER: u8 = 99;
const SESSION: u8 = 50;
const CONCLUSION: u8 = 170;
const ROOK_KING: &str = "4k^3/8/8/8/8/8/8/R3K^3 / W/w";
fn pk(byte: u8) -> PublicKey {
PublicKey::from_bytes([byte; 32])
}
fn eid(byte: u8) -> EventId {
EventId::from_bytes([byte; 32])
}
fn ts(secs: i64) -> Timestamp {
Timestamp::from_unix(secs)
}
fn ply(id: u8, signer: u8, step: u32, content: &str) -> Ply {
ply_at(id, signer, step, content, 0)
}
fn ply_at(id: u8, signer: u8, step: u32, content: &str, created_at: i64) -> Ply {
Ply::new(
eid(id),
pk(signer),
eid(SESSION),
step,
false,
content.to_owned(),
ts(created_at),
)
}
fn att(id: u8, attests: u8, at: i64) -> Attestation {
Attestation::new(eid(id), pk(TIMESTAMPER), eid(attests), ts(at))
}
fn params_feen(feen: &str) -> SessionParams {
let period = Period::new(Duration::from_secs(600), None, None).expect("valid period");
SessionParams::new(
eid(SESSION),
Some(pk(TIMESTAMPER)),
Seats::new(pk(FIRST), pk(SECOND)).expect("distinct"),
TimeControl::new(period, Vec::new()),
Position::parse(feen).expect("valid FEEN"),
ts(0),
)
.expect("first to move")
}
fn params() -> SessionParams {
params_feen(ROOK_KING)
}
fn params_self_timed() -> SessionParams {
let period = Period::new(Duration::from_secs(600), None, None).expect("valid period");
SessionParams::new(
eid(SESSION),
None, Seats::new(pk(FIRST), pk(SECOND)).expect("distinct"),
TimeControl::new(period, Vec::new()),
Position::parse(ROOK_KING).expect("valid FEEN"),
ts(0),
)
.expect("first to move")
}
fn cutoff(atts: &[Attestation]) -> Timestamp {
atts.iter()
.find(|a| a.attests == eid(CONCLUSION))
.map(|a| a.created_at)
.expect("the fixture attests the conclusion")
}
fn cutoff_att(at: i64) -> Attestation {
att(171, CONCLUSION, at)
}
const RA1A4: &str = "[\"a1\",\"a4\",null]"; const KE8E7: &str = "[\"e8\",\"e7\",null]"; const RA4A5: &str = "[\"a4\",\"a5\",null]";
#[test]
fn complete_consecutive_chain() {
let plies = [
ply(1, FIRST, 1, RA1A4),
ply(2, SECOND, 1, KE8E7),
ply(3, FIRST, 2, RA4A5),
];
let atts = [
att(101, 1, 100),
att(102, 2, 200),
att(103, 3, 300),
cutoff_att(1000),
];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 3);
assert_eq!(ns.next_half_move(), 4);
assert_eq!(*ns.chain[0].ply.id.as_bytes(), [1; 32]);
assert_eq!(*ns.chain[2].ply.id.as_bytes(), [3; 32]);
assert!(matches!(ns.end, ChainEnd::Ongoing(_)));
}
#[test]
fn self_timed_chain_uses_event_created_at() {
let plies = [
ply_at(1, FIRST, 1, RA1A4, 100),
ply_at(2, SECOND, 1, KE8E7, 200),
ply_at(3, FIRST, 2, RA4A5, 300),
];
let no_atts: Vec<Attestation> = Vec::new();
let ns = natural_state(¶ms_self_timed(), &plies, &no_atts, ts(1000));
assert_eq!(ns.chain.len(), 3);
assert_eq!(ns.next_half_move(), 4);
assert_eq!(*ns.chain[0].ply.id.as_bytes(), [1; 32]);
assert_eq!(ns.chain[0].at, ts(100));
assert!(matches!(ns.end, ChainEnd::Ongoing(_)));
}
#[test]
fn self_timed_cutoff_excludes_a_later_ply() {
let plies = [
ply_at(1, FIRST, 1, RA1A4, 100),
ply_at(2, SECOND, 1, KE8E7, 500),
];
let no_atts: Vec<Attestation> = Vec::new();
let ns = natural_state(¶ms_self_timed(), &plies, &no_atts, ts(300));
assert_eq!(ns.chain.len(), 1); }
#[test]
fn cutoff_inclusivity() {
let plies = [ply(1, FIRST, 1, RA1A4)];
let atts = [att(101, 1, 1000), cutoff_att(1000)];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 1);
}
#[test]
fn cutoff_excludes_a_later_ply() {
let plies = [
ply(1, FIRST, 1, RA1A4),
ply(2, SECOND, 1, KE8E7),
ply(3, FIRST, 2, RA4A5),
];
let atts = [
att(101, 1, 100),
att(102, 2, 200),
att(103, 3, 2000),
cutoff_att(1000),
];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 2);
assert_eq!(ns.next_half_move(), 3);
}
#[test]
fn opponent_slot_cannot_be_filled() {
let plies = [ply(1, FIRST, 1, RA1A4), ply(2, FIRST, 2, RA4A5)];
let atts = [att(101, 1, 100), att(102, 2, 200), cutoff_att(1000)];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 1);
assert!(matches!(ns.end, ChainEnd::Ongoing(_)));
}
#[test]
fn pending_ply_breaks_the_chain() {
let plies = [ply(1, FIRST, 1, RA1A4), ply(2, SECOND, 1, KE8E7)];
let atts = [att(101, 1, 100), cutoff_att(1000)];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 1);
}
#[test]
fn gap_in_play_order_stops_the_chain() {
let plies = [ply(1, FIRST, 1, RA1A4)];
let atts = [att(101, 1, 100), cutoff_att(1000)];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 1);
assert_eq!(ns.next_half_move(), 2);
assert!(!ns.is_empty());
}
#[test]
fn deep_premove_activates_by_chain_progression() {
let plies = [
ply(1, FIRST, 1, RA1A4),
ply(3, FIRST, 2, RA4A5),
ply(2, SECOND, 1, KE8E7),
];
let atts = [
att(101, 1, 100),
att(103, 3, 110), att(102, 2, 200),
cutoff_att(1000),
];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 3);
assert_eq!(*ns.chain[2].ply.id.as_bytes(), [3; 32]);
}
#[test]
fn re_premove_correction_supersedes_illegal_premove() {
let plies = [
ply(1, FIRST, 1, RA1A4),
ply(2, SECOND, 1, "[\"e8\",\"e6\",null]"), ply(3, SECOND, 1, KE8E7), ];
let atts = [
att(101, 1, 200),
att(102, 2, 50),
att(103, 3, 60),
cutoff_att(1000),
];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 2);
assert_eq!(*ns.chain[1].ply.id.as_bytes(), [3; 32]);
assert!(matches!(ns.end, ChainEnd::Ongoing(_)));
}
#[test]
fn informed_illegal_is_skipped_leaving_ongoing() {
let plies = [
ply(1, FIRST, 1, RA1A4),
ply(2, SECOND, 1, "[\"e8\",\"e6\",null]"),
];
let atts = [att(101, 1, 100), att(102, 2, 200), cutoff_att(1000)];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 1); assert!(matches!(ns.end, ChainEnd::Ongoing(_)));
}
#[test]
fn mating_move_terminates_the_chain() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a8\",null]")];
let atts = [att(101, 1, 100), cutoff_att(1000)];
let p = params_feen("7k^/6pp/8/8/8/8/8/R3K^3 / W/w");
let ns = natural_state(&p, &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 1); match ns.end {
ChainEnd::Terminal { verdict, at } => {
assert_eq!(verdict.status(), Status::Checkmate);
assert_eq!(at, ts(100));
}
ChainEnd::Ongoing(_) | ChainEnd::Inconsistent => {
panic!("expected a checkmate termination")
}
}
}
#[test]
fn empty_chain_if_no_first_ply() {
let plies: [Ply; 0] = [];
let atts = [cutoff_att(1000)];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert!(ns.is_empty());
assert_eq!(ns.next_half_move(), 1);
assert!(matches!(ns.end, ChainEnd::Ongoing(_)));
}
#[test]
fn identical_candidates_collapse_to_the_one_the_window_reaches_first() {
let plies = [
ply(1, FIRST, 1, RA1A4),
ply(2, SECOND, 1, KE8E7),
ply(3, SECOND, 1, KE8E7),
];
let atts = [
att(101, 1, 200),
att(102, 2, 50),
att(103, 3, 60),
cutoff_att(1000),
];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 2);
assert_eq!(*ns.chain[1].ply.id.as_bytes(), [3; 32]);
assert_eq!(ns.chain[1].at, ts(60));
let atts = [
att(101, 1, 20),
att(102, 2, 50),
att(103, 3, 60),
cutoff_att(1000),
];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(*ns.chain[1].ply.id.as_bytes(), [2; 32]);
}
#[test]
fn a_re_premove_back_to_an_earlier_content_supersedes() {
let plies = [
ply(1, FIRST, 1, RA1A4),
ply(2, SECOND, 1, KE8E7),
ply(3, SECOND, 1, "[\"e8\",\"d8\",null]"),
ply(4, SECOND, 1, KE8E7),
];
let atts = [
att(101, 1, 200),
att(102, 2, 50),
att(103, 3, 60),
att(104, 4, 70),
cutoff_att(1000),
];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 2);
assert_eq!(*ns.chain[1].ply.id.as_bytes(), [4; 32]);
assert_eq!(ns.chain[1].ply.content, KE8E7);
}
#[test]
fn a_pair_straddling_the_boundary_is_two_candidates() {
let plies = [
ply(1, FIRST, 1, RA1A4),
ply(2, SECOND, 1, KE8E7),
ply(3, SECOND, 1, KE8E7),
];
let atts = [
att(101, 1, 200),
att(102, 2, 50),
att(103, 3, 250),
cutoff_att(1000),
];
for order in [[0_usize, 1, 2], [0, 2, 1], [2, 1, 0]] {
let permuted: Vec<Ply> = order.iter().map(|&i| plies[i].clone()).collect();
let ns = natural_state(¶ms(), &permuted, &atts, cutoff(&atts));
assert_eq!(*ns.chain[1].ply.id.as_bytes(), [2; 32], "order {order:?}");
assert_eq!(ns.chain[1].at, ts(50));
}
let illegal = [
"[\"e8\",\"e6\",null]",
"[\"e8\",\"e5\",null]",
"[\"e8\",\"e4\",null]",
"[\"e8\",\"e3\",null]",
"[\"e8\",\"a8\",null]",
"[\"e8\",\"h8\",null]",
"[\"e8\",\"b5\",null]",
"[\"e8\",\"c6\",null]",
];
let mut flood: Vec<Ply> = plies.to_vec();
let mut flood_atts = atts.to_vec();
for (i, content) in illegal.iter().enumerate() {
let id = 10_u8.saturating_add(u8::try_from(i).expect("small"));
flood.push(ply(id, SECOND, 1, content));
flood_atts.push(att(
100_u8.saturating_add(id),
id,
60 + i64::try_from(i).expect("small"),
));
}
let ns = natural_state(¶ms(), &flood, &flood_atts, cutoff(&flood_atts));
assert_eq!(*ns.chain[1].ply.id.as_bytes(), [3; 32], "the informed twin");
assert_eq!(ns.chain[1].at, ts(250));
}
#[test]
fn pre_t0_candidates_are_ignored() {
let plies = [ply(1, FIRST, 1, RA1A4)];
let atts = [att(101, 1, -5), cutoff_att(1000)];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert!(ns.is_empty());
assert!(matches!(ns.end, ChainEnd::Ongoing(_)));
}
#[test]
fn played_ply_timeout_terminates_the_chain() {
use sashite_sanki_engine::domain::status::Status;
let plies = [ply(1, FIRST, 1, RA1A4)];
let atts = [att(101, 1, 700), cutoff_att(1000)];
let ns = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 1);
match ns.end {
ChainEnd::Terminal { verdict, at } => {
assert_eq!(verdict.status(), Status::Timeout);
assert_eq!(at, ts(700));
}
ChainEnd::Ongoing(_) | ChainEnd::Inconsistent => {
panic!("expected a played-ply timeout")
}
}
}
#[test]
fn a_founding_position_with_second_to_move_is_refused_at_construction() {
let period = Period::new(Duration::from_secs(600), None, None).expect("valid period");
let refused = SessionParams::new(
eid(SESSION),
Some(pk(TIMESTAMPER)),
Seats::new(pk(FIRST), pk(SECOND)).expect("distinct"),
TimeControl::new(period, Vec::new()),
Position::parse(
"-rnbik^bn-r/+f+f+f+f+f+f+f+f/8/8/6P1/8/+P+P+P+P+P+P1+P/-RNBQK^BN-R / j/W",
)
.expect("valid FEEN"),
ts(0),
);
assert!(refused.is_none());
}
fn is_legal_content(
state: &sashite_sanki_engine::kernel::state::SessionState,
content: &str,
) -> bool {
sashite_sanki_engine::domain::half_move::Move::parse(content)
.is_ok_and(|mv| super::is_legal(state, &mv))
}
#[test]
fn is_legal_matches_the_kernel_step_oracle() {
use sashite_sanki_engine::domain::half_move::Move;
use sashite_sanki_engine::domain::time_control::{Period, TimeControl};
use sashite_sanki_engine::kernel::state::SessionState;
use sashite_sanki_engine::kernel::step::step;
let oracle = |state: &SessionState, content: &str| {
let Ok(mv) = Move::parse(content) else {
return false;
};
!matches!(
step(state.clone(), &mv, ts(30)),
sashite_sanki_engine::kernel::step::StepResult::Illegal { .. }
)
};
let state = |feen: &str, secs: u64| {
let period = Period::new(Duration::from_secs(secs), None, None).expect("valid period");
SessionState::start(
Position::parse(feen).expect("valid FEEN"),
TimeControl::new(period, Vec::new()),
ts(0),
)
};
let cases: &[(&str, u64, &str, bool, &str)] = &[
(
"4k^3/8/8/8/8/8/8/R3K^3 / W/w",
600,
"[\"a1\",\"a4\",null]",
true,
"W/w rook move",
),
(
"4k^3/8/8/8/8/8/8/R3K^3 / W/w",
600,
"[\"a1\",\"b3\",null]",
false,
"W/w unreachable destination",
),
(
"4k^3/8/8/8/8/8/8/R3K^3 / W/w",
600,
"[\"e8\",\"e7\",null]",
false,
"W/w opponent's piece",
),
(
"4k^3/8/8/8/8/8/8/R3K^3 / W/w",
600,
"[\"h4\",\"h5\",null]",
false,
"W/w empty source",
),
(
"4k^3/8/8/8/8/8/8/R3K^3 / W/w",
600,
"not a ply",
false,
"W/w unparseable content",
),
(
"4k^3/8/8/8/8/8/8/R3K^3 / W/w",
5,
"[\"a1\",\"a4\",null]",
true,
"W/w legal but out of time",
),
(
"+r3k^1n1/3+pb+p2/b3p2r/1pp3P1/3nP1Pp/2PK^3P/RB+P+PB3/2Q3R1 2pq/2NP w/W",
600,
"[\"e8\",\"c8\",null]",
true,
"W/w castling queenside",
),
(
"+r3k^1n1/3+pb+p2/b3p2r/1pp3P1/3nP1Pp/2PK^3P/RB+P+PB3/2Q3R1 2pq/2NP w/W",
600,
"[\"e8\",\"g8\",null]",
false,
"W/w castling kingside without a rook",
),
(
"-r1b1k^1n1/1+p1+pb+p1r/4p3/2p3p1/3nPP-Pp/q1NK^3P/R+P+P+PB3/2BQ2R1 p/NP w/W",
600,
"[\"h4\",\"g3\",null]",
true,
"W/w pawn takes en passant",
),
(
"5k^2/3P1+pr1/2P5/6b1/p5Pp/2r5/6R1/2n2K^2 5pbnq/5P2B2NQR W/w",
600,
"[\"d7\",\"d8\",\"queen\"]",
true,
"W/w promotion naming an actor",
),
(
"5k^2/3P1+pr1/2P5/6b1/p5Pp/2r5/6R1/2n2K^2 5pbnq/5P2B2NQR W/w",
600,
"[\"d7\",\"d8\",null]",
false,
"W/w promotion without an actor",
),
(
"5k^2/3P1+pr1/2P5/6b1/p5Pp/2r5/6R1/2n2K^2 5pbnq/5P2B2NQR W/w",
600,
"[\"d7\",\"d8\",\"fu\"]",
false,
"W/w promotion naming an ogi actor",
),
(
"5k^2/3P1+pr1/2P5/6b1/p5Pp/2r5/6R1/2n2K^2 5pbnq/5P2B2NQR W/w",
600,
"[null,\"d5\",\"queen\"]",
false,
"W/w drop by a chess side",
),
(
"-rnb1k^bn-r/1+f2+f+f+f+f/f1f1i3/3f4/2P1P3/N5P1/+P+P1+P1+P1+P/-R1BQK^BN-R / W/j",
600,
"[\"c4\",\"d5\",null]",
true,
"W/j chess captures an ogi Fu",
),
(
"-rnb1k^bn-r/1+f1+f+f+f+f+f/f1f1i3/8/2P1P3/N5P1/+P+P1+P1+P1+P/-R1BQK^BN-R / j/W",
600,
"[\"e6\",\"c4\",null]",
true,
"W/j ogi captures a chess Pawn",
),
(
"rnb2br1/+f1ik^+f+f+f+f/8/1fff1P2/4n3/1N4P1/+P+P+P+P2B+P/-RNBQK^2+R /f W/j",
600,
"[\"e1\",\"g1\",null]",
true,
"W/j chess castles in a cross-variant session",
),
(
"-rnb1k^2+r/1+f+f+f+f+fb1/5i2/f6f/3P4/P4PPP/2+P1+P3/-RN1QK^BN-R fn/2f j/W",
600,
"[\"e8\",\"g8\",null]",
true,
"W/j ogi castles in a cross-variant session",
),
(
"3R4/3f+f2+f/5f2/5P-fP/Nf1k^4/3P4/+P2+P1K^2/RNB4b 7f2n2rbi/ W/j",
600,
"[\"f5\",\"g6\",null]",
true,
"W/j chess Pawn takes an ogi Fu en passant",
),
(
"1nb2bnr/r+fik^+f+f+f+f/f2P4/2f2P2/8/N5P1/+P+P1+PN2+P/-R1BQK^B1-R f/f j/W",
600,
"[null,\"d3\",\"fu\"]",
true,
"W/j ogi drops against a chess opponent",
),
(
"-rnb1k^b1-r/1+f+f+f+f+f1+f/4i3/P5f1/4n3/N6P/+P1+P+P+P+P+P1/1RBQK^BN-R f/ W/j",
600,
"[null,\"d4\",\"fu\"]",
false,
"W/j chess side drops its own inert tray",
),
(
"-rnb1k^b1-r/1R+f+f+f+f1+f/4i3/P5f1/4n3/N6P/+P1+P+P+P+P+P1/2BQK^BN-R 2f/ j/W",
600,
"[null,\"d4\",\"fu\"]",
false,
"W/j ogi side drops the OPPONENT's inert tray",
),
(
"1nb1ibnr/r+f1k^P+f1+f/f2P2f1/8/2f5/N5P1/+P+P1+PN2+P/-R1BQK^B1-R 2f/f W/j",
600,
"[\"e7\",\"f8\",\"queen\"]",
true,
"W/j chess promotes in a cross-variant session",
),
(
"1nb1iQn1/r+fk^2+f2/f5f1/4f3/2f2Q1f/N5P1/+P+P1f3+P/-R1B1K^BN-R 2fbr/f j/W",
600,
"[\"d2\",\"d1\",null]",
true,
"W/j ogi promotes without an actor",
),
(
"7k^/8/5N2/8/8/8/8/4K^1R1 F/ J/j",
600,
"[null,\"h7\",\"fu\"]",
false,
"J/j mating Fu drop (uchifuzume)",
),
(
"7k^/8/5N2/8/8/8/8/4K^1R1 F/ J/j",
600,
"[null,\"h6\",\"fu\"]",
true,
"J/j quiet Fu drop",
),
(
"7k^/8/5N2/8/8/8/8/4K^1R1 F/ J/j",
600,
"[null,\"h8\",\"fu\"]",
false,
"J/j drop on an occupied square",
),
(
"-rn2k^bn-r/1b2+f1+f+f/2ff1fN1/1f4B1/f7/FFIF2FF/2+F1+F+F1R/+R3K^B2 I/n J/j",
600,
"[\"e1\",\"c1\",null]",
true,
"J/j ogi castles",
),
(
"rnb2k^1r/1+f1+fn2+f/f1f2f2/2F3f1/FF2fN1F/1I1F2b1/3K^+F+F+F1/1RB2B1R I/n j/J",
600,
"[null,\"a1\",\"knight\"]",
true,
"J/j ogi drops a Knight",
),
(
"rnb4r/1+fF2rk^+f/f2f1f1n/i3n1fF/FF2f1n1/I2F4/4+F+F+Fb/R1BK^1B2 F/ J/j",
600,
"[\"c7\",\"c8\",null]",
true,
"J/j ogi promotes without an actor",
),
(
"7k^/8/5N2/8/8/8/8/4K^1R1 F/ J/w",
600,
"[null,\"h7\",\"fu\"]",
false,
"J/w mating Fu drop against a chess King",
),
(
"7k^/8/5N2/8/8/8/8/4K^1R1 F/ J/w",
600,
"[null,\"g2\",\"fu\"]",
true,
"J/w quiet Fu drop against a chess King",
),
(
"1r2k^3/8/8/8/8/2n5/8/K^7 /f j/W",
600,
"[null,\"a2\",\"fu\"]",
false,
"J/w mating Fu drop by a second-side dropper",
),
(
"1r2k^3/8/8/8/8/2n5/8/K^7 /f j/W",
600,
"[null,\"d4\",\"fu\"]",
true,
"J/w quiet Fu drop by a second-side dropper",
),
(
"-r1bqk^bn-r/+p+p+p+p+p+p+p1/n6p/8/2F5/2I5/+F+F1+F+F+F+F+F/-RNB1K^BN-R / J/w",
600,
"[\"c3\",\"g7\",null]",
true,
"J/w ogi Princess captures a chess Pawn",
),
(
"-r1bq-k^bn-r/+p+p+p+p+p+pI1/n6p/8/2F5/8/+F+F1+F+F+F+F+F/-RNB1K^BN-R F/ w/J",
600,
"[\"f8\",\"g7\",null]",
true,
"J/w chess Bishop captures an ogi Princess",
),
(
"1n1k^1Bnr/1+p2+p2+p/r1p2p2/pN1p4/4q1I1/F1FF4/1+F2N1+F+F/+R3K^B1-R 3F/2F J/w",
600,
"[\"e1\",\"c1\",null]",
true,
"J/w ogi castles queenside",
),
(
"-rnb1k^2+r/1+p1+p+p+p1+p/p6n/2F3q1/8/2bFF2N/+F2B1+F+F+F/-RN2K^BR1 F/2FI w/J",
600,
"[\"e8\",\"g8\",null]",
true,
"J/w chess castles kingside",
),
(
"r1bnqbn1/+p1+p+p+p1+p1/4k^2r/2p5/5-Fp1/F1FF4/1+F2+F2+F/-RN2K^BF-R /BFIN w/J",
600,
"[\"g4\",\"f3\",null]",
true,
"J/w chess Pawn takes an ogi Fu en passant",
),
(
"-rnbqk^bn-r/+p1+p+p+p+p+p+p/F7/1p6/8/8/1+F+F+F+F+F+F+F/-RNBIK^BN-R / J/w",
600,
"[\"a6\",\"b6\",null]",
false,
"J/w ogi Fu never takes en passant (sideways)",
),
(
"-rnbqk^bn-r/+p1+p+p+p+p+p+p/F7/1p6/8/8/1+F+F+F+F+F+F+F/-RNBIK^BN-R / J/w",
600,
"[\"a6\",\"b7\",null]",
false,
"J/w ogi Fu never takes en passant (diagonally)",
),
(
"-rnbqk^bn-r/+p1+p+p+p+p+p+p/F7/1p6/8/8/1+F+F+F+F+F+F+F/-RNBIK^BN-R / J/w",
600,
"[\"a6\",\"a7\",null]",
true,
"J/w ogi Fu captures straight ahead",
),
(
"-rnbeg^bn-r/1+s1+s+s+s+s+s/s1s5/8/2F5/2I5/+F+F1+F+F+F+F+F/-RNB1K^BN-R / J/c",
600,
"[\"c3\",\"g7\",null]",
true,
"J/c ogi Princess captures a xiongqi Soldier",
),
(
"-rnbe-g^bn-r/1+s1+s+s+sI+s/s1s5/8/2F5/8/+F+F1+F+F+F+F+F/-RNB1K^BN-R F/ c/J",
600,
"[\"f8\",\"g7\",null]",
true,
"J/c xiongqi Bear captures an ogi Princess",
),
(
"rnb2rg^1/1+s1+s+s2+s/2s2ssb/s7/4FN2/2FF4/+F+F1N1+F+F+F/+R3K^BR1 2F/BI J/c",
600,
"[\"e1\",\"c1\",null]",
true,
"J/c ogi castles queenside",
),
(
"-r1beg^2+r/1+s1+s+s+s1+s/n1s4n/s1F5/4F2F/1FN5/+Fb1+F1+F+F1/-R1B1K^BN-R F/I c/J",
600,
"[\"e8\",\"g8\",null]",
true,
"J/c xiongqi General castles kingside",
),
(
"1rbe1rg^1/1+s1+s+s+s1+s/2s4n/s1n5/4F2F/1FN5/+Fb1+F1+F+FR/-R1B1K^BN1 F/FI J/c",
600,
"[null,\"c2\",\"fu\"]",
true,
"J/c ogi drops against a xiongqi opponent",
),
(
"3T1g^2/3F4/2s1s3/rF3n1s/2s3-F1/5F1s/1I2B2R/1K^6 /11F2NBR c/J",
600,
"[\"h3\",\"g3\",null]",
true,
"J/c xiongqi Soldier takes an ogi Fu sideways en passant",
),
(
"7g^/8/5N2/8/8/8/8/4K^1R1 F/ J/c",
600,
"[null,\"h7\",\"fu\"]",
false,
"J/c mating Fu drop against a xiongqi General",
),
(
"7g^/8/5N2/8/8/8/8/4K^1R1 F/ J/c",
600,
"[null,\"h6\",\"fu\"]",
true,
"J/c quiet Fu drop against a xiongqi General",
),
(
"1n1eg^bn-r/rb6/s4sss/1ssss3/2SS2S1/E6N/+S+S3+SB+S/-RNB1G^2+R /S C/c",
600,
"[\"e1\",\"g1\",null]",
true,
"C/c xiongqi General castles kingside",
),
(
"1b1eg^2r/3b4/r1sss3/5SB1/1s1-S2Ss/2s1S2R/+S1+S2+SG^1/RN2EB2 2n2s/NS c/C",
600,
"[\"c3\",\"d3\",null]",
true,
"C/c Soldier takes sideways en passant past the river",
),
(
"1r6/1n1S2g^+s/1ss1ss2/rS6/1B3s2/3S4/1NR1G^1Be/6NR 2b2sn/5SE C/c",
600,
"[\"d7\",\"d8\",\"chariot\"]",
true,
"C/c xiongqi promotion naming an actor",
),
(
"1r6/1n1S2g^+s/1ss1ss2/rS6/1B3s2/3S4/1NR1G^1Be/6NR 2b2sn/5SE C/c",
600,
"[\"d7\",\"d8\",\"queen\"]",
false,
"C/c xiongqi promotion naming a chess actor",
),
(
"1r6/1n1S2g^+s/1ss1ss2/rS6/1B3s2/3S4/1NR1G^1Be/6NR 2b2sn/5SE C/c",
600,
"[null,\"d4\",\"chariot\"]",
false,
"C/c drop by a xiongqi side",
),
(
"-r1bqk^b1-r/+p+p+p+p+p+p+p1/n4n1p/8/2S1S3/8/+S+S1+S1+S+S+S/-RNBEG^BN-R / w/C",
600,
"[\"f6\",\"e4\",null]",
true,
"C/w chess Knight captures a xiongqi Soldier",
),
(
"-r1bqk^b1-r/+p+p+p+p+p+p1n/n6p/2S3p1/4S3/8/+S+S1+S1+S+S+S/-RNBEG^BN-R / C/w",
600,
"[\"f1\",\"a6\",null]",
true,
"C/w xiongqi Bear captures a chess Knight",
),
(
"-rnb1k^2+r/2+p+pb+p1n/pE3q1p/1BS1p3/4S1p1/1S3SS1/+SB1+S3+S/-RN2G^1N-R p/ w/C",
600,
"[\"e8\",\"g8\",null]",
true,
"C/w chess castles in a cross-variant session",
),
(
"rqb5/N1+p1nk^2/1p6/2npR1S1/8/2S1E3/3+S+S+S2/+R3G^B2 5pbr/3SBN C/w",
600,
"[\"e1\",\"c1\",null]",
true,
"C/w xiongqi General castles queenside",
),
(
"2b5/2+p4k^/1p6/5nS1/q2-SpS2/1BS1S3/2G^5/r2R4 5pbnr/3S2NBER w/C",
600,
"[\"e4\",\"d3\",null]",
true,
"C/w chess Pawn takes a xiongqi Soldier en passant",
),
(
"-rnbqk^bn-r/+p1+p+p+p+p+p+p/S7/1-p6/8/8/1+S+S+S+S+S+S+S/-RNBEG^BN-R / C/w",
600,
"[\"a6\",\"b6\",null]",
true,
"C/w Soldier takes a chess Pawn sideways en passant",
),
(
"-rnbqk^bn-r/+p1+p+p+p+p+p+p/S7/1-p6/8/8/1+S+S+S+S+S+S+S/-RNBEG^BN-R / C/w",
600,
"[\"a6\",\"b7\",null]",
false,
"C/w Soldier never captures diagonally",
),
(
"-rnb1k^bn-r/1+f1+f+f+f+f+f/f1f1i3/8/2S1S3/8/+S+S1+S1+S+S+S/-RNBEG^BN-R / j/C",
600,
"[\"e6\",\"c4\",null]",
true,
"C/j ogi Princess captures a xiongqi Soldier",
),
(
"-rnb1k^bn-r/1+f2+f+f+f+f/f1f5/2Sf1i2/4S3/8/+S+S1+S1+S+S+S/-RNBEG^BN-R / C/j",
600,
"[\"f1\",\"a6\",null]",
true,
"C/j xiongqi Bear captures an ogi Fu",
),
(
"-rnb1k^b1-r/+f1+f4+f/R2ff3/1f2E1f1/2N1SfBS/8/1+S+S2+S1R/2i1G^1N1 3fn/f C/j",
600,
"[\"e1\",\"c1\",null]",
true,
"C/j xiongqi General captures at Chariot range, not castling",
),
(
"1rb1k^2+r/1+f+f+f1+f+f+f/4B3/2bnf3/f1N5/3nS1S1/R+S3+S1+S/3EBG^NR fi/2f j/C",
600,
"[\"e8\",\"g8\",null]",
true,
"C/j ogi castles kingside",
),
(
"rnb2b1r/1+f1k^+f+f+f+f/f1f2n2/3SS3/1S6/2N3S1/+S2+S1+S1+S/-R1BEG^Bi-R f/f j/C",
600,
"[null,\"d3\",\"fu\"]",
true,
"C/j ogi drops against a xiongqi opponent",
),
(
"r4Br1/1f+f5/f1S4f/3-ffffS/S1S2nk^1/3SS3/8/R2E1G^N1 4f2bin/f C/j",
600,
"[\"c6\",\"d6\",null]",
true,
"C/j Soldier takes an ogi Fu sideways en passant",
),
(
"r4Br1/2S5/ff5f/3ffffn/S1S3k^1/3SS3/8/R1E2G^N1 5f2bin/2f C/j",
600,
"[\"c7\",\"c8\",\"chariot\"]",
true,
"C/j xiongqi promotes in a cross-variant session",
),
(
"rf1fk^f2/4f2r/f6B/S1S5/6f1/2NG^1SSS/2f5/b5R1 8f2nbi/ j/C",
600,
"[\"c2\",\"c1\",null]",
true,
"C/j ogi promotes without an actor",
),
];
for (feen, secs, content, expected, label) in cases {
let s = state(feen, *secs);
assert_eq!(
Position::parse(feen).expect("valid FEEN").to_feen(),
*feen,
"{label}: the fixture FEEN is not canonical"
);
assert_eq!(
is_legal_content(&s, content),
oracle(&s, content),
"probe/oracle divergence on {label}: {content} in {feen} ({secs} s bank)"
);
assert_eq!(
is_legal_content(&s, content),
*expected,
"legality class drifted on {label}: {content} in {feen}"
);
}
}
const PAIRING_STARTS: [&str; 9] = [
"-rnbqk^bn-r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/-RNBQK^BN-R / W/w",
"-rnbik^bn-r/+f+f+f+f+f+f+f+f/8/8/8/8/+P+P+P+P+P+P+P+P/-RNBQK^BN-R / W/j",
"-rnbeg^bn-r/+s+s+s+s+s+s+s+s/8/8/8/8/+P+P+P+P+P+P+P+P/-RNBQK^BN-R / W/c",
"-rnbqk^bn-r/+p+p+p+p+p+p+p+p/8/8/8/8/+F+F+F+F+F+F+F+F/-RNBIK^BN-R / J/w",
"-rnbik^bn-r/+f+f+f+f+f+f+f+f/8/8/8/8/+F+F+F+F+F+F+F+F/-RNBIK^BN-R / J/j",
"-rnbeg^bn-r/+s+s+s+s+s+s+s+s/8/8/8/8/+F+F+F+F+F+F+F+F/-RNBIK^BN-R / J/c",
"-rnbqk^bn-r/+p+p+p+p+p+p+p+p/8/8/8/8/+S+S+S+S+S+S+S+S/-RNBEG^BN-R / C/w",
"-rnbik^bn-r/+f+f+f+f+f+f+f+f/8/8/8/8/+S+S+S+S+S+S+S+S/-RNBEG^BN-R / C/j",
"-rnbeg^bn-r/+s+s+s+s+s+s+s+s/8/8/8/8/+S+S+S+S+S+S+S+S/-RNBEG^BN-R / C/c",
];
#[test]
#[ignore = "exhaustive: 486_852 probe pairs over 20 positions, ~2.6 s in a debug build — seven times the rest of the suite. Run with `cargo test -- --ignored`."]
fn is_legal_matches_the_kernel_step_oracle_exhaustively() {
use sashite_sanki_engine::domain::half_move::Move;
use sashite_sanki_engine::domain::square::Square;
use sashite_sanki_engine::domain::time_control::{Period, TimeControl};
use sashite_sanki_engine::kernel::state::SessionState;
use sashite_sanki_engine::kernel::step::{step, StepResult};
const ACTORS: [&str; 12] = [
"queen", "rook", "bishop", "knight", "fu", "princess", "chariot", "bear", "empress",
"king", "soldier", "zz",
];
const FILES: [char; 8] = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
const RANKS: [char; 8] = ['1', '2', '3', '4', '5', '6', '7', '8'];
let name = |square: Square| {
format!(
"{}{}",
FILES[usize::from(square.file())],
RANKS[usize::from(square.rank())]
)
};
let mut positions: Vec<&str> = PAIRING_STARTS.to_vec();
positions.extend_from_slice(&[
"-rnb1k^b1-r/1R+f+f+f+f1+f/4i3/P5f1/4n3/N6P/+P1+P+P+P+P+P1/2BQK^BN-R 2f/ j/W",
"1nb2bnr/r+fik^+f+f+f+f/f2P4/2f2P2/8/N5P1/+P+P1+PN2+P/-R1BQK^B1-R f/f j/W",
"3R4/3f+f2+f/5f2/5P-fP/Nf1k^4/3P4/+P2+P1K^2/RNB4b 7f2n2rbi/ W/j",
"-rnb1k^2+r/1+f+f+f+f+fb1/5i2/f6f/3P4/P4PPP/2+P1+P3/-RN1QK^BN-R fn/2f j/W",
"7k^/8/5N2/8/8/8/8/4K^1R1 F/ J/w",
"7g^/8/5N2/8/8/8/8/4K^1R1 F/ J/c",
"1rbe1rg^1/1+s1+s+s+s1+s/2s4n/s1n5/4F2F/1FN5/+Fb1+F1+F+FR/-R1B1K^BN1 F/FI J/c",
"r4Br1/1f+f5/f1S4f/3-ffffS/S1S2nk^1/3SS3/8/R2E1G^N1 4f2bin/f C/j",
"1b1eg^2r/3b4/r1sss3/5SB1/1s1-S2Ss/2s1S2R/+S1+S2+SG^1/RN2EB2 2n2s/NS c/C",
"2b5/2+p4k^/1p6/5nS1/q2-SpS2/1BS1S3/2G^5/r2R4 5pbnr/3S2NBER w/C",
"rqb5/N1+p1nk^2/1p6/2npR1S1/8/2S1E3/3+S+S+S2/+R3G^B2 5pbr/3SBN C/w",
]);
let mut probes: u64 = 0;
for feen in &positions {
let position = Position::parse(feen).expect("valid FEEN");
assert_eq!(position.to_feen(), *feen, "non-canonical fixture {feen}");
let period = Period::new(Duration::from_secs(600), None, None).expect("valid period");
let state = SessionState::start(position, TimeControl::new(period, Vec::new()), ts(0));
let mut check = |content: &str| {
probes = probes.saturating_add(1);
let oracle = match Move::parse(content) {
Ok(mv) => {
!matches!(step(state.clone(), &mv, ts(30)), StepResult::Illegal { .. })
}
Err(_) => false,
};
assert_eq!(
is_legal_content(&state, content),
oracle,
"probe/oracle divergence on {content} in {feen}"
);
};
for from in Square::all() {
let occupied = state.position().piece_at(from).is_some();
for to in Square::all() {
if from == to {
continue;
}
check(&format!("[\"{}\",\"{}\",null]", name(from), name(to)));
if occupied {
for actor in ACTORS {
check(&format!(
"[\"{}\",\"{}\",\"{actor}\"]",
name(from),
name(to)
));
}
}
}
}
for to in Square::all() {
for actor in ACTORS {
check(&format!("[null,\"{}\",\"{actor}\"]", name(to)));
}
}
}
assert_eq!(
probes, 486_852,
"the sweep no longer covers the expected cross-product"
);
}
#[test]
fn the_draw_flag_is_part_of_the_identity_of_a_candidate() {
let offer = |id: u8, content: &str| {
Ply::new(
eid(id),
pk(SECOND),
eid(SESSION),
1,
true,
content.to_owned(),
ts(0),
)
};
let atts = [
att(101, 1, 500),
att(103, 3, 100),
att(104, 4, 200),
att(171, CONCLUSION, 1000),
];
let p = params();
let with_offer = [
ply(1, FIRST, 1, RA1A4),
ply(3, SECOND, 1, KE8E7),
offer(4, KE8E7),
];
let natural = natural_state(&p, &with_offer, &atts, cutoff(&atts));
assert_eq!(natural.chain.len(), 2);
let tail = natural.chain.last().expect("a tail");
assert_eq!(tail.ply.id, eid(4));
assert!(tail.ply.draw, "the offer stands");
let twins = [
ply(1, FIRST, 1, RA1A4),
ply(3, SECOND, 1, KE8E7),
ply(4, SECOND, 1, KE8E7),
];
let natural = natural_state(&p, &twins, &atts, cutoff(&atts));
let tail = natural.chain.last().expect("a tail");
assert_eq!(tail.ply.id, eid(4));
assert!(!tail.ply.draw);
const X: &str = "[\"e8\",\"e6\",null]"; const Y: &str = "[\"e8\",\"e5\",null]"; let capped = [
ply(1, FIRST, 1, RA1A4),
ply(2, SECOND, 1, KE8E7), ply(3, SECOND, 1, X),
offer(4, X),
ply(5, SECOND, 1, Y),
];
let capped_atts = [
att(101, 1, 500),
att(102, 2, 100),
att(103, 3, 110),
att(104, 4, 120),
att(105, 5, 130),
att(171, CONCLUSION, 1000),
];
let three = core::num::NonZeroUsize::new(3).expect("non-zero");
let natural = natural_state(
&p.clone().with_candidate_cap(three),
&capped,
&capped_atts,
cutoff(&capped_atts),
);
assert_eq!(
natural.chain.len(),
1,
"the flagged and flagless X are two candidates, and fill the cap"
);
let four = core::num::NonZeroUsize::new(4).expect("non-zero");
let natural = natural_state(
&p.clone().with_candidate_cap(four),
&capped,
&capped_atts,
cutoff(&capped_atts),
);
assert_eq!(natural.chain.len(), 2);
assert_eq!(natural.chain[1].ply.id, eid(2));
}
#[test]
fn the_cap_the_session_carries_bounds_the_windows() {
let plies = [
ply(1, FIRST, 1, RA1A4),
ply(2, SECOND, 1, KE8E7),
ply(3, SECOND, 1, "[\"e8\",\"e5\",null]"),
];
let atts = [
att(101, 1, 100),
att(102, 2, 50),
att(103, 3, 60),
cutoff_att(1000),
];
let reference = natural_state(¶ms(), &plies, &atts, cutoff(&atts));
assert_eq!(reference.chain.len(), 2);
assert_eq!(reference.chain[1].ply.id, eid(2));
let one = core::num::NonZeroUsize::new(1).expect("non-zero");
let tight = params().with_candidate_cap(one);
let natural = natural_state(&tight, &plies, &atts, cutoff(&atts));
assert_eq!(
natural.chain.len(),
1,
"K = 1 admits the newest premove only"
);
assert!(matches!(natural.end, ChainEnd::Ongoing(_)));
}
#[test]
fn identical_twins_timed_at_the_boundary_are_informed() {
let plies = [
ply(1, FIRST, 1, RA1A4),
ply(2, SECOND, 1, KE8E7),
ply(3, SECOND, 1, KE8E7),
];
let atts = [
att(101, 1, 100),
att(102, 2, 100),
att(103, 3, 100),
cutoff_att(1000),
];
for order in [[0_usize, 1, 2], [0, 2, 1], [2, 1, 0], [1, 2, 0]] {
let permuted: Vec<Ply> = order.iter().map(|&i| plies[i].clone()).collect();
let ns = natural_state(¶ms(), &permuted, &atts, cutoff(&atts));
assert_eq!(ns.chain.len(), 2, "order {order:?}");
assert_eq!(ns.chain[1].ply.id, eid(2), "order {order:?}");
}
let anterior = [
att(101, 1, 100),
att(102, 2, 99),
att(103, 3, 99),
cutoff_att(1000),
];
let ns = natural_state(¶ms(), &plies, &anterior, cutoff(&anterior));
assert_eq!(ns.chain[1].ply.id, eid(3));
}
#[test]
fn a_ply_timed_exactly_at_t0_is_a_candidate() {
let period = Period::new(Duration::from_secs(600), None, None).expect("valid period");
let scheduled = SessionParams::new(
eid(SESSION),
Some(pk(TIMESTAMPER)),
Seats::new(pk(FIRST), pk(SECOND)).expect("distinct"),
TimeControl::new(period, Vec::new()),
Position::parse(ROOK_KING).expect("valid FEEN"),
ts(1000),
)
.expect("first to move");
let plies = [ply(1, FIRST, 1, RA1A4)];
let at_start = [att(101, 1, 1000), cutoff_att(2000)];
let ns = natural_state(&scheduled, &plies, &at_start, cutoff(&at_start));
assert_eq!(ns.chain.len(), 1);
assert_eq!(ns.chain[0].at, ts(1000));
let too_early = [att(101, 1, 999), cutoff_att(2000)];
let ns = natural_state(&scheduled, &plies, &too_early, cutoff(&too_early));
assert!(ns.is_empty());
}
}