| @@ -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 | |
| 15 | use std::path::{Path, PathBuf}; |
| 16 | |
| 17 | /// Quiet for this long and the pet is dead. |
| 18 | pub const LIFESPAN_MS: u64 = 7 * 86_400_000; |
| 19 | |
| 20 | /// Stage thresholds: quiet-time at which each begins. |
| 21 | const RESTLESS_MS: u64 = 86_400_000; |
| 22 | const HUNGRY_MS: u64 = 2 * 86_400_000; |
| 23 | const SICK_MS: u64 = 7 * 86_400_000 / 2; |
| 24 | const 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. |
| 28 | const 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)] |
| 36 | pub enum Stage { |
| 37 | Content, |
| 38 | Restless, |
| 39 | Hungry, |
| 40 | Sick, |
| 41 | Dying, |
| 42 | Dead, |
| 43 | } |
| 44 | |
| 45 | impl 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)] |
| 84 | pub struct Grave { |
| 85 | pub name: String, |
| 86 | pub generation: u32, |
| 87 | pub born_ms: u64, |
| 88 | pub died_ms: u64, |
| 89 | } |
| 90 | |
| 91 | impl 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)] |
| 99 | pub 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)] |
| 107 | pub 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)] |
| 117 | pub 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. |
| 127 | pub 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. |
| 140 | pub 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. |
| 203 | pub 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 | |
| 210 | pub 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. |
| 219 | pub 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. |
| 231 | pub 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. |
| 253 | pub 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. |
| 269 | pub 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. |
| 310 | pub 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)] |
| 342 | mod 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 | } |