//! # pet-life //! //! The one-life mechanic behind the menu-bar face. A pet ages, neglect kills //! it, and death is permanent for that pet — a new one is born when you come //! back, and the old one is remembered. //! //! ## Everything derives from timestamps //! //! Nothing here accumulates or ticks. Death happens at `last_activity + //! [`LIFESPAN_MS`]`, not when a program noticed, so the answer is identical //! whether you look an hour later or a month later. That makes sleep, reboots, //! daemon restarts and the app simply not running irrelevant by construction //! rather than by handling each case. use std::path::{Path, PathBuf}; /// Quiet for this long and the pet is dead. pub const LIFESPAN_MS: u64 = 7 * 86_400_000; /// Stage thresholds: quiet-time at which each begins. const RESTLESS_MS: u64 = 86_400_000; const HUNGRY_MS: u64 = 2 * 86_400_000; const SICK_MS: u64 = 7 * 86_400_000 / 2; const DYING_MS: u64 = 5 * 86_400_000; /// Names for new pets. Deterministic from the birth timestamp: a cemetery of /// "Mochi, 9 days" means something, one of "generation 3" does not. const NAMES: &[&str] = &[ "Mochi", "Bean", "Pip", "Waffle", "Sprout", "Tofu", "Biscuit", "Clover", "Pickle", "Marble", "Ash", "Juniper", "Olive", "Peanut", "Sage", "Fig", "Barley", "Cricket", "Dill", "Ember", "Fennel", "Gus", "Hazel", "Indigo", ]; /// How far a pet has declined. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Stage { Content, Restless, Hungry, Sick, Dying, Dead, } impl Stage { /// The stage for a pet that has been unattended for `quiet_ms`. pub fn at(quiet_ms: u64) -> Stage { match quiet_ms { q if q >= LIFESPAN_MS => Stage::Dead, q if q >= DYING_MS => Stage::Dying, q if q >= SICK_MS => Stage::Sick, q if q >= HUNGRY_MS => Stage::Hungry, q if q >= RESTLESS_MS => Stage::Restless, _ => Stage::Content, } } pub fn name(self) -> &'static str { match self { Stage::Content => "content", Stage::Restless => "restless", Stage::Hungry => "hungry", Stage::Sick => "sick", Stage::Dying => "dying", Stage::Dead => "dead", } } /// The glyph the menu bar shows. pub fn face(self) -> &'static str { match self { Stage::Content => "(^ω^)", Stage::Restless => "(・_・)", Stage::Hungry => "(っ_-)", Stage::Sick => "(×_×)", Stage::Dying => "(x_x)", Stage::Dead => "†", } } } /// A pet that died, kept forever. #[derive(Debug, Clone, PartialEq)] pub struct Grave { pub name: String, pub generation: u32, pub born_ms: u64, pub died_ms: u64, } impl Grave { pub fn lived_days(&self) -> f64 { self.died_ms.saturating_sub(self.born_ms) as f64 / 86_400_000.0 } } /// A pet currently alive. #[derive(Debug, Clone, PartialEq)] pub struct Pet { pub name: String, pub generation: u32, pub born_ms: u64, } /// Everything persisted between runs. #[derive(Debug, Clone, Default, PartialEq)] pub struct State { pub pet: Option, pub cemetery: Vec, /// The latest activity ever observed. The history store only reaches back /// as far as its retention, so this remembers what it has forgotten. pub last_activity_ms: u64, } /// The pet as it stands right now. #[derive(Debug, Clone, PartialEq)] pub struct Snapshot { pub pet: Option, pub stage: Stage, pub quiet_ms: u64, pub age_ms: u64, pub cemetery: Vec, } /// Name a pet born at `born_ms`. Deterministic, so the same birth always /// produces the same name and a rerun cannot rename the dead. pub fn name_for(born_ms: u64, generation: u32) -> String { // Mixing the generation in keeps two pets born in the same millisecond — // only reachable in tests — from sharing a name. let idx = (born_ms / 1000).wrapping_add(generation as u64) as usize % NAMES.len(); NAMES[idx].to_string() } /// Advance `state` to `now_ms` given the latest activity, and return what to /// display. Pure: the caller decides whether to persist the result. /// /// `observed_activity_ms` is the later of the history store's answer and what /// was already persisted, so a stretch with the app closed does not starve a /// pet that was being fed the whole time. pub fn advance(mut state: State, observed_activity_ms: u64, now_ms: u64) -> (State, Snapshot) { state.last_activity_ms = state.last_activity_ms.max(observed_activity_ms); let last = state.last_activity_ms; // A pet that ran out of time is buried at the moment it ran out, not when // a program got around to looking. if let Some(pet) = state.pet.clone() { let quiet = now_ms.saturating_sub(last.max(pet.born_ms)); if quiet >= LIFESPAN_MS { let died_ms = last.max(pet.born_ms) + LIFESPAN_MS; state.cemetery.push(Grave { name: pet.name, generation: pet.generation, born_ms: pet.born_ms, died_ms, }); state.pet = None; } } // Birth needs someone to come back. Being born automatically would turn a // fortnight away into a chain of pets born, never fed and dead — a // cemetery full of lives that did not happen. if state.pet.is_none() { let buried_at = state.cemetery.last().map(|g| g.died_ms).unwrap_or(0); if last > buried_at { let generation = state.cemetery.len() as u32 + 1; state.pet = Some(Pet { name: name_for(last, generation), generation, born_ms: last, }); } } let snapshot = match &state.pet { Some(pet) => { let quiet = now_ms.saturating_sub(last.max(pet.born_ms)); Snapshot { pet: Some(pet.clone()), stage: Stage::at(quiet), quiet_ms: quiet, age_ms: now_ms.saturating_sub(pet.born_ms), cemetery: state.cemetery.clone(), } } None => Snapshot { pet: None, stage: Stage::Dead, quiet_ms: now_ms.saturating_sub(last), age_ms: 0, cemetery: state.cemetery.clone(), }, }; (state, snapshot) } /// `$XDG_DATA_HOME/ambient-companions/pet.state`, else /// `$HOME/.local/share/ambient-companions/pet.state`. /// /// Deliberately not beside the daemon's socket and database. Those derive from /// `$XDG_RUNTIME_DIR` when it is set, which is a tmpfs wiped every reboot. A /// graveyard a reboot can erase is not a graveyard. Data, not runtime. pub fn state_path_from(xdg_data_home: Option<&str>, home: Option<&str>) -> PathBuf { match xdg_data_home { Some(dir) => PathBuf::from(dir).join("ambient-companions/pet.state"), None => PathBuf::from(home.unwrap_or(".")).join(".local/share/ambient-companions/pet.state"), } } pub fn default_state_path() -> PathBuf { state_path_from( std::env::var("XDG_DATA_HOME").ok().as_deref(), std::env::var("HOME").ok().as_deref(), ) } /// Write `state` to `path` via a temporary file and a rename, so a crash /// part-way through cannot leave a half-written cemetery behind. pub fn save(path: &Path, state: &State) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let tmp = path.with_extension("state.tmp"); std::fs::write(&tmp, serialise(state))?; std::fs::rename(&tmp, path) } /// Read the state, or a fresh one if there is nothing there yet. A file that /// cannot be parsed is treated as absent rather than fatal: losing a cemetery /// is sad, refusing to draw a menu bar is worse. pub fn load(path: &Path) -> State { std::fs::read_to_string(path) .ok() .map(|text| deserialise(&text)) .unwrap_or_default() } // --- persistence format --- // // Line-based rather than JSON. The file is written and read only here, so a // hand-rolled JSON *parser* would be all risk and no benefit — the project // already stores the terminal spool as plain whitespace-separated records for // the same reason. JSON is still what the app is handed; that direction only // needs emitting. // // last_activity // pet // grave // // Names come from a fixed list with no whitespace, so splitting is safe. /// Render `state` in the on-disk format. pub fn serialise(state: &State) -> String { let mut out = format!("last_activity {}\n", state.last_activity_ms); if let Some(p) = &state.pet { out.push_str(&format!("pet {} {} {}\n", p.generation, p.born_ms, p.name)); } for g in &state.cemetery { out.push_str(&format!( "grave {} {} {} {}\n", g.generation, g.born_ms, g.died_ms, g.name )); } out } /// Parse the on-disk format. Unreadable lines are skipped rather than fatal: /// losing part of a cemetery is sad, refusing to draw a menu bar is worse. pub fn deserialise(text: &str) -> State { let mut state = State::default(); for line in text.lines() { let f: Vec<&str> = line.split_whitespace().collect(); match f.as_slice() { ["last_activity", ms] => { state.last_activity_ms = ms.parse().unwrap_or(0); } ["pet", gen, born, name] => { if let (Ok(generation), Ok(born_ms)) = (gen.parse(), born.parse()) { state.pet = Some(Pet { name: (*name).to_string(), generation, born_ms, }); } } ["grave", gen, born, died, name] => { if let (Ok(generation), Ok(born_ms), Ok(died_ms)) = (gen.parse(), born.parse(), died.parse()) { state.cemetery.push(Grave { name: (*name).to_string(), generation, born_ms, died_ms, }); } } _ => {} } } state } /// The contract handed to the menu-bar app. Emitting only — nothing reads this /// back in Rust. /// /// Every value is a number, a boolean, a stage name, or a pet name from a /// fixed list, so there is no input needing escaping and no reason to take on /// a serialisation dependency for it. pub fn to_json(s: &Snapshot) -> String { let days = |ms: u64| ms as f64 / 86_400_000.0; let graves: Vec = s .cemetery .iter() .map(|g| { format!( r#"{{"name":"{}","generation":{},"lived_days":{:.2}}}"#, g.name, g.generation, g.lived_days() ) }) .collect(); let (name, generation) = match &s.pet { Some(p) => (format!(r#""{}""#, p.name), p.generation.to_string()), None => ("null".to_string(), "null".to_string()), }; format!( r#"{{"alive":{},"name":{},"generation":{},"stage":"{}","face":"{}","age_days":{:.2},"quiet_days":{:.2},"cemetery":[{}]}}"#, s.pet.is_some(), name, generation, s.stage.name(), s.stage.face(), days(s.age_ms), days(s.quiet_ms), graves.join(",") ) } #[cfg(test)] mod tests { use super::*; const DAY: u64 = 86_400_000; /// An arbitrary but fixed "now", so nothing here depends on the clock. const T0: u64 = 1_700_000_000_000; /// A pet born at T0 that has just been fed. fn living() -> State { let (state, _) = advance(State::default(), T0, T0); state } #[test] fn stages_step_through_the_week() { for (quiet, expected) in [ (0, Stage::Content), (DAY - 1, Stage::Content), (DAY, Stage::Restless), (2 * DAY, Stage::Hungry), (7 * DAY / 2, Stage::Sick), (5 * DAY, Stage::Dying), (7 * DAY - 1, Stage::Dying), (7 * DAY, Stage::Dead), (90 * DAY, Stage::Dead), ] { assert_eq!(Stage::at(quiet), expected, "quiet for {quiet}ms"); } } #[test] fn activity_resets_the_decline() { let state = living(); let (state, snap) = advance(state, T0, T0 + 3 * DAY); assert_eq!(snap.stage, Stage::Hungry); let (_, snap) = advance(state, T0 + 3 * DAY, T0 + 3 * DAY); assert_eq!(snap.stage, Stage::Content, "showing up restores it"); } /// Death is derived, so when you look does not change what happened. #[test] fn death_is_recorded_at_the_moment_it_ran_out() { let (state, _) = advance(living(), T0, T0 + 30 * DAY); assert_eq!(state.cemetery.len(), 1); assert_eq!( state.cemetery[0].died_ms, T0 + 7 * DAY, "died when the week ran out, not when observed" ); assert_eq!(state.cemetery[0].lived_days(), 7.0); } /// The reason birth needs activity: otherwise a fortnight away breeds a /// chain of pets that were born, never fed, and died. #[test] fn a_long_absence_leaves_one_grave_not_a_chain() { let (state, snap) = advance(living(), T0, T0 + 60 * DAY); assert_eq!(state.cemetery.len(), 1, "one death, not eight"); assert!(state.pet.is_none(), "nothing is born while you are away"); assert_eq!(snap.stage, Stage::Dead); } #[test] fn coming_back_starts_a_new_pet() { let (state, _) = advance(living(), T0, T0 + 30 * DAY); let returned = T0 + 30 * DAY; let (state, snap) = advance(state, returned, returned); let pet = snap.pet.expect("a new pet"); assert_eq!(pet.generation, 2); assert_eq!(snap.stage, Stage::Content); assert_eq!(state.cemetery.len(), 1, "the first is still remembered"); assert_ne!(state.cemetery[0].name, pet.name, "and it is a different pet"); } #[test] fn the_cemetery_accumulates_across_generations() { let mut state = living(); let mut t = T0; for expected in 1..=3 { t += 30 * DAY; let (s, _) = advance(state, t - 30 * DAY, t); // die let (s, _) = advance(s, t, t); // and be replaced state = s; assert_eq!(state.cemetery.len(), expected); assert_eq!(state.pet.as_ref().unwrap().generation, expected as u32 + 1); } } /// The history store only reaches back as far as its retention, so a /// remembered timestamp has to win over an older one. #[test] fn persisted_activity_outranks_a_forgotten_history() { let state = State { last_activity_ms: T0 + 5 * DAY, ..living() }; // History has been pruned and answers with something older. let (state, snap) = advance(state, T0, T0 + 5 * DAY); assert_eq!(state.last_activity_ms, T0 + 5 * DAY); assert_eq!(snap.stage, Stage::Content, "not starved by a pruned table"); } #[test] fn a_name_is_stable_for_a_given_birth() { assert_eq!(name_for(T0, 1), name_for(T0, 1)); assert_ne!(name_for(T0, 1), name_for(T0 + 60_000, 1)); } #[test] fn state_round_trips_through_the_file_format() { let (state, _) = advance(living(), T0, T0 + 30 * DAY); let (state, _) = advance(state, T0 + 30 * DAY, T0 + 30 * DAY); assert_eq!(deserialise(&serialise(&state)), state); } #[test] fn a_damaged_file_is_treated_as_empty_rather_than_fatal() { let s = deserialise("last_activity notanumber\ngrave oops\n\x00garbage"); assert_eq!(s, State::default()); } #[test] fn saving_is_atomic_and_leaves_no_temporary_behind() { let dir = std::env::temp_dir().join(format!("pet-life-save-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let path = dir.join("pet.state"); let (state, _) = advance(living(), T0, T0 + 30 * DAY); save(&path, &state).expect("save"); assert_eq!(load(&path), state); assert!(!path.with_extension("state.tmp").exists(), "no temp left over"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn state_path_is_data_not_runtime() { assert_eq!( state_path_from(None, Some("/Users/x")), PathBuf::from("/Users/x/.local/share/ambient-companions/pet.state"), ); assert_eq!( state_path_from(Some("/Users/x/.local/share"), Some("/Users/x")), PathBuf::from("/Users/x/.local/share/ambient-companions/pet.state"), ); } /// The contract the app parses, pinned to exact bytes — the discipline /// that has already caught two cross-language mismatches here. #[test] fn the_json_contract_is_exact() { let (state, _) = advance(living(), T0, T0 + 30 * DAY); let (_, snap) = advance(state, T0 + 30 * DAY, T0 + 30 * DAY + DAY / 2); assert_eq!( to_json(&snap), r#"{"alive":true,"name":"Ash","generation":2,"stage":"content","face":"(^ω^)","age_days":0.50,"quiet_days":0.50,"cemetery":[{"name":"Marble","generation":1,"lived_days":7.00}]}"# ); } #[test] fn the_json_contract_handles_a_dead_pet() { let (_, snap) = advance(living(), T0, T0 + 30 * DAY); let json = to_json(&snap); assert!(json.contains(r#""alive":false"#)); assert!(json.contains(r#""name":null"#)); assert!(json.contains(r#""stage":"dead""#)); } }