use crate::event::{Attestation, Conclusion, Ply, PublicKey};
use crate::implicit::accepts_standing_offer;
use crate::natural_state::{natural_state, ChainEnd, NaturalState};
use crate::session::SessionParams;
use crate::timing::canonical_timing;
use sashite_sanki_engine::clock::tick;
use sashite_sanki_engine::domain::outcome::Verdict as EngineVerdict;
use sashite_sanki_engine::domain::side::Side;
use sashite_sanki_engine::domain::status::{Outcome3, ResultKind, Status};
use sashite_sanki_engine::domain::time::{Duration, Timestamp};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Verdict {
status: Status,
outcome: Outcome3,
}
impl Verdict {
#[inline]
#[must_use]
pub const fn new(status: Status, outcome: Outcome3) -> Option<Self> {
let coherent = match (status.result_kind(), outcome) {
(ResultKind::Draw, Outcome3::Draw) => true,
(ResultKind::Decisive, Outcome3::FirstWins | Outcome3::SecondWins) => true,
(ResultKind::Draw, Outcome3::FirstWins | Outcome3::SecondWins)
| (ResultKind::Decisive, Outcome3::Draw) => false,
};
if coherent {
Some(Self { status, outcome })
} else {
None
}
}
const AGREEMENT: Self = Self {
status: Status::Agreement,
outcome: Outcome3::Draw,
};
#[inline]
const fn timeout_against(loser: Side) -> Self {
Self {
status: Status::Timeout,
outcome: Outcome3::loss_for(loser),
}
}
#[inline]
const fn resignation_by(invoker: Side) -> Self {
Self {
status: Status::Resignation,
outcome: Outcome3::loss_for(invoker),
}
}
#[inline]
#[must_use]
pub(crate) const fn from_engine(verdict: EngineVerdict) -> Option<Self> {
match verdict {
EngineVerdict::Terminated { status, result } => Self::new(status, result),
EngineVerdict::Ongoing => None,
}
}
#[inline]
#[must_use]
pub const fn status(&self) -> Status {
self.status
}
#[inline]
#[must_use]
pub const fn outcome(&self) -> Outcome3 {
self.outcome
}
#[inline]
#[must_use]
pub const fn score(&self, side: Side) -> u8 {
let (first, second) = self.outcome.points();
match side {
Side::First => first,
Side::Second => second,
}
}
#[inline]
#[must_use]
pub fn scores(&self, params: &SessionParams) -> [(PublicKey, u8); 2] {
[
(params.player(Side::First), self.score(Side::First)),
(params.player(Side::Second), self.score(Side::Second)),
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NoVerdict {
OtherSession,
NotAPlayer,
Pending,
BeforeStart,
Inconsistent,
}
impl NoVerdict {
#[inline]
#[must_use]
pub const fn is_transient(self) -> bool {
matches!(self, Self::Pending)
}
}
impl core::fmt::Display for NoVerdict {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::OtherSession => f.write_str("the Conclusion references another session"),
Self::NotAPlayer => f.write_str("the Conclusion is not signed by a session player"),
Self::Pending => f.write_str("the Conclusion has no canonical timing yet"),
Self::BeforeStart => f.write_str("the cutoff precedes the session start"),
Self::Inconsistent => {
f.write_str("the replay hit a broken internal invariant; no verdict is defined")
}
}
}
}
impl core::error::Error for NoVerdict {}
pub fn verdict_at(
params: &SessionParams,
plies: &[Ply],
attestations: &[Attestation],
invoker: Side,
cutoff: Timestamp,
) -> Result<Verdict, NoVerdict> {
if cutoff < params.start() {
return Err(NoVerdict::BeforeStart);
}
let natural = natural_state(params, plies, attestations, cutoff);
resolve_play(params, &natural, invoker)
}
pub fn cutoff_of(
params: &SessionParams,
attestations: &[Attestation],
conclusion: &Conclusion,
) -> Result<(Side, Timestamp), NoVerdict> {
if conclusion.session != params.session() {
return Err(NoVerdict::OtherSession);
}
let invoker = params
.side_of(conclusion.signer)
.ok_or(NoVerdict::NotAPlayer)?;
let cutoff = canonical_timing(
attestations,
conclusion.id,
conclusion.created_at,
params.timestamper(),
)
.ok_or(NoVerdict::Pending)?;
if cutoff < params.start() {
return Err(NoVerdict::BeforeStart);
}
Ok((invoker, cutoff))
}
pub fn expected_verdict(
params: &SessionParams,
plies: &[Ply],
attestations: &[Attestation],
conclusion: &Conclusion,
) -> Result<Verdict, NoVerdict> {
let (invoker, cutoff) = cutoff_of(params, attestations, conclusion)?;
verdict_at(params, plies, attestations, invoker, cutoff)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Check {
Conforming(Verdict),
Wrong {
claimed: Verdict,
expected: Verdict,
},
NoVerdict(NoVerdict),
}
impl Check {
#[inline]
#[must_use]
pub const fn is_conforming(&self) -> bool {
matches!(self, Self::Conforming(_))
}
}
#[must_use]
pub fn check(
params: &SessionParams,
plies: &[Ply],
attestations: &[Attestation],
conclusion: &Conclusion,
) -> Check {
match expected_verdict(params, plies, attestations, conclusion) {
Ok(expected) if expected == conclusion.claim => Check::Conforming(expected),
Ok(expected) => Check::Wrong {
claimed: conclusion.claim,
expected,
},
Err(reason) => Check::NoVerdict(reason),
}
}
#[inline]
#[must_use]
pub fn conforms(
params: &SessionParams,
plies: &[Ply],
attestations: &[Attestation],
conclusion: &Conclusion,
) -> bool {
check(params, plies, attestations, conclusion).is_conforming()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CanonicalConclusion<'a> {
pub conclusion: &'a Conclusion,
pub cutoff: Timestamp,
pub verdict: Verdict,
}
pub fn select_conclusion<'a>(
params: &SessionParams,
plies: &[Ply],
attestations: &[Attestation],
conclusions: &'a [Conclusion],
) -> Result<Option<CanonicalConclusion<'a>>, NoVerdict> {
let mut canonical: Option<CanonicalConclusion<'a>> = None;
for conclusion in conclusions {
let Ok((invoker, cutoff)) = cutoff_of(params, attestations, conclusion) else {
continue;
};
let verdict = verdict_at(params, plies, attestations, invoker, cutoff)?;
if verdict != conclusion.claim {
continue;
}
let candidate = CanonicalConclusion {
conclusion,
cutoff,
verdict,
};
let earlier = match canonical {
None => true,
Some(held) => {
(candidate.cutoff, candidate.conclusion.id) < (held.cutoff, held.conclusion.id)
}
};
if earlier {
canonical = Some(candidate);
}
}
Ok(canonical)
}
fn resolve_play(
params: &SessionParams,
natural: &NaturalState<'_>,
invoker: Side,
) -> Result<Verdict, NoVerdict> {
let state = match &natural.end {
ChainEnd::Terminal { verdict, .. } => return Ok(*verdict),
ChainEnd::Ongoing(state) => state,
ChainEnd::Inconsistent => return Err(NoVerdict::Inconsistent),
};
if accepts_standing_offer(params, natural, invoker) {
return Ok(Verdict::AGREEMENT);
}
let on_move = state.position().active_side();
let anchor = state.last_attestation();
let elapsed = match natural.cutoff.duration_since(anchor) {
Some(elapsed) => elapsed,
None if natural.cutoff < anchor => Duration::ZERO,
None => Duration::from_secs(u64::MAX),
};
if tick(params.time_control(), state.clocks().get(on_move), elapsed).is_flagged() {
return Ok(Verdict::timeout_against(on_move));
}
Ok(Verdict::resignation_by(invoker))
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
use super::{
check, conforms, expected_verdict, select_conclusion, verdict_at, Check, NoVerdict, Verdict,
};
use crate::event::{Attestation, Conclusion, EventId, Ply, PublicKey};
use crate::session::{Seats, SessionParams};
use sashite_sanki_engine::domain::side::Side;
use sashite_sanki_engine::domain::status::{Outcome3, 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;
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::new(
eid(id),
pk(signer),
eid(SESSION),
step,
false,
content.to_owned(),
ts(0),
)
}
fn ply_draw(id: u8, signer: u8, step: u32, content: &str) -> Ply {
Ply::new(
eid(id),
pk(signer),
eid(SESSION),
step,
true,
content.to_owned(),
ts(0),
)
}
fn att(id: u8, attests: u8, at: i64) -> Attestation {
Attestation::new(eid(id), pk(TIMESTAMPER), eid(attests), ts(at))
}
fn conclusion(signer: u8) -> Conclusion {
conclusion_at(signer, 0)
}
fn conclusion_at(signer: u8, created_at: i64) -> Conclusion {
Conclusion::new(
eid(CONCLUSION),
pk(signer),
eid(SESSION),
verdict(Status::Resignation, Outcome3::SecondWins),
ts(created_at),
)
}
fn verdict(status: Status, outcome: Outcome3) -> Verdict {
Verdict::new(status, outcome).expect("a coherent verdict")
}
fn params(feen: &str, tc_secs: u64, anchor: i64) -> SessionParams {
let period = Period::new(Duration::from_secs(tc_secs), None, None).expect("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(anchor),
)
.expect("first to move")
}
#[test]
fn mate_by_chain_replay() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a8\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 1000)];
let p = params("7k^/6pp/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let adj = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), Status::Checkmate);
assert_eq!(adj.outcome(), Outcome3::FirstWins);
}
#[test]
fn illegal_move_in_the_chain_is_skipped_not_a_loss() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 400)];
let p = params("4k^3/8/8/8/8/8/8/4K^3 / W/w", 600, 0);
let adj = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), Status::Resignation);
assert_eq!(adj.outcome(), Outcome3::FirstWins);
}
#[test]
fn differing_contents_are_not_an_equivocation_loss() {
let plies = [
ply(1, FIRST, 1, "[\"a1\",\"a4\",null]"),
ply(2, FIRST, 1, "[\"a1\",\"a5\",null]"),
];
let atts = [
att(101, 1, 100),
att(102, 2, 200),
att(171, CONCLUSION, 400),
];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let adj = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), Status::Resignation);
assert_eq!(adj.outcome(), Outcome3::FirstWins);
}
#[test]
fn own_turn_conclusion_without_cause_is_resignation() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 400)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let adj = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), Status::Resignation);
assert_eq!(adj.outcome(), Outcome3::FirstWins);
}
#[test]
fn off_turn_conclusion_without_cause_is_resignation() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 400)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let adj = expected_verdict(&p, &plies, &atts, &conclusion(FIRST)).expect("result");
assert_eq!(adj.status(), Status::Resignation);
assert_eq!(adj.outcome(), Outcome3::SecondWins);
}
#[test]
fn draw_by_agreement() {
let plies = [ply_draw(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 1000)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let adj = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), Status::Agreement);
assert_eq!(adj.outcome(), Outcome3::Draw);
}
#[test]
fn abandonment_timeout() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 1000)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let adj = expected_verdict(&p, &plies, &atts, &conclusion(FIRST)).expect("result");
assert_eq!(adj.status(), Status::Timeout);
assert_eq!(adj.outcome(), Outcome3::FirstWins);
}
#[test]
fn own_expired_clock_is_a_timeout_not_a_resignation() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 1000)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let adj = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), Status::Timeout);
assert_eq!(adj.outcome(), Outcome3::FirstWins);
}
#[test]
fn empty_chain_conclusion_is_resignation() {
let plies: [Ply; 0] = [];
let atts = [att(171, CONCLUSION, 400)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let adj = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), Status::Resignation);
assert_eq!(adj.outcome(), Outcome3::FirstWins);
}
#[test]
fn unattested_conclusion_no_result() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 100)]; let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
assert_eq!(
expected_verdict(&p, &plies, &atts, &conclusion(SECOND)),
Err(NoVerdict::Pending)
);
assert!(NoVerdict::Pending.is_transient());
}
#[test]
fn non_player_conclusion_no_result() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 1000)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
assert_eq!(
expected_verdict(&p, &plies, &atts, &conclusion(77)),
Err(NoVerdict::NotAPlayer)
);
assert!(!NoVerdict::NotAPlayer.is_transient());
}
#[test]
fn cross_session_conclusion_no_result() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 400)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let foreign = Conclusion::new(
eid(CONCLUSION),
pk(SECOND),
eid(51),
verdict(Status::Resignation, Outcome3::FirstWins),
ts(0),
);
assert_eq!(
expected_verdict(&p, &plies, &atts, &foreign),
Err(NoVerdict::OtherSession)
);
}
fn claim(id: u8, signer: u8, status: Status, result: Outcome3) -> Conclusion {
Conclusion::new(
eid(id),
pk(signer),
eid(SESSION),
verdict(status, result),
ts(0),
)
}
#[test]
fn conforms_iff_the_claim_is_the_expected_verdict() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a8\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 1000)];
let p = params("7k^/6pp/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let right = claim(CONCLUSION, SECOND, Status::Checkmate, Outcome3::FirstWins);
let right_result =
|p: &SessionParams| expected_verdict(p, &plies, &atts, &right).expect("in reach");
assert!(conforms(&p, &plies, &atts, &right));
let by_winner = claim(CONCLUSION, FIRST, Status::Checkmate, Outcome3::FirstWins);
assert!(conforms(&p, &plies, &atts, &by_winner));
let wrong_split = claim(CONCLUSION, SECOND, Status::Checkmate, Outcome3::SecondWins);
assert!(!conforms(&p, &plies, &atts, &wrong_split));
let wrong_status = claim(CONCLUSION, SECOND, Status::Resignation, Outcome3::FirstWins);
assert!(!conforms(&p, &plies, &atts, &wrong_status));
let drawn = claim(CONCLUSION, SECOND, Status::Agreement, Outcome3::Draw);
assert!(!conforms(&p, &plies, &atts, &drawn));
let unattested = [att(101, 1, 100)];
assert!(!conforms(&p, &plies, &unattested, &right));
assert_eq!(
check(&p, &plies, &unattested, &right),
Check::NoVerdict(NoVerdict::Pending)
);
match check(&p, &plies, &atts, &wrong_split) {
Check::Wrong { claimed, expected } => {
assert_eq!(claimed, wrong_split.claim);
assert_eq!(expected.status(), Status::Checkmate);
assert_eq!(expected.outcome(), Outcome3::FirstWins);
}
other => panic!("expected a wrong claim, got {other:?}"),
}
assert_eq!(
check(&p, &plies, &atts, &right),
Check::Conforming(right_result(&p))
);
let stranger = claim(CONCLUSION, 77, Status::Checkmate, Outcome3::FirstWins);
assert!(!conforms(&p, &plies, &atts, &stranger));
}
#[test]
fn a_premature_claim_conforms_only_as_the_claimants_resignation() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 400)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let win_on_time = claim(CONCLUSION, FIRST, Status::Timeout, Outcome3::FirstWins);
assert!(!conforms(&p, &plies, &atts, &win_on_time));
let resignation = claim(CONCLUSION, FIRST, Status::Resignation, Outcome3::SecondWins);
assert!(conforms(&p, &plies, &atts, &resignation));
let later = [att(101, 1, 100), att(171, CONCLUSION, 1000)];
assert!(conforms(&p, &plies, &later, &win_on_time));
assert!(!conforms(&p, &plies, &later, &resignation));
}
#[test]
fn select_conclusion_earliest_conforming_timed() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a8\",null]")];
let p = params("7k^/6pp/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let conclusions = [
claim(170, FIRST, Status::Checkmate, Outcome3::FirstWins),
claim(172, SECOND, Status::Checkmate, Outcome3::FirstWins),
claim(174, SECOND, Status::Resignation, Outcome3::FirstWins),
Conclusion::new(
eid(175),
pk(FIRST),
eid(51),
verdict(Status::Checkmate, Outcome3::FirstWins),
ts(0),
),
claim(176, FIRST, Status::Checkmate, Outcome3::FirstWins),
];
let atts = [
att(101, 1, 100),
att(201, 170, 300),
att(202, 172, 200),
att(203, 174, 150),
att(204, 175, 100),
];
let selected = select_conclusion(&p, &plies, &atts, &conclusions)
.expect("consistent")
.expect("a conclusion rules");
assert_eq!(*selected.conclusion.id.as_bytes(), [172; 32]);
assert_eq!(selected.cutoff, ts(200));
assert_eq!(selected.verdict.status(), Status::Checkmate);
let tied = [
claim(180, FIRST, Status::Checkmate, Outcome3::FirstWins),
claim(178, SECOND, Status::Checkmate, Outcome3::FirstWins),
];
let tied_atts = [att(101, 1, 100), att(211, 180, 500), att(212, 178, 500)];
let selected = select_conclusion(&p, &plies, &tied_atts, &tied)
.expect("consistent")
.expect("a conclusion rules");
assert_eq!(*selected.conclusion.id.as_bytes(), [178; 32]);
assert_eq!(
select_conclusion(&p, &plies, &atts, &conclusions[2..]),
Ok(None)
);
let empty: [Conclusion; 0] = [];
assert_eq!(select_conclusion(&p, &plies, &atts, &empty), Ok(None));
}
#[test]
fn two_conforming_conclusions_with_different_verdicts_the_earliest_rules() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let premature = claim(172, SECOND, Status::Resignation, Outcome3::FirstWins);
let on_time = claim(170, FIRST, Status::Timeout, Outcome3::FirstWins);
let atts = [att(101, 1, 100), att(202, 172, 400), att(201, 170, 1000)];
assert!(conforms(&p, &plies, &atts, &premature));
assert!(conforms(&p, &plies, &atts, &on_time));
let offered = [on_time, premature];
let selected = select_conclusion(&p, &plies, &atts, &offered)
.expect("consistent")
.expect("a conclusion rules");
assert_eq!(*selected.conclusion.id.as_bytes(), [172; 32]);
assert_eq!(selected.cutoff, ts(400));
assert_eq!(selected.verdict.status(), Status::Resignation);
assert_eq!(selected.verdict.outcome(), Outcome3::FirstWins);
}
#[test]
fn score_matches_the_point_split_for_every_outcome() {
use sashite_sanki_engine::domain::side::Side;
let mate = params("7k^/6pp/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let mating = [ply(1, FIRST, 1, "[\"a1\",\"a8\",null]")];
let mate_atts = [att(101, 1, 100), att(171, CONCLUSION, 300)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let none: [Ply; 0] = [];
let empty_atts = [att(171, CONCLUSION, 300)];
let offer = [ply_draw(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let offer_atts = [att(101, 1, 100), att(171, CONCLUSION, 300)];
let cases = [
(
expected_verdict(&mate, &mating, &mate_atts, &conclusion(SECOND)),
Outcome3::FirstWins,
),
(
expected_verdict(&p, &none, &empty_atts, &conclusion(FIRST)),
Outcome3::SecondWins,
),
(
expected_verdict(&p, &offer, &offer_atts, &conclusion(SECOND)),
Outcome3::Draw,
),
];
for (result, expected) in cases {
let result = result.expect("a result");
assert_eq!(result.outcome(), expected);
let (first, second) = expected.points();
assert_eq!(result.score(Side::First), first, "{expected:?} first");
assert_eq!(result.score(Side::Second), second, "{expected:?} second");
assert_eq!(
u16::from(result.score(Side::First)) + u16::from(result.score(Side::Second)),
100,
"{expected:?}: the split must total 100"
);
}
}
#[test]
fn abandonment_span_too_wide_saturates_instead_of_pardoning() {
let plies: [Ply; 0] = [];
let atts = [att(171, CONCLUSION, i64::MAX)];
let fits = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let adj = expected_verdict(&fits, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), Status::Timeout);
assert_eq!(adj.outcome(), Outcome3::SecondWins);
let overflows = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, -1);
assert_eq!(
ts(i64::MAX).duration_since(ts(-1)),
None,
"the span must be the one that overflows"
);
let adj = expected_verdict(&overflows, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), Status::Timeout);
assert_eq!(adj.outcome(), Outcome3::SecondWins);
}
#[test]
fn a_conclusion_timed_before_t0_concludes_nothing() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 1100), att(171, CONCLUSION, 500)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 1000);
assert_eq!(
expected_verdict(&p, &plies, &atts, &conclusion(SECOND)),
Err(NoVerdict::BeforeStart)
);
assert!(!NoVerdict::BeforeStart.is_transient());
assert!(!conforms(
&p,
&plies,
&atts,
&claim(CONCLUSION, SECOND, Status::Resignation, Outcome3::FirstWins)
));
assert_eq!(
verdict_at(&p, &plies, &atts, Side::Second, ts(500)),
Err(NoVerdict::BeforeStart)
);
let at_start = [att(101, 1, 1100), att(171, CONCLUSION, 1000)];
let r = expected_verdict(&p, &plies, &at_start, &conclusion(SECOND)).expect("in reach");
assert_eq!(r.status(), Status::Resignation);
assert_eq!(r.outcome(), Outcome3::FirstWins);
}
#[test]
fn empty_chain_abandonment_charges_the_player_who_never_moved() {
let plies: [Ply; 0] = [];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let atts = [att(171, CONCLUSION, 600)];
let adj = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), Status::Resignation);
let atts = [att(171, CONCLUSION, 601)];
for invoker in [FIRST, SECOND] {
let adj = expected_verdict(&p, &plies, &atts, &conclusion(invoker)).expect("result");
assert_eq!(adj.status(), Status::Timeout);
assert_eq!(adj.outcome(), Outcome3::SecondWins);
}
}
#[test]
fn abandonment_spends_the_overtime_periods() {
let main = Period::new(Duration::from_secs(600), None, None).expect("period");
let overtime = Period::new(
Duration::from_secs(0),
Some(Duration::from_secs(30)),
Some(1),
)
.expect("period");
let plies: [Ply; 0] = [];
for (cutoff, expected) in [(630_i64, Status::Resignation), (631, Status::Timeout)] {
let p = SessionParams::new(
eid(SESSION),
Some(pk(TIMESTAMPER)),
Seats::new(pk(FIRST), pk(SECOND)).expect("distinct"),
TimeControl::new(main, vec![overtime]),
Position::parse("4k^3/8/8/8/8/8/8/R3K^3 / W/w").expect("valid FEEN"),
ts(0),
)
.expect("first to move");
let atts = [att(171, CONCLUSION, cutoff)];
let adj = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), expected, "cutoff {cutoff}");
}
}
#[test]
fn abandonment_reads_the_replayed_clock_not_the_initial_one() {
let period = Period::new(Duration::from_secs(60), Some(Duration::from_secs(30)), None)
.expect("period");
let plies = [
ply(1, FIRST, 1, "[\"a1\",\"a4\",null]"),
ply(2, SECOND, 1, "[\"e8\",\"e7\",null]"),
ply(3, FIRST, 2, "[\"a4\",\"a5\",null]"),
];
for (cutoff, expected) in [(110_i64, Status::Resignation), (111, Status::Timeout)] {
let p = SessionParams::new(
eid(SESSION),
Some(pk(TIMESTAMPER)),
Seats::new(pk(FIRST), pk(SECOND)).expect("distinct"),
TimeControl::new(period, Vec::new()),
Position::parse("4k^3/8/8/8/8/8/8/R3K^3 / W/w").expect("valid FEEN"),
ts(0),
)
.expect("first to move");
let atts = [
att(101, 1, 10),
att(102, 2, 20),
att(103, 3, 30),
att(171, CONCLUSION, cutoff),
];
let adj = expected_verdict(&p, &plies, &atts, &conclusion(FIRST)).expect("result");
assert_eq!(adj.status(), expected, "cutoff {cutoff}");
if expected == Status::Timeout {
assert_eq!(adj.outcome(), Outcome3::FirstWins);
}
}
}
#[test]
fn the_engine_turn_tracks_the_kernel_play_order() {
use crate::natural_state::{natural_state, ChainEnd};
let moves: [(u8, u8, u32, &str); 5] = [
(1, FIRST, 1, "[\"a1\",\"a4\",null]"),
(2, SECOND, 1, "[\"e8\",\"e7\",null]"),
(3, FIRST, 2, "[\"a4\",\"a5\",null]"),
(4, SECOND, 2, "[\"e7\",\"e6\",null]"),
(5, FIRST, 3, "[\"a5\",\"a6\",null]"),
];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 100_000, 0);
for prefix in 0..=moves.len() {
let plies: Vec<Ply> = moves
.iter()
.take(prefix)
.map(|&(id, signer, step, content)| ply(id, signer, step, content))
.collect();
let atts: Vec<Attestation> = moves
.iter()
.take(prefix)
.enumerate()
.map(|(i, &(id, _, _, _))| att(100_u8.wrapping_add(id), id, 100 * (i as i64 + 1)))
.collect();
let ns = natural_state(&p, &plies, &atts, ts(10_000));
assert_eq!(ns.chain.len(), prefix);
match &ns.end {
ChainEnd::Ongoing(state) => assert_eq!(
state.position().active_side(),
p.side_at(ns.next_half_move()),
"turn/play-order divergence after {prefix} half-moves"
),
ChainEnd::Terminal { .. } | ChainEnd::Inconsistent => {
panic!("the chain must stay ongoing")
}
}
}
}
#[test]
fn terminal_outranks_a_standing_draw_offer() {
let plies = [ply_draw(1, FIRST, 1, "[\"a1\",\"a8\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 300)];
let p = params("7k^/6pp/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let adj = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.status(), Status::Checkmate);
assert_eq!(adj.outcome(), Outcome3::FirstWins);
}
#[test]
fn self_timed_session_rules_on_the_events_own_created_at() {
let period = Period::new(Duration::from_secs(600), None, None).expect("period");
let p = SessionParams::new(
eid(SESSION),
None, Seats::new(pk(FIRST), pk(SECOND)).expect("distinct"),
TimeControl::new(period, Vec::new()),
Position::parse("4k^3/8/8/8/8/8/8/R3K^3 / W/w").expect("valid FEEN"),
ts(0),
)
.expect("first to move");
let plies = [Ply::new(
eid(1),
pk(FIRST),
eid(SESSION),
1,
false,
"[\"a1\",\"a4\",null]".to_owned(),
ts(100),
)];
let no_atts: [Attestation; 0] = [];
let adj =
expected_verdict(&p, &plies, &no_atts, &conclusion_at(SECOND, 400)).expect("result");
assert_eq!(adj.status(), Status::Resignation);
assert_eq!(adj.outcome(), Outcome3::FirstWins);
let adj =
expected_verdict(&p, &plies, &no_atts, &conclusion_at(SECOND, 701)).expect("result");
assert_eq!(adj.status(), Status::Timeout);
assert_eq!(adj.outcome(), Outcome3::FirstWins);
let stray = [att(171, CONCLUSION, 100_000)];
let adj =
expected_verdict(&p, &plies, &stray, &conclusion_at(SECOND, 400)).expect("result");
assert_eq!(adj.status(), Status::Resignation);
}
#[test]
fn out_of_reach_conclusions_never_resolve_as_a_resignation() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a4\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 400)];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let control = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(control.status(), Status::Resignation);
let mut near_player = [FIRST; 32];
near_player[31] = FIRST.wrapping_add(1);
for signer in [
pk(2),
pk(TIMESTAMPER),
pk(77),
PublicKey::from_bytes(near_player),
] {
let foreign = Conclusion::new(
eid(CONCLUSION),
signer,
eid(SESSION),
verdict(Status::Resignation, Outcome3::FirstWins),
ts(0),
);
assert_eq!(
expected_verdict(&p, &plies, &atts, &foreign),
Err(NoVerdict::NotAPlayer),
"signer {signer} must not obtain a result"
);
assert!(!conforms(&p, &plies, &atts, &foreign));
}
let mut near_session = [SESSION; 32];
near_session[31] = SESSION.wrapping_add(1);
for session in [eid(51), EventId::from_bytes(near_session)] {
let foreign = Conclusion::new(
eid(CONCLUSION),
pk(SECOND),
session,
verdict(Status::Resignation, Outcome3::FirstWins),
ts(0),
);
assert_eq!(
expected_verdict(&p, &plies, &atts, &foreign),
Err(NoVerdict::OtherSession)
);
}
}
#[test]
fn the_verdict_does_not_depend_on_the_input_order() {
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let plies = [
ply(1, FIRST, 1, "[\"a1\",\"a4\",null]"),
ply(2, FIRST, 1, "[\"a1\",\"a4\",null]"), ply(3, FIRST, 1, "[\"a1\",\"a5\",null]"), ply_draw(4, SECOND, 1, "[\"e8\",\"e7\",null]"), ply(5, SECOND, 1, "[\"e8\",\"e6\",null]"), ply(6, FIRST, 2, "[\"a4\",\"a5\",null]"),
];
let atts = [
att(101, 1, 100),
att(102, 2, 110),
att(103, 3, 120),
att(104, 4, 90),
att(105, 5, 95),
att(106, 6, 300),
att(171, CONCLUSION, 400),
];
let reference = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(reference.status(), Status::Resignation);
assert_eq!(reference.outcome(), Outcome3::FirstWins);
let mut seed = 0x2545_F491_4F6C_DD1D_u64;
let mut next = move || {
seed = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
(seed >> 33) as usize
};
for round in 0..64 {
let mut shuffled_plies = plies.clone();
let mut shuffled_atts = atts;
for i in (1..shuffled_plies.len()).rev() {
shuffled_plies.swap(i, next() % (i + 1));
}
for i in (1..shuffled_atts.len()).rev() {
shuffled_atts.swap(i, next() % (i + 1));
}
assert_eq!(
expected_verdict(&p, &shuffled_plies, &shuffled_atts, &conclusion(SECOND)),
Ok(reference),
"round {round}: the verdict moved with the input order"
);
}
}
#[test]
fn verdict_at_is_the_invocation_primitive() {
let mate = params("7k^/6pp/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let mating = [ply(1, FIRST, 1, "[\"a1\",\"a8\",null]")];
let atts = [att(101, 1, 100)];
for invoker in [Side::First, Side::Second] {
let r = verdict_at(&mate, &mating, &atts, invoker, ts(1000)).expect("defined");
assert_eq!(r.status(), Status::Checkmate);
assert_eq!(r.outcome(), Outcome3::FirstWins);
}
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let none: [Ply; 0] = [];
let empty: [Attestation; 0] = [];
let r = verdict_at(&p, &none, &empty, Side::First, ts(400)).expect("defined");
assert_eq!(r.status(), Status::Resignation);
assert_eq!(r.outcome(), Outcome3::SecondWins);
assert_eq!(r.score(Side::First), 0);
assert_eq!(r.score(Side::Second), 100);
let scores = r.scores(&p);
assert_eq!(scores[0], (p.player(Side::First), 0));
assert_eq!(scores[1], (p.player(Side::Second), 100));
assert_eq!(p.outcome_from_scores(scores), Some(r.outcome()));
}
#[test]
fn abandonment_is_charged_from_the_chains_last_anchor_not_a_tail_premoves_timing() {
let plies = [
ply(1, FIRST, 1, "[\"a1\",\"a4\",null]"),
ply(2, SECOND, 1, "[\"e8\",\"e7\",null]"),
];
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 0);
for (cutoff, expected) in [
(551_i64, Status::Resignation),
(600, Status::Resignation),
(601, Status::Timeout),
] {
let atts = [
att(101, 1, 100),
att(102, 2, 50),
att(171, CONCLUSION, cutoff),
];
let natural = crate::natural_state::natural_state(&p, &plies, &atts, ts(cutoff));
assert_eq!(
natural.chain.len(),
2,
"cutoff {cutoff}: the premove is applied"
);
let r = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("in reach");
assert_eq!(r.status(), expected, "cutoff {cutoff}");
if expected == Status::Timeout {
assert_eq!(r.outcome(), Outcome3::SecondWins, "against first, on move");
}
}
}
#[test]
fn a_played_ply_timeout_outranks_the_mate_it_delivers() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a8\",null]")];
let atts = [att(101, 1, 700), att(171, CONCLUSION, 1000)];
let p = params("7k^/6pp/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let r = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("in reach");
assert_eq!(r.status(), Status::Timeout);
assert_eq!(r.outcome(), Outcome3::SecondWins);
let atts = [att(101, 1, 600), att(171, CONCLUSION, 1000)];
let r = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("in reach");
assert_eq!(r.status(), Status::Checkmate);
assert_eq!(r.outcome(), Outcome3::FirstWins);
}
#[test]
fn select_conclusion_in_self_timed_mode_reads_the_events_own_timing() {
let period = Period::new(Duration::from_secs(600), None, None).expect("period");
let p = SessionParams::new(
eid(SESSION),
None,
Seats::new(pk(FIRST), pk(SECOND)).expect("distinct"),
TimeControl::new(period, Vec::new()),
Position::parse("4k^3/8/8/8/8/8/8/R3K^3 / W/w").expect("valid FEEN"),
ts(0),
)
.expect("first to move");
let plies = [Ply::new(
eid(1),
pk(FIRST),
eid(SESSION),
1,
false,
"[\"a1\",\"a4\",null]".to_owned(),
ts(100),
)];
let no_atts: [Attestation; 0] = [];
let resignation = Conclusion::new(
eid(172),
pk(FIRST),
eid(SESSION),
verdict(Status::Resignation, Outcome3::SecondWins),
ts(400),
);
let on_time = Conclusion::new(
eid(170),
pk(FIRST),
eid(SESSION),
verdict(Status::Timeout, Outcome3::FirstWins),
ts(1000),
);
let stray = [att(171, 172, 5000)];
for atts in [&no_atts[..], &stray[..]] {
let offered = [on_time, resignation];
let selected = select_conclusion(&p, &plies, atts, &offered)
.expect("consistent")
.expect("rules");
assert_eq!(selected.conclusion.id, eid(172));
assert_eq!(selected.cutoff, ts(400));
assert_eq!(selected.verdict.status(), Status::Resignation);
}
}
#[test]
fn score_per_side() {
let plies = [ply(1, FIRST, 1, "[\"a1\",\"a8\",null]")];
let atts = [att(101, 1, 100), att(171, CONCLUSION, 1000)];
let p = params("7k^/6pp/8/8/8/8/8/R3K^3 / W/w", 600, 0);
let adj = expected_verdict(&p, &plies, &atts, &conclusion(SECOND)).expect("result");
assert_eq!(adj.score(Side::First), 100);
assert_eq!(adj.score(Side::Second), 0);
}
#[test]
fn a_verdict_is_a_coherent_status_outcome_pair() {
use sashite_sanki_engine::domain::status::ResultKind;
for status in Status::ALL {
for outcome in [Outcome3::FirstWins, Outcome3::Draw, Outcome3::SecondWins] {
let coherent = match status.result_kind() {
ResultKind::Draw => outcome == Outcome3::Draw,
ResultKind::Decisive => outcome != Outcome3::Draw,
};
let built = Verdict::new(status, outcome);
assert_eq!(built.is_some(), coherent, "{status:?} / {outcome:?}");
if let Some(v) = built {
assert_eq!(v.status(), status);
assert_eq!(v.outcome(), outcome);
let (first, second) = outcome.points();
assert_eq!(
(v.score(Side::First), v.score(Side::Second)),
(first, second)
);
}
}
}
assert_eq!(
Verdict::AGREEMENT,
verdict(Status::Agreement, Outcome3::Draw)
);
assert_eq!(
Verdict::timeout_against(Side::First),
verdict(Status::Timeout, Outcome3::SecondWins)
);
assert_eq!(
Verdict::resignation_by(Side::Second),
verdict(Status::Resignation, Outcome3::FirstWins)
);
}
#[test]
fn cutoff_of_reports_the_first_reason_that_holds() {
use super::cutoff_of;
let p = params("4k^3/8/8/8/8/8/8/R3K^3 / W/w", 600, 1000);
let at = |id: u8, signer: u8, session: u8| {
Conclusion::new(
eid(id),
pk(signer),
eid(session),
verdict(Status::Resignation, Outcome3::FirstWins),
ts(0),
)
};
let no_atts: [Attestation; 0] = [];
let early = [att(171, CONCLUSION, 500)];
let timed = [att(171, CONCLUSION, 1500)];
assert_eq!(
cutoff_of(&p, &no_atts, &at(CONCLUSION, 77, 51)),
Err(NoVerdict::OtherSession)
);
assert_eq!(
cutoff_of(&p, &no_atts, &at(CONCLUSION, 77, SESSION)),
Err(NoVerdict::NotAPlayer)
);
assert_eq!(
cutoff_of(&p, &early, &at(CONCLUSION, 77, SESSION)),
Err(NoVerdict::NotAPlayer)
);
assert_eq!(
cutoff_of(&p, &no_atts, &at(CONCLUSION, SECOND, SESSION)),
Err(NoVerdict::Pending)
);
assert_eq!(
cutoff_of(&p, &early, &at(CONCLUSION, SECOND, SESSION)),
Err(NoVerdict::BeforeStart)
);
assert_eq!(
cutoff_of(&p, &timed, &at(CONCLUSION, SECOND, SESSION)),
Ok((Side::Second, ts(1500)))
);
}
}