Add pet-life: the permadeath mechanic and the cemetery !27

merged merged by cmc on 2026-09-04 17:20 UTC · krz/ambient-companions:feat/pet-life into main

5 files changed, +619 −0

Cargo.lock +8
@@ -108,6 +108,14 @@ version = "1.21.4"
108108source = "registry+https://github.com/rust-lang/crates.io-index"
109109checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
110110
111[[package]]
112name = "pet-life"
113version = "1.1.0"
114dependencies = [
115 "signal-client",
116 "signald",
117]
118
111119[[package]]
112120name = "pkg-config"
113121version = "0.3.33"
Cargo.toml +1
@@ -6,6 +6,7 @@ members = [
66 "crates/signald",
77 "crates/terminal-garden",
88 "crates/terminal-pet",
9 "crates/pet-life",
910]
1011
1112[workspace.package]
crates/pet-life/Cargo.toml added +15
@@ -0,0 +1,15 @@
1[package]
2name = "pet-life"
3version.workspace = true
4edition.workspace = true
5license.workspace = true
6description = "The one-life mechanic behind the menu-bar face: ageing, permadeath, and the cemetery. A library plus a one-shot that prints the pet's state as JSON."
7
8[[bin]]
9name = "pet-life"
10path = "src/main.rs"
11
12[dependencies]
13signal-client = { path = "../signal-client" }
14# For the history query behind "when did activity last happen".
15signald = { path = "../signald" }
crates/pet-life/src/lib.rs added +509
@@ -0,0 +1,509 @@
1//! # pet-life
2//!
3//! The one-life mechanic behind the menu-bar face. A pet ages, neglect kills
4//! it, and death is permanent for that pet — a new one is born when you come
5//! back, and the old one is remembered.
6//!
7//! ## Everything derives from timestamps
8//!
9//! Nothing here accumulates or ticks. Death happens at `last_activity +
10//! [`LIFESPAN_MS`]`, not when a program noticed, so the answer is identical
11//! whether you look an hour later or a month later. That makes sleep, reboots,
12//! daemon restarts and the app simply not running irrelevant by construction
13//! rather than by handling each case.
14
15use std::path::{Path, PathBuf};
16
17/// Quiet for this long and the pet is dead.
18pub const LIFESPAN_MS: u64 = 7 * 86_400_000;
19
20/// Stage thresholds: quiet-time at which each begins.
21const RESTLESS_MS: u64 = 86_400_000;
22const HUNGRY_MS: u64 = 2 * 86_400_000;
23const SICK_MS: u64 = 7 * 86_400_000 / 2;
24const DYING_MS: u64 = 5 * 86_400_000;
25
26/// Names for new pets. Deterministic from the birth timestamp: a cemetery of
27/// "Mochi, 9 days" means something, one of "generation 3" does not.
28const NAMES: &[&str] = &[
29 "Mochi", "Bean", "Pip", "Waffle", "Sprout", "Tofu", "Biscuit", "Clover",
30 "Pickle", "Marble", "Ash", "Juniper", "Olive", "Peanut", "Sage", "Fig",
31 "Barley", "Cricket", "Dill", "Ember", "Fennel", "Gus", "Hazel", "Indigo",
32];
33
34/// How far a pet has declined.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Stage {
37 Content,
38 Restless,
39 Hungry,
40 Sick,
41 Dying,
42 Dead,
43}
44
45impl Stage {
46 /// The stage for a pet that has been unattended for `quiet_ms`.
47 pub fn at(quiet_ms: u64) -> Stage {
48 match quiet_ms {
49 q if q >= LIFESPAN_MS => Stage::Dead,
50 q if q >= DYING_MS => Stage::Dying,
51 q if q >= SICK_MS => Stage::Sick,
52 q if q >= HUNGRY_MS => Stage::Hungry,
53 q if q >= RESTLESS_MS => Stage::Restless,
54 _ => Stage::Content,
55 }
56 }
57
58 pub fn name(self) -> &'static str {
59 match self {
60 Stage::Content => "content",
61 Stage::Restless => "restless",
62 Stage::Hungry => "hungry",
63 Stage::Sick => "sick",
64 Stage::Dying => "dying",
65 Stage::Dead => "dead",
66 }
67 }
68
69 /// The glyph the menu bar shows.
70 pub fn face(self) -> &'static str {
71 match self {
72 Stage::Content => "(^ω^)",
73 Stage::Restless => "(・_・)",
74 Stage::Hungry => "(っ_-)",
75 Stage::Sick => "(×_×)",
76 Stage::Dying => "(x_x)",
77 Stage::Dead => "",
78 }
79 }
80}
81
82/// A pet that died, kept forever.
83#[derive(Debug, Clone, PartialEq)]
84pub struct Grave {
85 pub name: String,
86 pub generation: u32,
87 pub born_ms: u64,
88 pub died_ms: u64,
89}
90
91impl Grave {
92 pub fn lived_days(&self) -> f64 {
93 self.died_ms.saturating_sub(self.born_ms) as f64 / 86_400_000.0
94 }
95}
96
97/// A pet currently alive.
98#[derive(Debug, Clone, PartialEq)]
99pub struct Pet {
100 pub name: String,
101 pub generation: u32,
102 pub born_ms: u64,
103}
104
105/// Everything persisted between runs.
106#[derive(Debug, Clone, Default, PartialEq)]
107pub struct State {
108 pub pet: Option<Pet>,
109 pub cemetery: Vec<Grave>,
110 /// The latest activity ever observed. The history store only reaches back
111 /// as far as its retention, so this remembers what it has forgotten.
112 pub last_activity_ms: u64,
113}
114
115/// The pet as it stands right now.
116#[derive(Debug, Clone, PartialEq)]
117pub struct Snapshot {
118 pub pet: Option<Pet>,
119 pub stage: Stage,
120 pub quiet_ms: u64,
121 pub age_ms: u64,
122 pub cemetery: Vec<Grave>,
123}
124
125/// Name a pet born at `born_ms`. Deterministic, so the same birth always
126/// produces the same name and a rerun cannot rename the dead.
127pub fn name_for(born_ms: u64, generation: u32) -> String {
128 // Mixing the generation in keeps two pets born in the same millisecond —
129 // only reachable in tests — from sharing a name.
130 let idx = (born_ms / 1000).wrapping_add(generation as u64) as usize % NAMES.len();
131 NAMES[idx].to_string()
132}
133
134/// Advance `state` to `now_ms` given the latest activity, and return what to
135/// display. Pure: the caller decides whether to persist the result.
136///
137/// `observed_activity_ms` is the later of the history store's answer and what
138/// was already persisted, so a stretch with the app closed does not starve a
139/// pet that was being fed the whole time.
140pub fn advance(mut state: State, observed_activity_ms: u64, now_ms: u64) -> (State, Snapshot) {
141 state.last_activity_ms = state.last_activity_ms.max(observed_activity_ms);
142 let last = state.last_activity_ms;
143
144 // A pet that ran out of time is buried at the moment it ran out, not when
145 // a program got around to looking.
146 if let Some(pet) = state.pet.clone() {
147 let quiet = now_ms.saturating_sub(last.max(pet.born_ms));
148 if quiet >= LIFESPAN_MS {
149 let died_ms = last.max(pet.born_ms) + LIFESPAN_MS;
150 state.cemetery.push(Grave {
151 name: pet.name,
152 generation: pet.generation,
153 born_ms: pet.born_ms,
154 died_ms,
155 });
156 state.pet = None;
157 }
158 }
159
160 // Birth needs someone to come back. Being born automatically would turn a
161 // fortnight away into a chain of pets born, never fed and dead — a
162 // cemetery full of lives that did not happen.
163 if state.pet.is_none() {
164 let buried_at = state.cemetery.last().map(|g| g.died_ms).unwrap_or(0);
165 if last > buried_at {
166 let generation = state.cemetery.len() as u32 + 1;
167 state.pet = Some(Pet {
168 name: name_for(last, generation),
169 generation,
170 born_ms: last,
171 });
172 }
173 }
174
175 let snapshot = match &state.pet {
176 Some(pet) => {
177 let quiet = now_ms.saturating_sub(last.max(pet.born_ms));
178 Snapshot {
179 pet: Some(pet.clone()),
180 stage: Stage::at(quiet),
181 quiet_ms: quiet,
182 age_ms: now_ms.saturating_sub(pet.born_ms),
183 cemetery: state.cemetery.clone(),
184 }
185 }
186 None => Snapshot {
187 pet: None,
188 stage: Stage::Dead,
189 quiet_ms: now_ms.saturating_sub(last),
190 age_ms: 0,
191 cemetery: state.cemetery.clone(),
192 },
193 };
194 (state, snapshot)
195}
196
197/// `$XDG_DATA_HOME/ambient-companions/pet.state`, else
198/// `$HOME/.local/share/ambient-companions/pet.state`.
199///
200/// Deliberately not beside the daemon's socket and database. Those derive from
201/// `$XDG_RUNTIME_DIR` when it is set, which is a tmpfs wiped every reboot. A
202/// graveyard a reboot can erase is not a graveyard. Data, not runtime.
203pub fn state_path_from(xdg_data_home: Option<&str>, home: Option<&str>) -> PathBuf {
204 match xdg_data_home {
205 Some(dir) => PathBuf::from(dir).join("ambient-companions/pet.state"),
206 None => PathBuf::from(home.unwrap_or(".")).join(".local/share/ambient-companions/pet.state"),
207 }
208}
209
210pub fn default_state_path() -> PathBuf {
211 state_path_from(
212 std::env::var("XDG_DATA_HOME").ok().as_deref(),
213 std::env::var("HOME").ok().as_deref(),
214 )
215}
216
217/// Write `state` to `path` via a temporary file and a rename, so a crash
218/// part-way through cannot leave a half-written cemetery behind.
219pub fn save(path: &Path, state: &State) -> std::io::Result<()> {
220 if let Some(parent) = path.parent() {
221 std::fs::create_dir_all(parent)?;
222 }
223 let tmp = path.with_extension("state.tmp");
224 std::fs::write(&tmp, serialise(state))?;
225 std::fs::rename(&tmp, path)
226}
227
228/// Read the state, or a fresh one if there is nothing there yet. A file that
229/// cannot be parsed is treated as absent rather than fatal: losing a cemetery
230/// is sad, refusing to draw a menu bar is worse.
231pub fn load(path: &Path) -> State {
232 std::fs::read_to_string(path)
233 .ok()
234 .map(|text| deserialise(&text))
235 .unwrap_or_default()
236}
237
238// --- persistence format ---
239//
240// Line-based rather than JSON. The file is written and read only here, so a
241// hand-rolled JSON *parser* would be all risk and no benefit — the project
242// already stores the terminal spool as plain whitespace-separated records for
243// the same reason. JSON is still what the app is handed; that direction only
244// needs emitting.
245//
246// last_activity <ms>
247// pet <generation> <born_ms> <name>
248// grave <generation> <born_ms> <died_ms> <name>
249//
250// Names come from a fixed list with no whitespace, so splitting is safe.
251
252/// Render `state` in the on-disk format.
253pub fn serialise(state: &State) -> String {
254 let mut out = format!("last_activity {}\n", state.last_activity_ms);
255 if let Some(p) = &state.pet {
256 out.push_str(&format!("pet {} {} {}\n", p.generation, p.born_ms, p.name));
257 }
258 for g in &state.cemetery {
259 out.push_str(&format!(
260 "grave {} {} {} {}\n",
261 g.generation, g.born_ms, g.died_ms, g.name
262 ));
263 }
264 out
265}
266
267/// Parse the on-disk format. Unreadable lines are skipped rather than fatal:
268/// losing part of a cemetery is sad, refusing to draw a menu bar is worse.
269pub fn deserialise(text: &str) -> State {
270 let mut state = State::default();
271 for line in text.lines() {
272 let f: Vec<&str> = line.split_whitespace().collect();
273 match f.as_slice() {
274 ["last_activity", ms] => {
275 state.last_activity_ms = ms.parse().unwrap_or(0);
276 }
277 ["pet", gen, born, name] => {
278 if let (Ok(generation), Ok(born_ms)) = (gen.parse(), born.parse()) {
279 state.pet = Some(Pet {
280 name: (*name).to_string(),
281 generation,
282 born_ms,
283 });
284 }
285 }
286 ["grave", gen, born, died, name] => {
287 if let (Ok(generation), Ok(born_ms), Ok(died_ms)) =
288 (gen.parse(), born.parse(), died.parse())
289 {
290 state.cemetery.push(Grave {
291 name: (*name).to_string(),
292 generation,
293 born_ms,
294 died_ms,
295 });
296 }
297 }
298 _ => {}
299 }
300 }
301 state
302}
303
304/// The contract handed to the menu-bar app. Emitting only — nothing reads this
305/// back in Rust.
306///
307/// Every value is a number, a boolean, a stage name, or a pet name from a
308/// fixed list, so there is no input needing escaping and no reason to take on
309/// a serialisation dependency for it.
310pub fn to_json(s: &Snapshot) -> String {
311 let days = |ms: u64| ms as f64 / 86_400_000.0;
312 let graves: Vec<String> = s
313 .cemetery
314 .iter()
315 .map(|g| {
316 format!(
317 r#"{{"name":"{}","generation":{},"lived_days":{:.2}}}"#,
318 g.name,
319 g.generation,
320 g.lived_days()
321 )
322 })
323 .collect();
324 let (name, generation) = match &s.pet {
325 Some(p) => (format!(r#""{}""#, p.name), p.generation.to_string()),
326 None => ("null".to_string(), "null".to_string()),
327 };
328 format!(
329 r#"{{"alive":{},"name":{},"generation":{},"stage":"{}","face":"{}","age_days":{:.2},"quiet_days":{:.2},"cemetery":[{}]}}"#,
330 s.pet.is_some(),
331 name,
332 generation,
333 s.stage.name(),
334 s.stage.face(),
335 days(s.age_ms),
336 days(s.quiet_ms),
337 graves.join(",")
338 )
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 const DAY: u64 = 86_400_000;
346 /// An arbitrary but fixed "now", so nothing here depends on the clock.
347 const T0: u64 = 1_700_000_000_000;
348
349 /// A pet born at T0 that has just been fed.
350 fn living() -> State {
351 let (state, _) = advance(State::default(), T0, T0);
352 state
353 }
354
355 #[test]
356 fn stages_step_through_the_week() {
357 for (quiet, expected) in [
358 (0, Stage::Content),
359 (DAY - 1, Stage::Content),
360 (DAY, Stage::Restless),
361 (2 * DAY, Stage::Hungry),
362 (7 * DAY / 2, Stage::Sick),
363 (5 * DAY, Stage::Dying),
364 (7 * DAY - 1, Stage::Dying),
365 (7 * DAY, Stage::Dead),
366 (90 * DAY, Stage::Dead),
367 ] {
368 assert_eq!(Stage::at(quiet), expected, "quiet for {quiet}ms");
369 }
370 }
371
372 #[test]
373 fn activity_resets_the_decline() {
374 let state = living();
375 let (state, snap) = advance(state, T0, T0 + 3 * DAY);
376 assert_eq!(snap.stage, Stage::Hungry);
377 let (_, snap) = advance(state, T0 + 3 * DAY, T0 + 3 * DAY);
378 assert_eq!(snap.stage, Stage::Content, "showing up restores it");
379 }
380
381 /// Death is derived, so when you look does not change what happened.
382 #[test]
383 fn death_is_recorded_at_the_moment_it_ran_out() {
384 let (state, _) = advance(living(), T0, T0 + 30 * DAY);
385 assert_eq!(state.cemetery.len(), 1);
386 assert_eq!(
387 state.cemetery[0].died_ms,
388 T0 + 7 * DAY,
389 "died when the week ran out, not when observed"
390 );
391 assert_eq!(state.cemetery[0].lived_days(), 7.0);
392 }
393
394 /// The reason birth needs activity: otherwise a fortnight away breeds a
395 /// chain of pets that were born, never fed, and died.
396 #[test]
397 fn a_long_absence_leaves_one_grave_not_a_chain() {
398 let (state, snap) = advance(living(), T0, T0 + 60 * DAY);
399 assert_eq!(state.cemetery.len(), 1, "one death, not eight");
400 assert!(state.pet.is_none(), "nothing is born while you are away");
401 assert_eq!(snap.stage, Stage::Dead);
402 }
403
404 #[test]
405 fn coming_back_starts_a_new_pet() {
406 let (state, _) = advance(living(), T0, T0 + 30 * DAY);
407 let returned = T0 + 30 * DAY;
408 let (state, snap) = advance(state, returned, returned);
409
410 let pet = snap.pet.expect("a new pet");
411 assert_eq!(pet.generation, 2);
412 assert_eq!(snap.stage, Stage::Content);
413 assert_eq!(state.cemetery.len(), 1, "the first is still remembered");
414 assert_ne!(state.cemetery[0].name, pet.name, "and it is a different pet");
415 }
416
417 #[test]
418 fn the_cemetery_accumulates_across_generations() {
419 let mut state = living();
420 let mut t = T0;
421 for expected in 1..=3 {
422 t += 30 * DAY;
423 let (s, _) = advance(state, t - 30 * DAY, t); // die
424 let (s, _) = advance(s, t, t); // and be replaced
425 state = s;
426 assert_eq!(state.cemetery.len(), expected);
427 assert_eq!(state.pet.as_ref().unwrap().generation, expected as u32 + 1);
428 }
429 }
430
431 /// The history store only reaches back as far as its retention, so a
432 /// remembered timestamp has to win over an older one.
433 #[test]
434 fn persisted_activity_outranks_a_forgotten_history() {
435 let state = State {
436 last_activity_ms: T0 + 5 * DAY,
437 ..living()
438 };
439 // History has been pruned and answers with something older.
440 let (state, snap) = advance(state, T0, T0 + 5 * DAY);
441 assert_eq!(state.last_activity_ms, T0 + 5 * DAY);
442 assert_eq!(snap.stage, Stage::Content, "not starved by a pruned table");
443 }
444
445 #[test]
446 fn a_name_is_stable_for_a_given_birth() {
447 assert_eq!(name_for(T0, 1), name_for(T0, 1));
448 assert_ne!(name_for(T0, 1), name_for(T0 + 60_000, 1));
449 }
450
451 #[test]
452 fn state_round_trips_through_the_file_format() {
453 let (state, _) = advance(living(), T0, T0 + 30 * DAY);
454 let (state, _) = advance(state, T0 + 30 * DAY, T0 + 30 * DAY);
455 assert_eq!(deserialise(&serialise(&state)), state);
456 }
457
458 #[test]
459 fn a_damaged_file_is_treated_as_empty_rather_than_fatal() {
460 let s = deserialise("last_activity notanumber\ngrave oops\n\x00garbage");
461 assert_eq!(s, State::default());
462 }
463
464 #[test]
465 fn saving_is_atomic_and_leaves_no_temporary_behind() {
466 let dir = std::env::temp_dir().join(format!("pet-life-save-{}", std::process::id()));
467 let _ = std::fs::remove_dir_all(&dir);
468 let path = dir.join("pet.state");
469 let (state, _) = advance(living(), T0, T0 + 30 * DAY);
470
471 save(&path, &state).expect("save");
472 assert_eq!(load(&path), state);
473 assert!(!path.with_extension("state.tmp").exists(), "no temp left over");
474 let _ = std::fs::remove_dir_all(&dir);
475 }
476
477 #[test]
478 fn state_path_is_data_not_runtime() {
479 assert_eq!(
480 state_path_from(None, Some("/Users/x")),
481 PathBuf::from("/Users/x/.local/share/ambient-companions/pet.state"),
482 );
483 assert_eq!(
484 state_path_from(Some("/Users/x/.local/share"), Some("/Users/x")),
485 PathBuf::from("/Users/x/.local/share/ambient-companions/pet.state"),
486 );
487 }
488
489 /// The contract the app parses, pinned to exact bytes — the discipline
490 /// that has already caught two cross-language mismatches here.
491 #[test]
492 fn the_json_contract_is_exact() {
493 let (state, _) = advance(living(), T0, T0 + 30 * DAY);
494 let (_, snap) = advance(state, T0 + 30 * DAY, T0 + 30 * DAY + DAY / 2);
495 assert_eq!(
496 to_json(&snap),
497 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}]}"#
498 );
499 }
500
501 #[test]
502 fn the_json_contract_handles_a_dead_pet() {
503 let (_, snap) = advance(living(), T0, T0 + 30 * DAY);
504 let json = to_json(&snap);
505 assert!(json.contains(r#""alive":false"#));
506 assert!(json.contains(r#""name":null"#));
507 assert!(json.contains(r#""stage":"dead""#));
508 }
509}
crates/pet-life/src/main.rs added +86
@@ -0,0 +1,86 @@
1//! # pet-life
2//!
3//! Prints the pet's current state as JSON and exits. The menu-bar app runs it
4//! on a timer; everything interesting — ageing, death, the cemetery — happens
5//! here, where it can be tested.
6//!
7//! Usage:
8//! ```text
9//! pet-life [--db <path>] [--state <path>]
10//! pet-life --help | --version
11//! ```
12
13use std::path::PathBuf;
14
15use signald::history::History;
16
17const USAGE: &str = "\
18Usage: pet-life [--db <path>] [--state <path>]
19
20 --db <path> signald's history database (default: beside its socket)
21 --state <path> the pet's own state (default $XDG_DATA_HOME, else
22 ~/.local/share/ambient-companions/pet.state)
23 -h, --help print this and exit
24 -V, --version print the version and exit
25
26Prints the pet's state as JSON. Exits 0 even with no daemon: a pet whose
27history cannot be read is not a dead pet, only an unobserved one.
28";
29
30fn main() {
31 let mut db: Option<PathBuf> = None;
32 let mut state_path: Option<PathBuf> = None;
33 let mut args = std::env::args().skip(1);
34 while let Some(arg) = args.next() {
35 match arg.as_str() {
36 "--db" => db = args.next().map(PathBuf::from),
37 "--state" => state_path = args.next().map(PathBuf::from),
38 "--help" | "-h" => {
39 print!("{USAGE}");
40 return;
41 }
42 "--version" | "-V" => {
43 println!("pet-life {}", env!("CARGO_PKG_VERSION"));
44 return;
45 }
46 _ => {
47 eprintln!("pet-life: unknown option {arg}\n");
48 eprint!("{USAGE}");
49 std::process::exit(2);
50 }
51 }
52 }
53
54 let state_path = state_path.unwrap_or_else(pet_life::default_state_path);
55 let db = db.unwrap_or_else(default_db_path);
56
57 // No daemon, or no history yet, is not fatal. The persisted activity still
58 // stands, so the pet ages on the record it already has rather than dying
59 // because nothing could be read this once.
60 let observed = History::open_read_only(&db)
61 .ok()
62 .and_then(|h| h.last_activity_ms().ok().flatten())
63 .unwrap_or(0);
64
65 let (state, snapshot) = pet_life::advance(pet_life::load(&state_path), observed, now_millis());
66 if let Err(e) = pet_life::save(&state_path, &state) {
67 eprintln!("pet-life: cannot write {}: {e}", state_path.display());
68 }
69 println!("{}", pet_life::to_json(&snapshot));
70}
71
72/// The history database sits beside the daemon's socket.
73fn default_db_path() -> PathBuf {
74 let socket = signal_client::default_socket_path();
75 socket
76 .parent()
77 .map(|d| d.join("signald.sqlite"))
78 .unwrap_or_else(|| PathBuf::from("signald.sqlite"))
79}
80
81fn now_millis() -> u64 {
82 std::time::SystemTime::now()
83 .duration_since(std::time::UNIX_EPOCH)
84 .map(|d| d.as_millis() as u64)
85 .unwrap_or(0)
86}