diff --git a/crates/daemon/names/families.txt b/crates/daemon/names/families.txt new file mode 100644 index 0000000..a1a968e --- /dev/null +++ b/crates/daemon/names/families.txt @@ -0,0 +1,102 @@ +# Families: surnames for homes whose name doesn't already carry one ("The +# Hart house" is the Harts). Dealt by lot, so append only (see letters.rs). +Abara +Whitfield +Moreau +Kowalski +Mensah +Ishikawa +Fairbairn +Delgado +Petrov +Nakamura +Gallagher +Adeyemi +Bergström +Rossi +Thackeray +Mbeki +Aldana +Holloway +Yilmaz +Fitzroy +Chandra +Ostrowski +Pemberton +Achebe +Lemaire +Takahashi +Kearney +Valdez +Sorensen +Iqbal +Marlowe +Oyelaran +Bianchi +Hennessy +Kaminski +Tran +Ellery +Nwosu +Fontaine +Albrecht +Ramírez +Calloway +Szabo +Mwangi +Ashdown +Kovač +Ferreira +Lindgren +Osei +Prescott +Hayashi +Daly +Andrade +Wójcik +Thornbury +Eze +Rinaldi +Kimura +Lachance +Bakshi +Merriweather +Horvath +Ndiaye +Sinclair +Galloway +Mirza +Oyama +Rowntree +Castellano +Brodsky +Achterberg +Kamara +Llewellyn +Fujita +Ortega +Vance +Halvorsen +Adebayo +Romano +Whitlock +Kaur +Dunmore +Paredes +Sokolov +Eriksen +Obi +Montague +Silva +Ainsworth +Nyberg +Banerjee +Corrigan +Lucero +Hartigan +Wainwright +Mutua +Beaumont +Ivanova +Okonkwo +Ashby diff --git a/crates/daemon/src/letters.rs b/crates/daemon/src/letters.rs new file mode 100644 index 0000000..bef20ae --- /dev/null +++ b/crates/daemon/src/letters.rs @@ -0,0 +1,257 @@ +//! Thank-you letters from the residents (`docs/GAME.md`): who writes, and +//! what. Pure; `world.rs` decides when, from the ledger, so letters are +//! folded like everything else and nobody can write one by opening the app. + +use crate::plan::Kind; + +const FAMILIES: &str = include_str!("../names/families.txt"); + +fn families() -> &'static [&'static str] { + static PILE: std::sync::OnceLock> = std::sync::OnceLock::new(); + PILE.get_or_init(|| { + FAMILIES + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .collect() + }) +} + +/// Cottages and apartments: the buildings families live in. +pub fn is_home(kind: Kind) -> bool { + matches!(kind, Kind::Cottage | Kind::Apartment) +} + +/// The family in a home: the one its name gives ("The Hart house"), else +/// one dealt by lot from the pile. Append-only pile, so a family never +/// changes once dealt. +pub fn family(lot: usize, name: &str) -> String { + let surname = name + .strip_prefix("The ") + .and_then(|n| n.strip_suffix(" house")) + .filter(|n| !n.contains(' ')) + .unwrap_or_else(|| { + let pile = families(); + pile[lot % pile.len()] + }); + format!("The {surname} family") +} + +/// What a letter thanks the mayor for. +#[derive(Debug, Clone, Copy)] +pub enum About<'a> { + /// A street paid up to `class` (1 street, 2 boulevard, 3 avenue). + Street { street: &'a str, class: u32 }, + /// A damaged building whole again; `own`: the writers' own home. + Repaired { building: &'a str, own: bool }, + /// A decoration placed in a park, by id (`DECORATIONS`). + Decoration { decoration: &'a str, park: &'a str }, + /// A public building finished. + Opened { + kind: Kind, + building: &'a str, + street: &'a str, + }, + /// Every lot on a street, or in a neighbourhood, built. + Set { name: &'a str, neighbourhood: bool }, +} + +impl About<'_> { + pub fn kind(&self) -> &'static str { + match self { + About::Street { .. } => "street", + About::Repaired { .. } => "repair", + About::Decoration { .. } => "park", + About::Opened { .. } => "opened", + About::Set { .. } => "set", + } + } +} + +/// The letter's body. `roll` picks among a few wordings. +pub fn write(about: About, roll: u64) -> String { + let pick = |options: &[String]| options[(roll % options.len() as u64) as usize].clone(); + match about { + About::Street { street, class } => match class { + 1 => pick(&[ + format!( + "Thank you for the tarmac on {street}. The children ride their bikes to the end and back until it gets dark, and now the lamps let them stay out a little longer." + ), + format!( + "No more mud on {street}! We can finally hang the washing out without it coming back spotted. The new lamps make the walk home feel safe." + ), + ]), + 2 => pick(&[ + format!( + "The trees along {street} are in. We sit on the step in the evening and watch the leaves move. It feels like a proper town now." + ), + format!( + "Thank you for the boulevard. {street} has shade all afternoon, and the birds have found it before we did." + ), + ]), + _ => pick(&[ + format!( + "{street} is an avenue! People from the other side of town walk down it just to look. We tell them we live here." + ), + format!( + "We never thought our street would be the grand one. Thank you for making {street} an avenue." + ), + ]), + }, + About::Repaired { + building, + own: true, + } => pick(&[ + format!( + "Thank you for mending {building}. We are back in our own beds tonight, and the kettle is on." + ), + format!( + "{building} is whole again. We were afraid you'd forgotten us. We know now you hadn't." + ), + ]), + About::Repaired { + building, + own: false, + } => pick(&[ + format!( + "{building} stands whole again. The whole street breathed out when the scaffolding came down." + ), + format!( + "It was hard to walk past {building} while it was broken. Thank you for putting it right." + ), + ]), + About::Decoration { decoration, park } => { + let thing = match decoration { + "trees" => "new trees", + "benches" => "benches and lamps", + "statue" => "statue of the founder", + other => other, + }; + pick(&[ + format!( + "We took a picnic to {park} on Sunday to see the {thing}. Half the town had the same idea." + ), + format!( + "Thank you for the {thing} in {park}. Our youngest says it's the best thing in the whole world, and this week we agree." + ), + ]) + } + // Public buildings by what they are ("the new school"): their plan + // names are labels, not names to put in a sentence. + About::Opened { + kind, + building, + street, + } => match kind { + Kind::Water => pick(&[ + "Running water! No more carrying buckets up from the river. Thank you.".to_string(), + "The taps run clear and strong now, even on the top floors. Thank you for the new waterworks.".to_string(), + ]), + Kind::Power => pick(&[ + format!("The lights came on all at once last night, every house on {street}. We stood in the road and clapped."), + "No more flickering in the evenings. The new power station hums away, and we can all read past dark.".to_string(), + ]), + Kind::School => pick(&[format!( + "Our two walk to the new school on {street} now. They complain about homework, which means they're learning." + )]), + Kind::Clinic => pick(&[ + "When our father fell ill last week, the clinic was there, five minutes away. Thank you.".to_string(), + ]), + Kind::Hospital => pick(&[ + "Our daughter was born at the new hospital this week. We'll tell her the town built it for her.".to_string(), + ]), + Kind::FireStation => pick(&[format!( + "We sleep better knowing the fire station is on {street}. The children wave at the engine every morning." + )]), + Kind::Police => pick(&[ + "Bikes stay where we leave them now. Thank you for the police station.".to_string(), + ]), + Kind::Library => pick(&[ + "The library is open, and our eldest has read half of it already. Thank you for somewhere quiet.".to_string(), + ]), + Kind::CityHall => pick(&[format!( + "We walked past the new city hall on {street} today and felt proud to live here. A town needs a heart." + )]), + Kind::Square => pick(&[ + "There's a market in the square on Saturdays now. We meet half our neighbours there before nine.".to_string(), + ]), + Kind::Park => pick(&[ + "The new park is the best thing in town. We go every evening, rain or not.".to_string(), + ]), + _ => pick(&[format!( + "{building} on {street} opened this week. It feels like the town is looking after us." + )]), + }, + About::Set { + name, + neighbourhood: false, + } => pick(&[ + format!( + "Every house on {name} is built. We held a street party, and everyone asked us to say thank you." + ), + format!("{name} is finished, end to end. We know all our neighbours by name now."), + ]), + About::Set { + name, + neighbourhood: true, + } => pick(&[ + format!( + "All of {name} is built. When people ask where we live, we say {name}, and they know it." + ), + format!( + "{name} is complete. We walked every street of it this evening, and there wasn't an empty plot left." + ), + ]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn families_come_from_the_house_name_or_the_pile() { + assert_eq!(family(4, "The Hart house"), "The Hart family"); + assert_eq!( + family(0, "Willowbank"), + format!("The {} family", families()[0]) + ); + let n = families().len(); + assert_eq!(family(n + 1, "Rosemount"), family(1, "Fernlea")); + let mut seen = std::collections::HashSet::new(); + assert!( + families().iter().all(|f| seen.insert(*f)), + "no family twice" + ); + } + + #[test] + fn letters_name_what_they_thank_for() { + for roll in 0..4 { + let t = write( + About::Street { + street: "Mill Lane", + class: 2, + }, + roll, + ); + assert!(t.contains("Mill Lane"), "{t}"); + let t = write( + About::Set { + name: "Millbrook", + neighbourhood: true, + }, + roll, + ); + assert!(t.contains("Millbrook"), "{t}"); + let t = write( + About::Decoration { + decoration: "fountain", + park: "The Green", + }, + roll, + ); + assert!(t.contains("fountain") && t.contains("The Green"), "{t}"); + } + } +} diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index a451039..e473aef 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -8,6 +8,7 @@ mod enforcer; mod engine; mod free; mod layout; +mod letters; mod loosen; mod names; mod nfqueue; diff --git a/crates/daemon/src/world.rs b/crates/daemon/src/world.rs index 9500bfa..13c43b4 100644 --- a/crates/daemon/src/world.rs +++ b/crates/daemon/src/world.rs @@ -26,8 +26,8 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use deepwork_proto::{ Award, BlockInfo, BlueprintInfo, BoxInfo, BuildPart, Construction, DecorationInfo, GoalInfo, - ItemInfo, Layout, LotInfo, Memory, NeedInfo, NeighbourhoodInfo, Plot, Scar, SetInfo, SiteInfo, - SpotInfo, StreetInfo, World, WorldCause, WorldEvent, WorldEventKind, + ItemInfo, Layout, Letter, LotInfo, Memory, NeedInfo, NeighbourhoodInfo, Plot, Scar, SetInfo, + SiteInfo, SpotInfo, StreetInfo, World, WorldCause, WorldEvent, WorldEventKind, }; use jiff::Timestamp; use jiff::civil::Date; @@ -35,7 +35,8 @@ use jiff::tz::TimeZone; use crate::economy::{self, Goal, Item}; use crate::layout::{self, Axis, Ground, NEIGHBOURHOODS, STREETS}; -use crate::plan::{self, BLUEPRINTS, Blueprint, NEEDS, Need}; +use crate::letters::{self, About}; +use crate::plan::{self, BLUEPRINTS, Blueprint, Kind, NEEDS, Need}; const COINS_PER_HOUR: u64 = 100; /// Coin rate per minute of a session, in tenths, by how far into the session /// the minute is: depth pays, but a stopwatch left running does not. @@ -62,6 +63,10 @@ const FIRE_DAMAGE: u32 = 2; /// One burst of tamper (or minor) events counts once. const PENALTY_COOLDOWN: i64 = 10 * 60; const HISTORY: usize = 30; +/// Letters the world shows, newest last. +const LETTERS: usize = 30; +/// When no family lives on the street, one of this many nearest writes. +const NEAREST_WRITERS: usize = 3; /// Chance in a thousand that a finished building comes out shiny, and the /// most longer sessions can raise it to. const SHINY_BASE: u64 = 50; @@ -247,6 +252,7 @@ pub struct City { since_rare: u32, /// Sets whose box is won. sets_won: BTreeSet, + letters: Vec, } /// A collection set: the plan's lots on one street, or in one neighbourhood. @@ -358,6 +364,7 @@ impl Default for City { inventory: BTreeMap::new(), since_rare: 0, sets_won: BTreeSet::new(), + letters: Vec::new(), } } } @@ -484,6 +491,17 @@ impl City { if let Ok((si, price)) = self.check_upgrade(street) { self.coins_spent += price; self.roads[si] += 1; + let s = STREETS[si]; + let near = match s.axis { + Axis::EastWest => (2, s.line * 3), + Axis::NorthSouth => (s.line * 5 - 1, 0), + }; + let name = self.street_name(si).to_string(); + let about = About::Street { + street: &name, + class: self.roads[si], + }; + self.letter(f.ts, si as u64, Some(si), near, about, None); } } (FactKind::StreetRenamed { street, name }, _) => { @@ -498,6 +516,19 @@ impl City { if let Ok((d, price)) = self.check_decorate(spot, decoration) { self.coins_spent += price; self.spots.insert(spot.clone(), d.id); + let b = spot + .split_once(':') + .and_then(|(b, _)| b.parse::().ok()); + if let Some(block) = b.map(|b| layout::blocks()[b]) + && let Ground::Park(_, park) = block.ground + { + let near = (block.x * 5 + 2, block.y * 3 + 1); + let about = About::Decoration { + decoration: d.id, + park, + }; + self.letter(f.ts, block.x as u64 ^ 0x5eed, None, near, about, None); + } } } (FactKind::Staked { coins }, Some(id)) => { @@ -589,6 +620,74 @@ impl City { vec![i as u32], vec![], ); + self.thank_for_repair(i, ts); + } + + /// The family of a mended home thanks the mayor; for any other building, + /// the family nearest it. + fn thank_for_repair(&mut self, i: usize, ts: i64) { + let name = self.name(i); + let own = letters::is_home(need_of(i).kind); + let about = About::Repaired { + building: &name, + own, + }; + if own { + self.send(ts, i, about, i as u64, Some(i)); + } else { + self.letter(ts, i as u64, None, ground_pos(i), about, Some(i)); + } + } + + /// Finished homes that stand: the families who can write. + fn homes(&self) -> impl Iterator + '_ { + self.standing() + .filter(|&i| letters::is_home(need_of(i).kind)) + } + + /// A letter from a family on `street` if any lives there, else one of + /// the few nearest `near` (in `ground_pos` units), so one house doesn't + /// write them all. No families yet, no letter. + fn letter( + &mut self, + ts: i64, + salt: u64, + street: Option, + near: (i32, i32), + about: About, + lot: Option, + ) { + let roll = mix((ts as u64).rotate_left(11) ^ salt.wrapping_mul(0x2545_f491)); + let on: Vec = self + .homes() + .filter(|&h| Some(plan::lots()[h].place.street) == street) + .collect(); + let pool = if on.is_empty() { + let mut by_distance: Vec = self.homes().collect(); + by_distance.sort_by_key(|&h| { + let (x, y) = ground_pos(h); + ((x - near.0).pow(2) + (y - near.1).pow(2), h) + }); + by_distance.truncate(NEAREST_WRITERS); + by_distance + } else { + on + }; + let writer = (!pool.is_empty()).then(|| pool[(roll % pool.len() as u64) as usize]); + if let Some(w) = writer { + self.send(ts, w, about, roll, lot); + } + } + + fn send(&mut self, ts: i64, writer: usize, about: About, roll: u64, lot: Option) { + self.letters.push(Letter { + ts, + kind: about.kind().into(), + from: letters::family(writer, &self.name(writer)), + address: self.address(writer), + text: letters::write(about, roll), + lot: lot.map(|l| l as u32), + }); } /// Whether a block is part of the town: land in it bought, or an open park. @@ -684,6 +783,16 @@ impl City { if let Some(a) = self.award.as_mut() { a.boxes.push(won); } + let name = self.set_name(set); + let (street, neighbourhood) = match set { + Set::Street(si) => (Some(si), false), + Set::Hood(_) => (None, true), + }; + let about = About::Set { + name: &name, + neighbourhood, + }; + self.letter(ts, set.salt(), street, ground_pos(i), about, None); } } @@ -1095,6 +1204,24 @@ impl City { a.finished.push(i as u32); } self.sets_finished(i, ts); + let kind = need_of(i).kind; + if !letters::is_home(kind) + && !matches!( + kind, + Kind::Shop | Kind::Workshop | Kind::Office | Kind::Tower + ) + { + let (name, street) = ( + self.name(i), + self.street_name(plan::lots()[i].place.street).to_string(), + ); + let about = About::Opened { + kind, + building: &name, + street: &street, + }; + self.letter(ts, i as u64, None, ground_pos(i), about, Some(i)); + } while (self.level as usize) < plan::LEVELS.len() && NEEDS .iter() @@ -1346,6 +1473,18 @@ impl City { } if !repaired.is_empty() { repaired.sort_unstable(); + // One letter for the session: from a home made whole, else + // about the first building that is. + let whole: Vec = repaired + .iter() + .map(|&i| i as usize) + .filter(|&i| self.lots[i].damage == 0) + .collect(); + let thanks = whole + .iter() + .copied() + .find(|&i| letters::is_home(need_of(i).kind)) + .or(whole.first().copied()); self.record( ts, (WorldEventKind::Repaired, WorldCause::Completed), @@ -1353,6 +1492,9 @@ impl City { repaired, vec![], ); + if let Some(i) = thanks { + self.thank_for_repair(i, ts); + } } } @@ -1647,6 +1789,7 @@ impl City { }) .collect(), boxes_to_rare: economy::PITY.saturating_sub(self.since_rare), + letters: self.letters[self.letters.len().saturating_sub(LETTERS)..].to_vec(), sets: Set::all() .map(|set| SetInfo { id: set.id(), @@ -2387,6 +2530,54 @@ mod tests { ); } + #[test] + fn families_write_to_thank_the_mayor() { + let mut facts = daily(1, 60); + facts.push(event(DAY + 10, FactKind::Denied)); + let w = world(&facts, DAY + 20); + assert!(w.letters.is_empty(), "damage gets no thanks"); + facts.push(mayor( + DAY + 30, + FactKind::RepairBought { + lot: "hamlet-homes-1".into(), + }, + )); + let w = world(&facts, DAY + 40); + let l = w.letters.last().unwrap(); + assert_eq!((l.kind.as_str(), l.lot), ("repair", Some(0))); + assert_eq!(l.address, w.plots[0].address, "the mended home writes"); + assert!(l.from.starts_with("The ") && l.from.ends_with(" family")); + + // A paved street: a family living on it; a park: the nearest one. + let mut facts = daily(4, 180); + let green = layout::blocks() + .iter() + .position(|b| matches!(b.ground, Ground::Park(_, "The Green"))) + .unwrap(); + facts.push(mayor( + 4 * DAY + 20, + FactKind::StreetUpgraded { + street: "main".into(), + }, + )); + facts.push(mayor( + 4 * DAY + 40, + FactKind::Decorated { + spot: format!("{green}:1"), + decoration: "trees".into(), + }, + )); + let w = world(&facts, 4 * DAY + 50); + let street = w.letters.iter().find(|l| l.kind == "street").unwrap(); + assert!(street.address.ends_with("Main Street") && street.text.contains("Main Street")); + let park = w.letters.iter().find(|l| l.kind == "park").unwrap(); + assert!(park.text.contains("The Green") && park.text.contains("new trees")); + // The hamlet's water is a public building: its opening is thanked. + assert!(w.letters.iter().any(|l| l.kind == "opened")); + // Same facts, same letters. + assert_eq!(world(&facts, 4 * DAY + 50).letters, w.letters); + } + #[test] fn decorations_go_on_open_parks() { let facts = daily(3, 180); diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index b1e8128..fb92c01 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -363,6 +363,9 @@ pub struct World { /// Streets and neighbourhoods with lots, as collection sets. #[serde(default)] pub sets: Vec, + /// The residents' latest letters, oldest first. + #[serde(default)] + pub letters: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -475,6 +478,22 @@ pub struct BoxInfo { pub set: Option, } +/// A thank-you letter from a family in the city, for something focus or +/// its coins paid for. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Letter { + pub ts: i64, + /// `street`, `repair`, `park`, `opened` or `set`. + pub kind: String, + /// "The Hart family". + pub from: String, + /// The writers' address. + pub address: String, + pub text: String, + /// The building it is about, by lot index. + pub lot: Option, +} + /// A collection set: every lot on a street, or in a neighbourhood. Building /// the last one wins a box, once. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/docs/APP.md b/docs/APP.md index db352d1..194c956 100644 --- a/docs/APP.md +++ b/docs/APP.md @@ -23,6 +23,7 @@ this are in [DEVELOPMENT.md](DEVELOPMENT.md). | HUD | City card (level, progress, condition), the start card (timer / stopwatch, ring dial, profile, stake, Start), the session card (timer, site progress, throttle stage, surrender with the phrase typed by hand) | | Mayor's desk (`desk.ts`) | Needs, buy plot, design pick, coins, paused sites, "What next?", today's goals, the two sets nearest done, items | | Town hall | Street upgrades and renames, park decorations, every set begun | +| Letters (envelope) | The residents' letters, newest first, with a link to the building. An unread dot; what was read is kept in localStorage, and a first run starts with nothing unread | | Shop | Blueprints for sale with pictures (`city/thumbs.ts`); later levels as silhouettes | | Deed card | A building's memory, rename, paid repair or a repair kit; hover shows a tag. Picking tests lot rectangles, not meshes | | Settings sheet (cog) | Every `config.toml` key: session, site lists per profile, routines and skips, inverted mode, DNS, appearance, backup. Save sends the whole config; loosening shows the 24 h notice | diff --git a/docs/GAME.md b/docs/GAME.md index b91e05b..2f9e29c 100644 --- a/docs/GAME.md +++ b/docs/GAME.md @@ -104,6 +104,23 @@ to the active site; the deed shows it apart from focus. Building its last lot pays one box, once (`STREET_RARE_PCT`, `NEIGHBOURHOOD_RARE_PCT`); ruins and renames don't take it back. +## Letters + +Families live in the homes (cottages and apartments): the one a house's +name gives ("The Hart house"), else one from `names/families.txt` by lot. +They write to the mayor when something reaches them (`letters.rs` words +it, `world.rs` decides when): + +| Event | Who writes | +|---|---| +| A street upgraded | A family on it, else the nearest | +| A building whole again (bought, kit or session repairs) | Its own family, or the nearest | +| A park decoration | The nearest family | +| A public building opens | The nearest family | +| A set finished | A family on the street, else the nearest | + +Letters are folded like the rest and pay nothing: reading one is not a reward. + ## Penalties and repairs | Ledger event | In the city | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index eb7bddc..8b80e92 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,6 +51,8 @@ with a root shell open in another terminal for the PAM checks. - [ ] Names: a new building gets a name from its pile, and its sign shows it. - [ ] Sets: finishing a street's last lot shows its box in the award and the item in the desk. +- [ ] Letters: paving a street brings a letter from a family on it; the + envelope shows the dot, and the letter names the street. - [ ] Model pack: `deepwork-ui` from the next release shows the download line, then the realistic city. - [ ] Install on a real Debian / Ubuntu and Fedora machine and repeat. @@ -98,7 +100,7 @@ Check each against the rule in [GAME.md](GAME.md#the-one-rule). and box odds are first guesses. - **Seasons and quests**: a monthly track advanced by focused hours; quests that train deep work. Seasonal items return in later years. -- **Residents**: named families in the houses; thank-you letters naming - their street; families leave long-unrepaired homes and come back. +- **Residents**: families leave long-unrepaired homes and come back when + they're mended (families and their letters are in [GAME.md](GAME.md#letters)). - **On hold**: endowed progress (sites starting part built); it breaks "a 25 h tower means 25 h". diff --git a/ui/index.html b/ui/index.html index edf0615..2d4946c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -30,6 +30,13 @@ + + +
+
diff --git a/ui/src/letters.test.ts b/ui/src/letters.test.ts new file mode 100644 index 0000000..290c82c --- /dev/null +++ b/ui/src/letters.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; +import { unread } from "./letters"; +import type { Letter } from "./proto"; + +const letter = (ts: number): Letter => ({ ts, kind: "street", from: "The Hart family", address: "9 Main Street", text: "Thank you.", lot: null }); + +test("a first run has nothing unread; after that, letters past the last read", () => { + const ls = [letter(10), letter(20), letter(30)]; + expect(unread(ls, null)).toBe(0); + expect(unread(ls, 20)).toBe(1); + expect(unread(ls, 0)).toBe(3); +}); diff --git a/ui/src/letters.ts b/ui/src/letters.ts new file mode 100644 index 0000000..9ae3545 --- /dev/null +++ b/ui/src/letters.ts @@ -0,0 +1,53 @@ +// The residents' letters (`Letter` in proto.ts): the mailbox's unread count +// and the letters sheet. Which letters were read is a per-window note, like +// the theme: reading earns nothing, so the daemon never needs to know. +import { dayName, escapeHtml } from "./format"; +import type { Letter, World } from "./proto"; + +const KEY = "deepwork.lettersRead"; + +/** The newest letter's time the mailbox was last opened at, or null on a first run. */ +export function readUpTo(): number | null { + try { + const v = localStorage.getItem(KEY); + return v === null ? null : Number(v) || 0; + } catch { + return null; + } +} + +export function markRead(ts: number) { + try { + localStorage.setItem(KEY, String(ts)); + } catch { + // Private windows: unread again next time. + } +} + +/** Letters after `readTs`. A first run (null) starts with none unread, not a backlog. */ +export function unread(letters: Letter[], readTs: number | null): number { + if (readTs === null) return 0; + return letters.filter((l) => l.ts > readTs).length; +} + +export function newest(letters: Letter[]): number { + return letters.reduce((m, l) => Math.max(m, l.ts), 0); +} + +/** Newest first, each with its writers' signature and, when about a building, a link to its deed. */ +export function lettersHtml(w: World, readTs: number | null, now: number): string { + const letters = [...(w.letters ?? [])].reverse(); + if (!letters.length) return `

No letters yet. Families write when something you built or paid for reaches them: a paved street, a mended home, a new school.

`; + const built = new Set(w.plots.map((p) => p.index)); + return `
    ${letters + .map((l) => { + const fresh = readTs !== null && l.ts > readTs; + const see = l.lot != null && built.has(l.lot) ? `` : ""; + return `
  • +
    ${dayName(l.ts, now)}${see}
    +

    ${escapeHtml(l.text)}

    +

    ${escapeHtml(l.from)}, ${escapeHtml(l.address)}

    +
  • `; + }) + .join("")}
`; +} diff --git a/ui/src/main.ts b/ui/src/main.ts index 8a57aaf..e730d62 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -5,6 +5,7 @@ import { deedHtml, deskHtml, levelUpHtml, nearMiss, shopHtml, tagHtml, townHtml import { notice } from "./panels"; import { SURRENDER_PHRASE, type SessionInfo, type Status, type World } from "./proto"; import { openProgress, progressUpdate } from "./progress"; +import { lettersHtml, markRead, newest, readUpTo, unread } from "./letters"; import { confirmSkip, hudUpdate, openSettings, type SettingsSection } from "./settings"; import { City } from "./city/scene"; import { modelsStatus, type ModelsStatus } from "./models"; @@ -379,6 +380,47 @@ function openTown() { renderTown(); if (!townDialog.open) townDialog.showModal(); } +const lettersDialog = $("#letters") as HTMLDialogElement; +let lettersRead = readUpTo(); +/** The newest letter the poll has seen; a newer one is announced. */ +let lettersSeen: number | null = null; + +function renderMailbox() { + const ls = world?.letters ?? []; + if (lettersRead === null && ls.length) { + // A first run: the city's past letters are there to read, not unread. + lettersRead = newest(ls); + markRead(lettersRead); + } + const n = unread(ls, lettersRead); + const dot = $("#mailbox .dot"); + dot.hidden = n === 0; + $("#mailbox").setAttribute("aria-label", n ? `Letters, ${n} unread` : "Letters"); + const top = newest(ls); + if (lettersSeen !== null && top > lettersSeen) { + const l = ls[ls.length - 1]; + story(`A letter from ${l.from}`); + } + lettersSeen = top; +} + +function openLetters() { + if (!world) return; + $("#letters-main").innerHTML = lettersHtml(world, lettersRead, Math.floor(Date.now() / 1000)); + lettersRead = Math.max(lettersRead ?? 0, newest(world.letters ?? [])); + markRead(lettersRead); + renderMailbox(); + if (!lettersDialog.open) lettersDialog.showModal(); +} + +lettersDialog.addEventListener("click", (ev) => { + const btn = (ev.target as HTMLElement).closest("button[data-action]"); + if (btn?.dataset.action === "letter-deed") { + lettersDialog.close(); + openDeed(Number(btn.dataset.lot)); + } +}); + const deedDialog = $("#deed") as HTMLDialogElement; let deedIndex: number | null = null; @@ -420,6 +462,7 @@ async function decide(req: Parameters[0], errorEl: string, after?: if (r.type === "world") { world = r; renderCity(world); + renderMailbox(); renderDesk(true); skyline?.update(world, localHour(), status?.session?.elapsed_secs ?? 0); } @@ -468,7 +511,7 @@ townDialog.addEventListener("submit", (ev) => { void decide({ type: "rename_street", street: form.dataset.street!, name }, "#town-error", renderTown); }); -for (const d of [shopDialog, deedDialog, townDialog]) { +for (const d of [shopDialog, deedDialog, townDialog, lettersDialog]) { d.addEventListener("click", (ev) => { if (ev.target === d || (ev.target as HTMLElement).closest("[data-action=close]")) d.close(); }); @@ -599,6 +642,7 @@ dialog.addEventListener("close", () => { $("#menu").addEventListener("click", (ev) => { const btn = (ev.target as HTMLElement).closest("button"); if (btn?.dataset.open === "progress") openProgress(); + else if (btn?.dataset.open === "letters") openLetters(); else if (btn?.dataset.open === "settings") void openSettings("session"); else if (btn?.dataset.open === "sound") { setSound(!soundOn()); @@ -643,6 +687,7 @@ async function poll() { // The scene starts the card with its fireworks; without one, at once. if (!skyline && was !== undefined && world.tier > was) levelUp(world.tier); renderCity(world); + renderMailbox(); if (deedDialog.open) renderDeed(); if (shopDialog.open) renderShop(); $("#notice").innerHTML = notice(await pendingConfig()); diff --git a/ui/src/mockcity.ts b/ui/src/mockcity.ts index 4f5411b..e852103 100644 --- a/ui/src/mockcity.ts +++ b/ui/src/mockcity.ts @@ -5,7 +5,7 @@ // scenarios get their land free); the daemon is the truth. The layout is // the daemon's own (`layout.json`, kept current by a Rust test). import FIXTURE from "./layout.json"; -import type { Award, BlueprintInfo, Construction, GoalInfo, Layout, NeedInfo, Plot, SetInfo, SiteInfo, World, WorldEvent } from "./proto"; +import type { Award, BlueprintInfo, Letter, Construction, GoalInfo, Layout, NeedInfo, Plot, SetInfo, SiteInfo, World, WorldEvent } from "./proto"; const LAYOUT = FIXTURE as Layout; const LAND: Record = { village: 60, works: 80, civic: 100, main_street: 120, downtown: 250 }; @@ -145,6 +145,21 @@ export class MockCity { spots = new Map(); inventory = new Map(); setsWon = new Set(); + letters: Letter[] = []; + + /** + * A thank-you letter, as the daemon writes them but simpler: from a + * family on the street if one lives there, else any home by the lot; + * one wording per kind. + */ + private letter(ts: number, kind: Letter["kind"], text: string, street: number | null, lot: number | null) { + const homes = [...this.built.keys()].filter((i) => ["cottage", "apartment"].includes(NEEDS[LOTS[i].need].kind)).sort((a, b) => a - b); + const w = homes.find((i) => LAYOUT.lots[i]?.street === street) ?? homes[(lot ?? ts) % homes.length]; + if (w === undefined) return; + const name = this.nameOf(w, this.built.get(w)!.design); + const sur = /^The (\S+) house$/.exec(name)?.[1] ?? SURNAMES[w % SURNAMES.length]; + this.letters.push({ ts, kind, from: `The ${sur} family`, address: LOTS[w].address, text, lot }); + } today = { day: -1, focus: 0, sessions: 0, longest: 0, paid: new Set() }; stakes = new Map(); recent: number[] = []; @@ -236,6 +251,12 @@ export class MockCity { const item = ["repair_kit", "hired_help", "master_crew"][(sessionId + set.id.length) % 3]; this.inventory.set(item, (this.inventory.get(item) ?? 0) + 1); this.award?.boxes?.push({ goal: "set", item, set: set.name }); + const street = set.id.startsWith("street:") ? LAYOUT.streets.findIndex((s) => `street:${s.id}` === set.id) : null; + this.letter(ts, "set", `${set.name} is finished, end to end. We held a party, and everyone asked us to say thank you.`, street, null); + } + const kind = NEEDS[LOTS[i].need].kind; + if (!["cottage", "apartment", "shop", "workshop", "office", "tower"].includes(kind)) { + this.letter(ts, "opened", `${this.nameOf(i, design)} opened this week. It feels like the town is looking after us.`, null, i); } while (this.level < 4 && NEEDS.every((n, ni) => n.level !== this.level || this.nextLot(ni) === null)) { this.level++; @@ -338,7 +359,9 @@ export class MockCity { if (!kit && this.coins() < price) return `repairing it costs ${price} coins; you have ${this.coins()}`; if (!kit) this.spent += price; b.damage = 0; - this.history.push({ ts: Math.floor(Date.now() / 1000), kind: "repaired", cause: "mayor", session_id: null, plots: [l.index], ruined: [] }); + const ts = Math.floor(Date.now() / 1000); + this.history.push({ ts, kind: "repaired", cause: "mayor", session_id: null, plots: [l.index], ruined: [] }); + this.letter(ts, "repair", `Thank you for mending ${this.nameOf(l.index, b.design)}. We are back in our own beds tonight, and the kettle is on.`, LAYOUT.lots[l.index]?.street ?? null, l.index); return null; } @@ -351,6 +374,8 @@ export class MockCity { if (this.coins() < next[0]) return `that costs ${next[0]} coins; you have ${this.coins()}`; this.spent += next[0]; this.roads[si]++; + const name = this.streetNames.get(si) ?? LAYOUT.streets[si].name; + this.letter(Math.floor(Date.now() / 1000), "street", `Thank you for what you did to ${name}. The children ride their bikes to the end and back until it gets dark.`, si, null); return null; } @@ -390,6 +415,8 @@ export class MockCity { if (this.coins() < d.price) return `a ${d.name.toLowerCase()} costs ${d.price} coins; you have ${this.coins()}`; this.spent += d.price; this.spots.set(spot, id); + const park = LAYOUT.blocks[Number(spot.split(":")[0])]?.name ?? "the park"; + this.letter(Math.floor(Date.now() / 1000), "park", `We took a picnic to ${park} on Sunday to see the ${d.name.toLowerCase()}. Half the town had the same idea.`, null, null); return null; } @@ -564,6 +591,7 @@ export class MockCity { goals: this.goals(now), boxes_to_rare: 5, sets: this.sets(), + letters: this.letters.slice(-30), }; } diff --git a/ui/src/proto.ts b/ui/src/proto.ts index d37e09e..f554d60 100644 --- a/ui/src/proto.ts +++ b/ui/src/proto.ts @@ -349,6 +349,19 @@ export interface BoxInfo { set?: string | null; } +/** A thank-you letter from a family in the city, for something focus or its coins paid for. */ +export interface Letter { + ts: number; + kind: "street" | "repair" | "park" | "opened" | "set"; + /** "The Hart family". */ + from: string; + /** The writers' address. */ + address: string; + text: string; + /** The building it is about, by lot index. */ + lot: number | null; +} + /** A collection set: every lot on a street, or in a neighbourhood. The last one built wins a box, once. */ export interface SetInfo { /** `street:` or `hood:`. */ @@ -483,6 +496,8 @@ export interface World { boxes_to_rare?: number; /** Streets and neighbourhoods with lots, as collection sets. */ sets?: SetInfo[]; + /** The residents' latest letters, oldest first. */ + letters?: Letter[]; } export const MAX_PAUSED = 3; diff --git a/ui/src/style.css b/ui/src/style.css index 6aa0e53..8989d98 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -113,6 +113,15 @@ code { font-family: var(--font-mono); } #menu { align-self: flex-start; display: flex; gap: 2px; padding: 3px; } #menu button { display: flex; align-items: center; padding: 7px; border: none; background: none; color: var(--muted-foreground); border-radius: 8px; box-shadow: none; } #menu button:hover { background: var(--muted); color: var(--foreground); } +#menu button { position: relative; } +#menu .dot { position: absolute; top: 5px; right: 5px; width: 7px; height: 7px; border-radius: 50%; background: var(--brand); box-shadow: 0 0 0 2px var(--card); } +#menu .dot[hidden] { display: none; } +.letters { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.letter { padding: 12px 14px; border-radius: 10px; background: var(--background); border: 1px solid var(--border); } +.letter.fresh { border-color: color-mix(in oklab, var(--brand) 55%, var(--border)); background: var(--brand-soft); } +.letter-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.letter p { margin: 6px 0 0; font-size: 13.5px; line-height: 1.5; } +.letter .sign { font-style: italic; } #side { position: absolute; top: 16px; right: 16px; width: 300px; display: flex; flex-direction: column; gap: 10px; } #side .card { position: static; } #notice { font-size: 12.5px; }