pixtuoid 0.4.0

Terminal pixel-art office for AI coding agents
Documentation
use std::time::SystemTime;

use crate::tui::layout::Point;

/// Duration (ms) the pet stays frozen in place after being petted.
pub const PET_DURATION_MS: u64 = 2000;

/// State for the "pet the animal" interaction. Lives on `TuiRenderer`
/// (render-side only) — petting is a local visual effect, not a data
/// model concern. Same pattern as `mouse_pos` and `pinned_agent`.
pub struct PetState {
    pub petted_at: SystemTime,
    pub pet_pos: Point,
    pub kind: PetKind,
    pub floor_idx: usize,
}

impl PetState {
    pub fn is_active(&self, now: SystemTime) -> bool {
        now.duration_since(self.petted_at)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(PET_DURATION_MS + 1)
            < PET_DURATION_MS
    }

    pub fn elapsed_ms(&self, now: SystemTime) -> u64 {
        now.duration_since(self.petted_at)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PetKind {
    Cat,
    Dog,
}

impl PetKind {
    pub const ALL: &'static [PetKind] = &[PetKind::Cat, PetKind::Dog];

    pub fn config_name(self) -> &'static str {
        match self {
            PetKind::Cat => "cat",
            PetKind::Dog => "dog",
        }
    }

    pub fn from_config_name(s: &str) -> Option<Self> {
        match s {
            "cat" => Some(PetKind::Cat),
            "dog" => Some(PetKind::Dog),
            _ => None,
        }
    }

    pub fn walk_anim(self) -> &'static str {
        match self {
            PetKind::Cat => "cat_walk",
            PetKind::Dog => "dog_walk",
        }
    }

    pub fn sit_anim(self) -> &'static str {
        match self {
            PetKind::Cat => "cat_sit",
            PetKind::Dog => "dog_sit",
        }
    }

    pub fn sleep_anim(self) -> &'static str {
        match self {
            PetKind::Cat => "cat_sleep",
            PetKind::Dog => "dog_sleep",
        }
    }

    pub fn sleeps_near_idle(self) -> bool {
        match self {
            PetKind::Cat => true,
            PetKind::Dog => false,
        }
    }

    pub fn hitbox(self, anim_name: &str) -> (u16, u16) {
        if anim_name == self.walk_anim() {
            (8, 6)
        } else if anim_name == self.sleep_anim() {
            (6, 4)
        } else {
            (6, 6)
        }
    }
}

pub fn select_pet_for_floor(floor_seed: u64, enabled_pets: &[PetKind]) -> Option<PetKind> {
    if enabled_pets.is_empty() {
        return None;
    }
    Some(enabled_pets[(floor_seed as usize) % enabled_pets.len()])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn config_name_roundtrip() {
        for &kind in PetKind::ALL {
            assert_eq!(PetKind::from_config_name(kind.config_name()), Some(kind));
        }
    }

    #[test]
    fn from_config_name_unknown_returns_none() {
        assert_eq!(PetKind::from_config_name("hamster"), None);
    }

    #[test]
    fn select_pet_empty_returns_none() {
        assert_eq!(select_pet_for_floor(42, &[]), None);
    }

    #[test]
    fn select_pet_single_always_returns_it() {
        assert_eq!(select_pet_for_floor(0, &[PetKind::Dog]), Some(PetKind::Dog));
        assert_eq!(
            select_pet_for_floor(99, &[PetKind::Dog]),
            Some(PetKind::Dog)
        );
    }

    #[test]
    fn select_pet_two_pets_alternates_by_seed() {
        let pets = vec![PetKind::Cat, PetKind::Dog];
        let floor0 = select_pet_for_floor(0, &pets);
        let floor1 = select_pet_for_floor(1, &pets);
        assert_ne!(floor0, floor1);
    }

    #[test]
    fn anim_names_match_kind() {
        assert!(PetKind::Cat.walk_anim().starts_with("cat_"));
        assert!(PetKind::Dog.walk_anim().starts_with("dog_"));
    }

    #[test]
    fn dog_anim_methods() {
        assert_eq!(PetKind::Dog.walk_anim(), "dog_walk");
        assert_eq!(PetKind::Dog.sit_anim(), "dog_sit");
        assert_eq!(PetKind::Dog.sleep_anim(), "dog_sleep");
    }

    #[test]
    fn dog_does_not_sleep_near_idle() {
        assert!(!PetKind::Dog.sleeps_near_idle());
        assert!(PetKind::Cat.sleeps_near_idle());
    }

    #[test]
    fn hitbox_walk_larger_than_sit() {
        for &kind in PetKind::ALL {
            let (ww, _) = kind.hitbox(kind.walk_anim());
            let (sw, _) = kind.hitbox(kind.sit_anim());
            assert!(ww > sw, "{:?} walk should be wider than sit", kind);
        }
    }

    #[test]
    fn hitbox_sleep_shorter_than_sit() {
        for &kind in PetKind::ALL {
            let (_, sh) = kind.hitbox(kind.sit_anim());
            let (_, slh) = kind.hitbox(kind.sleep_anim());
            assert!(slh < sh, "{:?} sleep should be shorter than sit", kind);
        }
    }

    #[test]
    fn hitbox_unknown_anim_returns_default() {
        assert_eq!(PetKind::Cat.hitbox("unknown"), (6, 6));
        assert_eq!(PetKind::Dog.hitbox("unknown"), (6, 6));
    }
}