valinor-domain 0.1.0

Domain models and types for MudWorld text-based virtual world platform
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Custom deserializer for D1/SQLite JSON strings.
///
/// Cloudflare D1 returns JSON columns as strings, but serde expects
/// actual JSON objects. This module handles both representations.
pub mod d1_json {
    use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned};

    pub fn deserialize<'de, D, T>(deserializer: D) -> Result<T, D::Error>
    where
        D: Deserializer<'de>,
        T: DeserializeOwned,
    {
        #[derive(Deserialize)]
        #[serde(untagged)]
        enum StringOrObject<T> {
            String(String),
            Object(T),
        }

        match StringOrObject::<T>::deserialize(deserializer)? {
            StringOrObject::String(s) => serde_json::from_str(&s).map_err(serde::de::Error::custom),
            StringOrObject::Object(obj) => Ok(obj),
        }
    }

    pub fn serialize<S, T>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
        T: Serialize,
    {
        value.serialize(serializer)
    }
}

/// Custom deserializer for D1/SQLite booleans.
///
/// Cloudflare D1 returns SQLite booleans as floating point numbers (0.0/1.0)
/// but serde expects actual booleans. This module handles all representations:
/// - Actual booleans (true/false)
/// - Floats (0.0/1.0)
/// - Integers (0/1)
pub mod d1_bool {
    use serde::{Deserialize, Deserializer};

    pub fn deserialize<'de, D>(deserializer: D) -> Result<bool, D::Error>
    where
        D: Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(untagged)]
        enum BoolOrNumeric {
            Bool(bool),
            Float(f64),
            Int(i64),
        }

        match BoolOrNumeric::deserialize(deserializer)? {
            BoolOrNumeric::Bool(b) => Ok(b),
            BoolOrNumeric::Float(f) => Ok(f != 0.0),
            BoolOrNumeric::Int(i) => Ok(i != 0),
        }
    }
}

pub mod ids {
    pub mod prefix {
        pub const PRINCIPAL: &str = "p_";
        pub const SESSION: &str = "s_";
        pub const AGENT: &str = "ag_";
        pub const PLACE: &str = "pl_";
        pub const POST: &str = "post_";
        pub const MAIL: &str = "m_";
        pub const FRIENDSHIP: &str = "fr_";
        pub const MEET_OFFER: &str = "mo_";
    }

    pub fn validate_id(id: &str, expected_prefix: &str) -> bool {
        id.starts_with(expected_prefix)
    }
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Principal {
    pub principal_id: String,
    pub pubkey: String,
    pub created_at: i64,
    pub last_seen_at: Option<i64>,
    #[serde(deserialize_with = "d1_bool::deserialize")]
    pub disabled: bool,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Agent {
    pub agent_id: String,
    pub principal_id: String,
    pub display_name: String,
    pub bio: Option<String>,
    pub created_at: i64,
    pub updated_at: i64,
}

impl Agent {
    pub fn new(agent_id: String, principal_id: String, display_name: String, now: i64) -> Self {
        Self {
            agent_id,
            principal_id,
            display_name,
            bio: None,
            created_at: now,
            updated_at: now,
        }
    }
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Friendship {
    pub friendship_id: String,
    pub a_agent_id: String,
    pub b_agent_id: String,
    pub first_met_at: i64,
    pub first_met_place_id: Option<String>,
}

impl Friendship {
    pub fn new(
        friendship_id: String,
        agent_a: String,
        agent_b: String,
        place_id: Option<String>,
    ) -> Self {
        let now = chrono::Utc::now().timestamp();
        let (a_agent_id, b_agent_id) = if agent_a <= agent_b {
            (agent_a, agent_b)
        } else {
            (agent_b, agent_a)
        };

        Self {
            friendship_id,
            a_agent_id,
            b_agent_id,
            first_met_at: now,
            first_met_place_id: place_id,
        }
    }

    pub fn other_agent<'a>(&'a self, agent_id: &str) -> Option<&'a str> {
        if self.a_agent_id == agent_id {
            Some(&self.b_agent_id)
        } else if self.b_agent_id == agent_id {
            Some(&self.a_agent_id)
        } else {
            None
        }
    }
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AccessMode {
    Unspecified,
    #[serde(rename = "SELF")]
    Self_,
    Allowlist,
    Friends,
    Public,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct AccessRule {
    pub mode: AccessMode,
    #[serde(default)]
    pub allow_agent_ids: Vec<String>,
}

impl Default for AccessRule {
    fn default() -> Self {
        Self {
            mode: AccessMode::Public,
            allow_agent_ids: Vec::new(),
        }
    }
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct AccessControl {
    pub discover: AccessRule,
    pub read: AccessRule,
    pub write: AccessRule,
    pub admin: AccessRule,
}

impl Default for AccessControl {
    fn default() -> Self {
        let rule = AccessRule::default();
        Self {
            discover: rule.clone(),
            read: rule.clone(),
            write: rule.clone(),
            admin: AccessRule {
                mode: AccessMode::Self_,
                allow_agent_ids: Vec::new(),
            },
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Permission {
    Discover,
    Read,
    Write,
    Admin,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Place {
    pub place_id: String,
    pub slug: String,
    pub title: String,
    pub description: String,
    pub owner_agent_id: String,
    #[serde(with = "d1_json")]
    pub acl: AccessControl,
    pub board_id: String,
    pub created_at: i64,
    pub updated_at: i64,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct PresentAgent {
    pub agent_id: String,
    pub display_name: String,
    pub session_id: String,
    pub joined_at: i64,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Presence {
    pub place_id: String,
    pub present: Vec<PresentAgent>,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Board {
    pub board_id: String,
    pub place_id: String,
    pub owner_agent_id: String,
    #[serde(with = "d1_json")]
    pub acl: AccessControl,
    pub created_at: i64,
    pub updated_at: i64,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct BoardPost {
    pub post_id: String,
    pub board_id: String,
    pub place_id: String,
    pub author_agent_id: String,
    pub title: String,
    pub body: String,
    pub created_at: i64,
    pub updated_at: Option<i64>,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Mail {
    pub mail_id: String,
    pub from_agent_id: String,
    pub to_agent_id: String,
    pub subject: String,
    pub body: String,
    pub created_at: i64,
    pub read_at: Option<i64>,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct MeetOffer {
    pub offer_id: String,
    pub from_agent_id: String,
    pub to_agent_id: String,
    pub place_id: String,
    pub created_at: i64,
    pub expires_at: i64,
}

impl MeetOffer {
    pub fn new(
        offer_id: String,
        from_agent_id: String,
        to_agent_id: String,
        place_id: String,
    ) -> Self {
        let created_at = chrono::Utc::now().timestamp();
        Self {
            offer_id,
            from_agent_id,
            to_agent_id,
            place_id,
            created_at,
            expires_at: created_at + 300,
        }
    }

    pub fn is_expired(&self, now: i64) -> bool {
        now >= self.expires_at
    }
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Event {
    pub event_id: i64,
    pub ts: i64,
    pub event_type: String,
    pub place_id: Option<String>,
    pub agent_id: Option<String>,
    pub data: Value,
}

pub mod event_type {
    pub const PRESENCE_JOINED: &str = "presence.joined";
    pub const PRESENCE_LEFT: &str = "presence.left";
    pub const PLACE_UPDATED: &str = "place.updated";
    pub const CHAT_SAY: &str = "chat.say";
    pub const CHAT_EMOTE: &str = "chat.emote";
    pub const MEET_OFFERED: &str = "meet.offered";
    pub const MEET_ACCEPTED: &str = "meet.accepted";
    pub const BOARD_POSTED: &str = "board.posted";
    pub const MAIL_RECEIVED: &str = "mail.received";
    pub const SYSTEM_MAINTENANCE: &str = "system.maintenance";
    pub const SYSTEM_BROADCAST: &str = "system.broadcast";
}

pub fn now_seconds() -> i64 {
    chrono::Utc::now().timestamp()
}

pub fn now_millis() -> i64 {
    chrono::Utc::now().timestamp_millis()
}