use crate::verdict::Verdict;
use sashite_sanki_engine::domain::time::Timestamp;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct EventId([u8; 32]);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PublicKey([u8; 32]);
impl EventId {
#[inline]
#[must_use]
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
#[inline]
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
#[inline]
#[must_use]
pub fn parse(hex: &str) -> Option<Self> {
parse_hex32(hex).map(Self)
}
}
impl PublicKey {
#[inline]
#[must_use]
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
#[inline]
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
#[inline]
#[must_use]
pub fn parse(hex: &str) -> Option<Self> {
parse_hex32(hex).map(Self)
}
}
impl core::fmt::Display for EventId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write_hex(f, &self.0)
}
}
impl core::fmt::Display for PublicKey {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write_hex(f, &self.0)
}
}
fn parse_hex32(hex: &str) -> Option<[u8; 32]> {
if hex.len() != 64 {
return None;
}
let mut bytes = [0_u8; 32];
let mut digits = hex.chars();
for byte in bytes.iter_mut() {
let high = digits.next()?.to_digit(16)?;
let low = digits.next()?.to_digit(16)?;
*byte = u8::try_from(high.checked_mul(16)?.checked_add(low)?).ok()?;
}
Some(bytes)
}
fn write_hex(f: &mut core::fmt::Formatter<'_>, bytes: &[u8; 32]) -> core::fmt::Result {
for byte in bytes {
write!(f, "{byte:02x}")?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ply {
pub id: EventId,
pub signer: PublicKey,
pub session: EventId,
pub step: u32,
pub draw: bool,
pub content: String,
pub created_at: Timestamp,
}
impl Ply {
#[inline]
#[must_use]
pub const fn new(
id: EventId,
signer: PublicKey,
session: EventId,
step: u32,
draw: bool,
content: String,
created_at: Timestamp,
) -> Self {
Self {
id,
signer,
session,
step,
draw,
content,
created_at,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Attestation {
pub id: EventId,
pub signer: PublicKey,
pub attests: EventId,
pub created_at: Timestamp,
}
impl Attestation {
#[inline]
#[must_use]
pub const fn new(
id: EventId,
signer: PublicKey,
attests: EventId,
created_at: Timestamp,
) -> Self {
Self {
id,
signer,
attests,
created_at,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Conclusion {
pub id: EventId,
pub signer: PublicKey,
pub session: EventId,
pub claim: Verdict,
pub created_at: Timestamp,
}
impl Conclusion {
#[inline]
#[must_use]
pub const fn new(
id: EventId,
signer: PublicKey,
session: EventId,
claim: Verdict,
created_at: Timestamp,
) -> Self {
Self {
id,
signer,
session,
claim,
created_at,
}
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
use super::{Attestation, Conclusion, EventId, Ply, PublicKey};
use crate::verdict::Verdict;
use sashite_sanki_engine::domain::status::{Outcome3, Status};
use sashite_sanki_engine::domain::time::Timestamp;
#[test]
fn event_id_hex_round_trip() {
let hex = "deadbeef".repeat(8); let id = EventId::parse(&hex).expect("valid hex");
assert_eq!(id.to_string(), hex);
}
#[test]
fn event_id_ordered_by_bytes() {
let small = EventId::parse(&"0".repeat(64)).expect("valid hex");
let large = EventId::parse(&format!("{}1", "0".repeat(63))).expect("valid hex");
assert!(small < large);
}
#[test]
fn parse_hex_rejects_invalid_inputs() {
assert!(EventId::parse("too short").is_none());
assert!(EventId::parse(&"z".repeat(64)).is_none()); assert!(PublicKey::parse(&"0".repeat(63)).is_none()); }
#[test]
fn parse_accepts_uppercase_and_normalizes_to_lowercase() {
let upper = "AABBCCDD".repeat(8);
let lower = "aabbccdd".repeat(8);
let id = EventId::parse(&upper).expect("valid hex");
assert_eq!(id.to_string(), lower);
assert_eq!(id, EventId::parse(&lower).expect("valid hex"));
let mixed = "AbCdEf0123456789".repeat(4);
assert_eq!(
PublicKey::parse(&mixed),
PublicKey::parse(&mixed.to_lowercase()),
);
assert_eq!(
PublicKey::parse(&mixed).expect("valid hex").to_string(),
mixed.to_lowercase(),
);
}
#[test]
fn parse_measures_bytes_and_decodes_ascii_only() {
let multibyte = format!("{}{}", "é".repeat(2), "0".repeat(60)); assert_eq!(multibyte.len(), 64);
assert_eq!(multibyte.chars().count(), 62);
assert!(EventId::parse(&multibyte).is_none());
assert!(PublicKey::parse(&multibyte).is_none());
let arabic_indic = format!("{}{}", '\u{0663}', "0".repeat(62)); assert_eq!(arabic_indic.len(), 64);
assert!(EventId::parse(&arabic_indic).is_none());
for rejected in [
String::new(),
"0".repeat(63),
"0".repeat(65),
format!(" {}", "0".repeat(63)),
format!("{}+=", "0".repeat(62)),
format!("{}gg", "0".repeat(62)),
] {
assert!(EventId::parse(&rejected).is_none(), "{rejected:?}");
}
}
#[test]
fn event_id_order_is_the_raw_byte_order() {
let hexes = [
"00".to_owned() + &"0".repeat(62),
"0f".to_owned() + &"0".repeat(62),
"10".to_owned() + &"0".repeat(62),
"7f".to_owned() + &"0".repeat(62),
"80".to_owned() + &"0".repeat(62),
"ff".to_owned() + &"0".repeat(62),
"ff".to_owned() + &"f".repeat(62),
];
let mut by_bytes: Vec<EventId> = hexes
.iter()
.map(|hex| EventId::parse(hex).expect("valid hex"))
.collect();
by_bytes.sort();
let mut by_string = hexes.clone();
by_string.sort();
let encoded: Vec<String> = by_bytes.iter().map(ToString::to_string).collect();
assert_eq!(encoded, by_string.to_vec());
let mut high = [0_u8; 32];
high[0] = 1;
let mut low = [255_u8; 32];
low[0] = 0;
assert!(EventId::from_bytes(high) > EventId::from_bytes(low));
let mut a = [7_u8; 32];
let mut b = [7_u8; 32];
a[31] = 8;
b[31] = 9;
assert!(EventId::from_bytes(a) < EventId::from_bytes(b));
}
#[test]
fn public_key_equality() {
let a = PublicKey::from_bytes([7; 32]);
let b = PublicKey::from_bytes([7; 32]);
let c = PublicKey::from_bytes([9; 32]);
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn ply_exposes_its_fields() {
let ply = Ply::new(
EventId::from_bytes([1; 32]),
PublicKey::from_bytes([2; 32]),
EventId::from_bytes([3; 32]),
7,
true,
"[\"e2\",\"e4\",null]".to_owned(),
Timestamp::from_unix(1000),
);
assert_eq!(ply.step, 7);
assert!(ply.draw);
assert_eq!(ply.signer, PublicKey::from_bytes([2; 32]));
assert_eq!(ply.created_at, Timestamp::from_unix(1000));
}
#[test]
fn attestation_carries_the_canonical_timing() {
let attestation = Attestation::new(
EventId::from_bytes([1; 32]),
PublicKey::from_bytes([2; 32]),
EventId::from_bytes([3; 32]),
Timestamp::from_unix(1_700_000_000),
);
assert_eq!(attestation.created_at, Timestamp::from_unix(1_700_000_000));
assert_eq!(attestation.attests, EventId::from_bytes([3; 32]));
}
#[test]
fn conclusion_links_session_and_carries_its_claim() {
let claim = Verdict::new(Status::Checkmate, Outcome3::FirstWins).expect("coherent");
let conclusion = Conclusion::new(
EventId::from_bytes([1; 32]),
PublicKey::from_bytes([2; 32]),
EventId::from_bytes([4; 32]),
claim,
Timestamp::from_unix(2000),
);
assert_eq!(conclusion.session, EventId::from_bytes([4; 32]));
assert_eq!(conclusion.signer, PublicKey::from_bytes([2; 32]));
assert_eq!(conclusion.created_at, Timestamp::from_unix(2000));
assert_eq!(conclusion.claim, claim);
assert_eq!(conclusion.claim.status(), Status::Checkmate);
assert_eq!(conclusion.claim.outcome(), Outcome3::FirstWins);
}
}