zero-trust-rps 0.0.5

Online Multiplayer Rock Paper Scissors
Documentation
use serde::{Deserialize, Serialize};
pub use string::FixedStr20 as RpsData;
use uuid::Uuid;

use super::{
    blake3::{B3Hash, B3Key, Blake3Hash},
    hex::OwnedHexStr,
    utils::hash_users,
};

type Version = u32;
// INCREASE on BREAKING CHANGE
pub const PROTOCOL_VERSION: Version = 0;

pub type CommitHash = OwnedHexStr<32, 64>;

pub type RoomId = u64;

#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum Hash {
    B3sum(B3Hash),
}

impl Hash {
    #[allow(unused)]
    pub fn get_algorithm(&self) -> &'static str {
        match self {
            Hash::B3sum(_) => "blake3",
        }
    }
}

impl AsRef<str> for Hash {
    fn as_ref(&self) -> &str {
        match self {
            Hash::B3sum(owned_hex_str) => owned_hex_str.as_ref(),
        }
    }
}

mod string {
    #![expect(clippy::partialeq_ne_impl)]
    use fixed_len_str::fixed_len_str;

    fixed_len_str!(20);

    impl FixedStr20 {
        #[allow(unused)]
        pub const LEN: usize = 20;
    }
}

impl Blake3Hash for RpsData {
    fn hash_keyed(&self, key: &B3Key, round: &Round) -> B3Hash {
        blake3::Hasher::new_keyed(key)
            .update(&round.round_id.into_original_bytes())
            .update(self.trim_end_matches('\0').as_bytes())
            .finalize()
            .into()
    }
}

#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashWithData {
    B3sum {
        data: RpsData,
        hash: B3Hash,
        key: B3Key,
    },
}

#[allow(dead_code)]
impl HashWithData {
    pub fn verify(&self, round: &Round) -> Result<(), String> {
        match self {
            HashWithData::B3sum { hash, key, data } => {
                let real_hash = data.hash_keyed(key, round);

                if &real_hash != hash {
                    Err(format!("Verifying {hash} failed. Got {real_hash}"))
                } else {
                    Ok(())
                }
            }
        }
    }

    pub fn get_data(&self) -> &str {
        match self {
            HashWithData::B3sum {
                hash: _,
                key: _,
                data,
            } => data.as_ref().trim_end_matches('\0'),
        }
    }

    pub fn get_hash(&self) -> String {
        match self {
            HashWithData::B3sum {
                hash,
                key: _,
                data: _,
            } => format!("{hash}"),
        }
    }

    pub fn as_hash(&self) -> Hash {
        match self {
            HashWithData::B3sum {
                data: _,
                hash,
                key: _,
            } => Hash::B3sum(*hash),
        }
    }
}

#[derive(Serialize, Deserialize, Debug)]
pub enum ClientMessage {
    Ping { c: u8 },
    // Pong { s: u8 },
    Join { room_id: RoomId, user: Uuid },
    Play { value: Hash, round: Round }, // client can send really big messages with this
    ConfirmPlay(HashWithData),
    RoundFinished { round_id: RoundId },
}

#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
pub struct UserMove {
    pub user: Uuid,
    pub data: HashWithData,
}

#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
pub enum UserState {
    InRoom,
    Played(Hash),
    Confirmed(HashWithData),
}

#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
pub struct User {
    pub id: Uuid,
    pub state: UserState,
}

pub type RoundId = B3Hash;

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Round {
    pub round_id: RoundId,
    pub users: Box<[Uuid]>,
}

impl Round {
    pub fn validate(&self) -> Result<(), String> {
        if !self.users.is_sorted() {
            return Err("Invalid round: users are not sorted".into());
        }
        let hash = hash_users(self.users.iter().copied());
        if hash == self.round_id {
            Ok(())
        } else {
            Err(format!(
                "Invalid round: got id {}, expected {hash}",
                self.round_id
            ))
        }
    }
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct RoomState {
    pub id: RoomId,
    pub users: Box<[User]>,
    pub round: Option<Round>,
}

#[derive(Serialize, Deserialize, Debug)]
pub enum ServerMessage {
    Hello(Version, String, Option<CommitHash>),
    // Ping { s: u8 },
    Pong { c: u8 },
    RoomUpdate { new_state: RoomState },
    Error(String),
}