Something went wrong. Try again.
a tiny pet with an even tinier brain, living in your browser
Something went wrong. Try again.
33 kB · 851 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852//! A running pet: the save plus the short-lived state around it (the turn//! queue, disturbances, effects to animate, an accepted game). The host and//! the browser's local mode run this same engine. Owner commands and the//! brain's tags change it; the brain makes every decision.use crate::{ body::{self, DAY, HOUR, MAX_CATCH_UP, MIN, clamp}, turn::{ChatTurn, DiaryTurn}, types::*, world::{self, events},};use serde::{Deserialize, Serialize};
/// Random draws, passed in so both runtimes (and tests) choose the source.pub trait Dice { fn roll(&mut self) -> f64; /// Wake rolls are separate so browser checks can force an outcome. fn wake(&mut self) -> f64 { self.roll() }}impl<F: FnMut() -> f64> Dice for F { fn roll(&mut self) -> f64 { self() }}
/// Something for the LCD to show. Effects never change the save again.#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]pub struct Effect { pub seq: u64, pub kind: String, pub arg: String,}/// A catch game the pet accepted; its score commits once, by id.#[derive(Clone, Debug, Deserialize, Serialize)]pub struct Game { pub id: String, pub started: f64,}
#[derive(Clone, Debug, Deserialize, Serialize)]#[serde(tag = "type", rename_all = "camelCase")]pub enum Command { Hatch { name: String, }, Message { text: String, }, Care { kind: String, item: Option<String>, }, Rename { name: String, }, Chatter { value: bool, }, ForgetMemory { index: usize, fact: String, }, GameComplete { #[serde(rename = "gameId")] game_id: String, score: u32, },}
/// A refused command, worded for the owner.#[derive(Clone, Debug, PartialEq)]pub struct RuleError(pub String);impl std::fmt::Display for RuleError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) }}impl std::error::Error for RuleError {}fn ensure(ok: bool, why: impl Into<String>) -> Result<(), RuleError> { if ok { Ok(()) } else { Err(RuleError(why.into())) }}
/// Work the brain should do next.#[derive(Clone, Debug, PartialEq, Serialize)]#[serde(tag = "type", rename_all = "camelCase")]pub enum Task { /// A pet line: `react` answers something that happened, `idle` lets it live. Chat { kind: String }, /// A diary page for `date`. Diary { date: String },}
pub const MAX_EFFECTS: usize = 32;/// Owners have left the pet alone this long before a missed page is written.pub const DIARY_QUIET_MS: f64 = 30_000.0;/// An accepted game that never reports a score expires.pub const GAME_EXPIRY: f64 = 5.0 * MIN;
#[derive(Clone, Debug, Deserialize, Serialize)]#[serde(rename_all = "camelCase")]pub struct Engine { pub state: PetState, /// Bumps on every change; snapshots with a lower revision are older. pub revision: u64, /// Bumps whenever the prompt context changes; a reply generated for an /// older context is stale. pub context_revision: u64, /// The turn the pet wants next: "react" or "idle". pub pending: Option<String>, pub next_idle: f64, pub last_turn: f64, pub game: Option<Game>, pub effects: Vec<Effect>, pub effect_seq: u64, /// Disturbances while asleep; reset on waking, a new sleep or 5 quiet minutes. pub disturbance_count: u32, pub disturbance_last: f64,}
impl Engine { pub fn new(state: PetState, t: f64) -> Self { Self { state, revision: 0, context_revision: 0, pending: None, next_idle: t + 4000.0, last_turn: t, game: None, effects: vec![], effect_seq: 0, disturbance_count: 0, disturbance_last: 0.0, } } fn pet_name(&self) -> String { self.state .pet .as_ref() .map_or_else(String::new, |p| p.name.clone()) } pub fn effect(&mut self, kind: &str, arg: &str) { self.effect_seq += 1; self.effects.push(Effect { seq: self.effect_seq, kind: kind.into(), arg: arg.into(), }); if self.effects.len() > MAX_EFFECTS { self.effects.remove(0); } } /// Appends a chat line. At most two silent pet lines (only tags) stay in a /// row, so idling cannot push the conversation out of the context. pub fn push_line(&mut self, line: String) { if self.state.pet.is_some() { let prefix = self.pet_name() + ": "; let silent = |l: &String| l.strip_prefix(&prefix).is_some_and(world::silent); if silent(&line) && self .state .chat .iter() .rev() .take(2) .filter(|l| silent(l)) .count() == 2 { self.state.chat.remove(self.state.chat.len() - 2); } } self.state.chat.push(line); if self.state.chat.len() > MAX_CHAT { self.state.chat.remove(0); } self.context_revision += 1; self.revision += 1; } /// At midnight a day with no page yet waits in `history` for the pet to /// write it. Days with fewer than two things in them get no page, and /// only the newest week is kept. pub fn roll_day(&mut self, t: f64, seed: &str) { let k = body::date_key(&self.state, t); if self.state.today.date == k { return; } if self.state.today.log.log.len() >= 2 && !self .state .diary .iter() .any(|d| d.date == self.state.today.date) { let log = DayLog { log: self.state.today.log.log.clone(), weather: Some( self.state .today .log .weather .clone() .unwrap_or_else(|| body::weather_at(&self.state, t - DAY, seed)), ), day: Some( self.state .today .log .day .unwrap_or_else(|| body::day_num(&self.state, t) - 1), ), }; self.state .history .insert(self.state.today.date.clone(), log); } while self.state.history.len() > 7 { self.state.history.pop_first(); } self.state.today = Today { date: k, log: DayLog { log: vec![], weather: Some(body::weather_at(&self.state, t, seed)), day: Some(body::day_num(&self.state, t)), }, }; self.revision += 1; } /// Notes something in today's log; a repeat moves to the end. pub fn log(&mut self, text: String, t: f64, seed: &str) { self.roll_day(t, seed); let log = &mut self.state.today.log.log; log.retain(|s| *s != text); log.push(text); if log.len() > 14 { log.remove(0); } self.revision += 1; } /// A react request replaces a waiting idle one. pub fn want_turn(&mut self, kind: &str) { if kind == "react" || self.pending.is_none() { self.pending = Some(kind.into()); } } pub fn reset_disturbances(&mut self) { self.disturbance_count = 0; self.disturbance_last = 0.0; } pub fn wake(&mut self, t: f64) { if !self.state.asleep || self.state.pet.is_none() { return; } self.state.asleep = false; self.state.woke_at = t; self.reset_disturbances(); self.effect("woke", ""); self.push_line(events::woke(&self.pet_name())); } /// Disturbances that still count at `t`: they lapse after five quiet minutes. pub fn disturbances_at(&self, t: f64) -> u32 { if t - self.disturbance_last >= 5.0 * MIN { 0 } else { self.disturbance_count } } /// Talking, touching or the lights may wake a sleeping pet (a body roll). /// Offers wake it only when the brain accepts. pub fn disturb(&mut self, kind: &str, t: f64, dice: &mut dyn Dice) -> bool { if !self.state.asleep || self.state.pet.is_none() { return false; } self.disturbance_count = self.disturbances_at(t) + 1; self.disturbance_last = t; let wakes = dice.wake() < world::wake_chance(self.state.stats.energy, kind, self.disturbance_count); if wakes { self.wake(t); } wakes } fn sleep_effects(&mut self, t: f64) { self.reset_disturbances(); self.state.lights_off |= body::is_night(&self.state, t); self.effect("sleep", ""); self.context_revision += 1; } /// Runs the body from `last_tick` to `t`. Gaps over two minutes are a quiet /// catch-up (capped at a week). `returning` marks the owner coming back to /// a closed page, which the pet notices after half an hour. pub fn advance(&mut self, t: f64, seed: &str, returning: bool, dice: &mut dyn Dice) { let from = self.state.last_tick; if t <= from { return; } let away = t - from; let quiet = away > 2.0 * MIN; let happened = body::simulate_body( &mut self.state, from, t.min(from + MAX_CATCH_UP), quiet, &mut || dice.roll(), ); let has = |e: &str| happened.contains(&e); self.state.last_tick = t; self.revision += 1; self.roll_day(t, seed); if self .game .as_ref() .is_some_and(|g| t - g.started > GAME_EXPIRY) { self.game = None; } let Some(pet) = self.state.pet.clone() else { return; }; let stage = body::stage_for(t - pet.hatched); if pet.stage != stage { self.state.pet.as_mut().unwrap().stage = stage.into(); self.effect("grew", stage); self.push_line(events::grew(&pet.name, stage)); self.want_turn("react"); } if has("pooped") { self.log("pooped".into(), t, seed); if !quiet { self.push_line(events::pooped(&pet.name)); self.want_turn("react"); } } if has("sick") && !quiet { self.effect("sick", ""); } if has("fell asleep") || (quiet && has("woke")) { self.log("napped".into(), t, seed); } if has("fell asleep") && self.state.asleep { self.sleep_effects(t); } if returning && away > 30.0 * MIN { let span = if away > 2.0 * DAY { format!("{} days", (away / DAY).floor()) } else if away > 2.0 * HOUR { format!("{} hours", (away / HOUR).floor()) } else { format!("{} minutes", (away / MIN).floor()) }; self.push_line(events::back(&self.state.owner, &span)); self.log( format!("{} was away for a while", self.state.owner), t, seed, ); self.want_turn("react"); } if has("woke") && !self.state.asleep { self.reset_disturbances(); self.effect("woke", ""); self.push_line(events::woke(&pet.name)); self.want_turn("react"); } for (low, entry) in [ (self.state.stats.tummy < 15.0, "was very hungry"), (self.state.stats.clean < 20.0, "was stinky"), ] { if low && !self.state.today.log.log.iter().any(|s| s == entry) { self.log(entry.into(), t, seed); } } // An offer left open while nobody ran the pet has gone stale. if quiet && self.pending.is_none() { self.state.offer = None; } } /// The prompt's view of the pet at `t`. pub fn world(&self, t: f64, seed: &str) -> WorldState { let p = self.state.pet.as_ref().expect("an egg has no world state"); let d = body::local(&self.state, t); let yesterday = self .state .diary .iter() .rev() .find(|p| p.date != self.state.today.date) .map(|p| { let first = p .text .char_indices() .find(|(i, c)| { ['.', '!', '?'].contains(c) && p.text[*i + 1..] .chars() .next() .is_some_and(char::is_whitespace) }) .map_or(p.text.as_str(), |(i, _)| &p.text[..=i]); let first: String = world::compact(first).chars().take(140).collect(); if first.to_lowercase().starts_with("dear diary") { first } else { format!("dear diary, {first}") } }) .unwrap_or_default(); WorldState { pet: p.name.clone(), owner: self.state.owner.clone(), stage: p.stage.clone(), day: body::day_num(&self.state, t), traits: p.traits.clone(), hh: d.hour, mm: d.minute, weather: body::weather_at(&self.state, t, seed), stats: self.state.stats.clone(), memories: self.state.memories.clone(), today: self.state.today.log.log.clone(), shelf: self.state.shelf.clone(), toy: p.toy.clone(), poop: self.state.poop, asleep: self.state.asleep, sick: self.state.sick, yesterday, } } /// The owner names themselves and gets an egg (setup, before hatching). pub fn lay_egg(&mut self, owner: &str, t: f64) -> Result<(), RuleError> { let owner = world::clean_name(owner, 24); ensure(!owner.is_empty(), "Your name is required.")?; self.state.owner = owner; if self.state.pet.is_none() && self.state.egg.is_none() { self.state.egg = Some(Egg { laid: t }); } self.revision += 1; Ok(()) } pub fn command( &mut self, command: Command, t: f64, seed: &str, dice: &mut dyn Dice, ) -> Result<(), RuleError> { self.advance(t, seed, false, dice); let owner = self.state.owner.clone(); let pet = self.pet_name(); match command { Command::Hatch { name } => { ensure( self.state.pet.is_none() && self.state.egg.is_some(), "There is no egg to hatch.", )?; let name = world::clean_name(&name, 16); ensure(!name.is_empty(), "The pet needs a name.")?; let mut traits: Vec<_> = world::PERSONALITIES.to_vec(); for i in (1..traits.len()).rev() { traits.swap(i, pick_index(dice, i + 1)); } self.state.pet = Some(Pet { name, traits: traits[..2].iter().map(|s| s.to_string()).collect(), color: world::COLORS[pick_index(dice, world::COLORS.len())].into(), toy: world::TOYS[pick_index(dice, world::TOYS.len())].into(), hatched: t, stage: "baby".into(), }); self.state.egg = None; self.state.today = Today { date: body::date_key(&self.state, t), log: DayLog { log: vec![], weather: Some(body::weather_at(&self.state, t, seed)), day: Some(1), }, }; self.state.next_poop = t + 3.0 * HOUR; self.effect("sparkle", ""); self.want_turn("react"); } Command::Message { text } => { ensure( self.state.pet.is_some() && self.game.is_none(), format!("{pet} can't listen right now."), )?; let text = world::clean_message(&text); ensure(!text.is_empty(), "The message is empty.")?; self.disturb("talk", t, dice); self.push_line(format!("{owner}: {text}")); if text.chars().count() > 12 && dice.roll() < 0.5 { let said: String = text.chars().take(48).collect(); self.log(format!("{owner} said: {}", said.to_lowercase()), t, seed); } self.want_turn("react"); } Command::Care { kind, item } => { ensure( self.state.pet.is_some() && self.game.is_none(), format!("{pet} can't be looked after right now."), )?; match kind.as_str() { "feed" | "bath" | "medicine" | "play" => { if let Some(open) = &self.state.offer { return Err(RuleError(format!( "{pet} is still deciding about {}.", offer_thing(open) ))); } let line = match kind.as_str() { "feed" => { let food = item.as_deref().unwrap_or(""); ensure(world::FOODS.contains(&food), "That isn't a food.")?; events::offer_food(&owner, &pet, food) } "bath" => events::offer_bath(&owner, &pet), "medicine" => events::offer_medicine(&owner, &pet), _ => events::offer_catch(&owner, &pet), }; let item = if kind == "feed" { item } else { None }; self.state.offer = Some(Offer { kind, item }); self.push_line(line); } "pat" => { // The squish is physics; the feelings come from the brain. self.effect("pat", if self.state.asleep { "asleep" } else { "" }); self.disturb("touch", t, dice); self.state.stats.fun = clamp(self.state.stats.fun + 4.0); self.state.counts.pats += 1; // While a reaction to the last pat is pending, more pats just squish. let line = events::pat(&owner, &pet); if self.state.chat.last() != Some(&line) { self.push_line(line); self.log("got patted".into(), t, seed); } } "clean" => { ensure(self.state.poop > 0, "All clean already.")?; self.state.poop = 0; self.state.stats.clean = clamp(self.state.stats.clean + 10.0); self.effect("sparkle", ""); self.push_line(events::cleaned(&owner)); } "lights" => { self.state.lights_off = !self.state.lights_off; self.push_line(events::lights(&owner, !self.state.lights_off)); if !self.state.lights_off { self.disturb("light", t, dice); } } _ => return Err(RuleError("Unknown care action.".into())), } self.want_turn("react"); } Command::Rename { name } => { let name = world::clean_name(&name, 24); ensure(!name.is_empty(), "Your name is required.")?; self.state.owner = name; } Command::Chatter { value } => { self.state.settings.chatter = value; self.schedule_idle(t, dice); } Command::ForgetMemory { index, fact } => { ensure( self.state.memories.get(index) == Some(&fact), "That memory changed; look again before forgetting it.", )?; self.state.memories.remove(index); } Command::GameComplete { game_id, score } => { ensure( self.game.as_ref().is_some_and(|g| g.id == game_id), "That game is over.", )?; ensure(score <= 60, "That score is impossible.")?; self.complete_game(score, t, seed); } } self.state.last_active = t; self.context_revision += 1; self.revision += 1; Ok(()) } fn complete_game(&mut self, n: u32, t: f64, seed: &str) { self.game = None; self.state.stats.fun = clamp(self.state.stats.fun + 8.0 + n as f64 * 3.0); self.state.stats.energy = clamp(self.state.stats.energy - 8.0); self.state.counts.games += 1; self.push_line(events::caught(&self.state.owner, &self.pet_name(), n)); self.log(format!("played catch ({n} {})", events::stars(n)), t, seed); self.want_turn("react"); } /// The brain's answer to the open offer; only `yes` applies it. pub fn answer_offer(&mut self, yes: bool, t: f64, seed: &str, dice: &mut dyn Dice) { let Some(offer) = self.state.offer.take() else { return; }; self.revision += 1; if !yes { self.log(format!("said no to {}", offer_thing(&offer)), t, seed); return; } self.wake(t); match offer.kind.as_str() { "feed" => { let food = offer.item.unwrap_or_default(); let treat = ["pudding", "cookie"].contains(&food.as_str()); self.state.stats.tummy = clamp(self.state.stats.tummy + if treat { 22.0 } else { 28.0 }); if treat || food == "strawberry" { self.state.stats.fun = clamp(self.state.stats.fun + 6.0); } self.state.counts.fed += 1; self.effect("eat", &food); self.log(format!("ate {}", world::a(&food)), t, seed); } "bath" => { self.state.stats.clean = 100.0; self.state.stats.fun = clamp(self.state.stats.fun - 2.0); self.state.counts.baths += 1; self.effect("bath", ""); self.log("had a bath".into(), t, seed); } "medicine" => { // Medicine has no effect on a healthy pet. if self.state.sick { self.state.sick = false; self.state.grime_since = 0.0; self.effect("sparkle", ""); } self.log("took medicine".into(), t, seed); } _ => { let id = (0..4) .map(|_| format!("{:08x}", (dice.roll() * 4294967296.0) as u32)) .collect(); self.game = Some(Game { id, started: t }); } } } /// Effects of the pet's own tags in a committed line. pub fn apply_line(&mut self, line: &str, t: f64, seed: &str, dice: &mut dyn Dice) { let sleeping = self.state.asleep; let tags = world::tags(line); let answers: Vec<_> = tags .iter() .filter(|tag| world::ANSWER_TOOLS.contains(&tag.name.as_str())) .collect(); // Resolve the answer once, independent of tag order; ambiguous answers do nothing. if self.state.offer.is_some() && answers.len() == 1 && answers[0].arg.is_none() && answers[0].result.is_none() { self.answer_offer(answers[0].name == "yes", t, seed, dice); } for tag in tags { let arg = tag.arg.unwrap_or_default(); if tag.result.is_some() && world::EFFECT_TOOLS.contains(&tag.name.as_str()) { continue; } if tag.name == "dream" { if sleeping && self.state.asleep && world::DREAMS.contains(&arg.as_str()) { self.dream(&arg, t, seed); } continue; } // A sleeping answer can only dream or accept/refuse the offer, even if // accepting it has just woken the pet. Other effects wait for next turn. if sleeping || self.state.asleep { continue; } match tag.name.as_str() { "emote" if world::EMOTES.contains(&arg.as_str()) => self.effect("emote", &arg), "want" if world::WANTS.contains(&arg.as_str()) => self.effect("want", &arg), "walk" if world::WALKS.contains(&arg.as_str()) => self.effect("walk", &arg), "remember" if !arg.trim().is_empty() => { let fact = arg.trim().to_owned(); let known = self .state .memories .iter() .any(|s| s.to_lowercase() == fact.to_lowercase()); if !known { self.state.memories.push(fact.clone()); if self.state.memories.len() > MAX_MEMORIES { self.state.memories.remove(0); } self.effect("remember", &fact); self.log(format!("learned: {fact}"), t, seed); } } "gift" if world::GIFTS.contains(&arg.as_str()) => { self.state.shelf.push(arg.clone()); if self.state.shelf.len() > MAX_SHELF { self.state.shelf.remove(0); } self.effect("gift", &arg); self.log(format!("gave {} a {arg}", self.state.owner), t, seed); } "diary" if !arg.is_empty() => self.log(format!("note: {}", arg.trim()), t, seed), "sleep" if arg.is_empty() => { self.state.asleep = true; self.log("napped".into(), t, seed); self.sleep_effects(t); } _ => {} } } // An offer the pet saw but did not answer counts as no. if self.state.offer.is_some() { self.answer_offer(false, t, seed, dice); } self.revision += 1; } fn dream(&mut self, item: &str, t: f64, seed: &str) { self.effect("dream", item); self.roll_day(t, seed); let entry = format!("dreamed of {}", world::dream_thing(item)); if self.state.today.log.log.contains(&entry) { return; } // Keep at most two dreams, so frequent dreams cannot displace the whole day. let dreams: Vec<_> = self .state .today .log .log .iter() .filter(|s| s.starts_with("dreamed of ")) .cloned() .collect(); if dreams.len() >= 2 { self.state.today.log.log.retain(|s| *s != dreams[0]); } self.log(entry, t, seed); } /// The next idle turn: minutes while asleep or with chatter off. pub fn schedule_idle(&mut self, t: f64, dice: &mut dyn Dice) { self.next_idle = t + if self.state.asleep { (3.0 + dice.roll() * 3.0) * MIN } else if self.state.settings.chatter { 8000.0 + dice.roll() * 12000.0 } else { (2.0 + dice.roll() * 2.0) * MIN }; } /// What the brain should do now. Without `background` only a requested /// reaction runs (the browser while its page is hidden). pub fn choose_task(&self, t: f64, background: bool) -> Option<Task> { self.state.pet.as_ref()?; if let Some(kind) = &self.pending { return Some(Task::Chat { kind: kind.clone() }); } if !background { return None; } let today = &self.state.today; // Today's page at bedtime, once there is something to write about. if self.state.asleep && body::is_night(&self.state, t) && today.log.log.len() >= 2 && t - self.last_turn >= 1500.0 && !self.state.diary.iter().any(|d| d.date == today.date) { return Some(Task::Diary { date: today.date.clone(), }); } if t >= self.next_idle { return Some(Task::Chat { kind: "idle".into(), }); } // Missed days, oldest first, once the owner has left the pet alone. if t - self.state.last_active >= DIARY_QUIET_MS { if let Some(date) = self.state.history.keys().next() { return Some(Task::Diary { date: date.clone() }); } } None } /// The brain took a task. pub fn begin_turn(&mut self, t: f64, dice: &mut dyn Dice) { self.pending = None; self.last_turn = t; self.schedule_idle(t, dice); self.revision += 1; } pub fn chat_turn(&self, kind: &str, t: f64, seed: &str) -> ChatTurn { ChatTurn::new(self.world(t, seed), self.state.chat.clone(), kind) } /// Commits a finished line for the current context. Lines without a word /// add nothing, and an open offer they leave unanswered counts as no. pub fn commit_line(&mut self, body: &str, t: f64, seed: &str, dice: &mut dyn Dice) { if body.bytes().any(|b| b.is_ascii_alphanumeric()) { self.push_line(format!("{}: {body}", self.pet_name())); self.apply_line(body, t, seed, dice); } else if self.state.offer.is_some() { self.answer_offer(false, t, seed, dice); } self.revision += 1; } /// The diary page for `date` (today or a missed day), unless written. pub fn diary_turn(&self, date: &str, t: f64, seed: &str, replace: bool) -> Option<DiaryTurn> { self.state.pet.as_ref()?; let today = date == self.state.today.date; if !(replace && today) && self.state.diary.iter().any(|d| d.date == date) { return None; } let mut st = self.world(t, seed); if !today { let day = self.state.history.get(date)?; st.today = day.log.clone(); st.weather = day.weather.clone().unwrap_or(st.weather); st.day = day.day.unwrap_or(st.day); } Some(DiaryTurn::new(&st, date.into(), replace && today)) } pub fn commit_page(&mut self, page: DiaryPage, replace: bool) { self.state.history.remove(&page.date); if replace { self.state.diary.retain(|d| d.date != page.date); } if !self.state.diary.iter().any(|d| d.date == page.date) { self.effect("diary", &page.date); self.state.diary.push(page); } self.revision += 1; }}
fn pick_index(dice: &mut dyn Dice, n: usize) -> usize { ((dice.roll() * n as f64) as usize).min(n - 1)}/// How an offer reads in the day log and in messages ("a cookie", "a bath").pub fn offer_thing(o: &Offer) -> String { match o.kind.as_str() { "feed" => world::a(o.item.as_deref().unwrap_or("")), "bath" => "a bath".into(), "medicine" => "medicine".into(), _ => "catch".into(), }}