summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.rs110
-rw-r--r--src/cli.rs156
-rw-r--r--src/dungeon/crawl.rs416
-rw-r--r--src/dungeon/fov.rs207
-rw-r--r--src/dungeon/mod.rs156
-rw-r--r--src/dungeon/ship.rs452
-rw-r--r--src/main.rs43
-rw-r--r--src/names/artifact.rs69
-rw-r--r--src/names/mod.rs319
-rw-r--r--src/scenes/derelict.rs413
-rw-r--r--src/scenes/forge.rs154
-rw-r--r--src/scenes/mod.rs74
-rw-r--r--src/scenes/title.rs224
-rw-r--r--src/seed.rs93
-rw-r--r--src/term.rs50
15 files changed, 2936 insertions, 0 deletions
diff --git a/src/app.rs b/src/app.rs
new file mode 100644
index 0000000..e3fe72a
--- /dev/null
+++ b/src/app.rs
@@ -0,0 +1,110 @@
+//! The game loop and the scene stack.
+
+use std::io;
+use std::time::{Duration, Instant};
+
+use ratatui::DefaultTerminal;
+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
+
+use crate::names::Artifact;
+use crate::scenes::{Scene, Transition, title::Title};
+use crate::term::{self, KeyboardCaps};
+
+/// Simulation rate. Real-time scenes (flight) move by this many steps a
+/// second; turn-based scenes (dungeons) can ignore ticks and react to keys.
+pub const TICKS_PER_SECOND: u32 = 30;
+const TICK: Duration = Duration::from_nanos(1_000_000_000 / TICKS_PER_SECOND as u64);
+/// After a stall (suspended with ^Z, a slow terminal), run at most this many
+/// missed ticks, then carry on from now instead of fast-forwarding.
+const MAX_CATCH_UP: u32 = 5;
+
+/// State every scene can see.
+pub struct Ctx {
+ /// The universe seed. Everything procedural derives from it (see `seed`).
+ pub seed: u64,
+ /// Ticks since the game started.
+ pub ticks: u64,
+ pub keyboard: KeyboardCaps,
+ /// How many derelicts you've boarded. Each boarding is a different ship,
+ /// `derive(seed, "derelict", n)`.
+ pub boarded: u64,
+ pub hold: Hold,
+}
+
+impl Ctx {
+ pub fn new(seed: u64, keyboard: KeyboardCaps) -> Ctx {
+ Ctx { seed, ticks: 0, keyboard, boarded: 0, hold: Hold::default() }
+ }
+}
+
+/// What you've brought back from expeditions.
+#[derive(Default)]
+pub struct Hold {
+ pub artifacts: Vec<Artifact>,
+ /// Salvage, counted by what it'll sell for.
+ pub scrip: u32,
+}
+
+pub fn run(seed: u64) -> io::Result<()> {
+ let (mut terminal, keyboard) = term::init()?;
+ let ctx = Ctx::new(seed, keyboard);
+ let result = run_loop(&mut terminal, ctx);
+ term::restore(keyboard);
+ result
+}
+
+fn run_loop(terminal: &mut DefaultTerminal, mut ctx: Ctx) -> io::Result<()> {
+ let mut stack: Vec<Box<dyn Scene>> = vec![Box::new(Title::new(&ctx))];
+ let mut next_tick = Instant::now() + TICK;
+
+ while let Some(top) = stack.last() {
+ terminal.draw(|frame| top.render(frame, &ctx))?;
+
+ // Wait for input until the next tick is due, then take whatever else
+ // is already queued without waiting again.
+ let mut timeout = next_tick.saturating_duration_since(Instant::now());
+ while event::poll(timeout)? {
+ timeout = Duration::ZERO;
+ let Event::Key(key) = event::read()? else {
+ continue;
+ };
+ if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
+ return Ok(());
+ }
+ // Scenes get presses and auto-repeats. Releases only arrive on
+ // terminals with `ctx.keyboard.key_release`; when flight needs
+ // held keys, track them here before this filter.
+ if key.kind == KeyEventKind::Release {
+ continue;
+ }
+ let Some(top) = stack.last_mut() else { break };
+ let transition = top.key(key, &mut ctx);
+ apply(&mut stack, transition);
+ }
+
+ let mut caught_up = 0;
+ while Instant::now() >= next_tick {
+ let Some(top) = stack.last_mut() else { break };
+ ctx.ticks += 1;
+ let transition = top.tick(&mut ctx);
+ apply(&mut stack, transition);
+ next_tick += TICK;
+ caught_up += 1;
+ if caught_up == MAX_CATCH_UP {
+ next_tick = Instant::now() + TICK;
+ }
+ }
+ }
+ Ok(())
+}
+
+fn apply(stack: &mut Vec<Box<dyn Scene>>, transition: Transition) {
+ match transition {
+ Transition::None => {}
+ Transition::Push(scene) => stack.push(scene),
+ Transition::Pop => {
+ stack.pop();
+ }
+ Transition::Quit => stack.clear(),
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000..70e9156
--- /dev/null
+++ b/src/cli.rs
@@ -0,0 +1,156 @@
+//! Command-line arguments, `orrery names` and `orrery ship`.
+
+use crate::dungeon::Pos;
+use crate::dungeon::crawl::Crawl;
+use crate::names::{self, Culture};
+use crate::scenes::derelict::{item_look, tile_look};
+use crate::seed;
+
+pub const USAGE: &str = "\
+usage: orrery [--seed N]
+ orrery names [--culture NAME] [-n COUNT] [--seed N]
+ orrery ship [-n WHICH] [--seed N]
+
+ --seed N universe seed; the same seed always makes the same universe
+ names print generated names instead of playing
+ --culture NAME qaari, cant, houses or drift (default: all four)
+ -n COUNT how many of each kind to print (default: 8)
+ ship print a whole derelict, as you'd find it on boarding
+ -n WHICH which derelict: the first you'd board, the second... (default: 1)
+";
+
+pub enum Command {
+ Play { seed: Option<u64> },
+ Names(NamesOpts),
+ Ship { seed: u64, which: u64 },
+ Help,
+}
+
+pub struct NamesOpts {
+ pub cultures: Vec<Culture>,
+ pub count: usize,
+ pub seed: u64,
+}
+
+pub fn parse(args: &[String]) -> Result<Command, String> {
+ let mut args = args.iter().map(String::as_str).peekable();
+ let sub = args.next_if(|a| matches!(*a, "names" | "ship"));
+ let (mut seed, mut culture, mut count) = (None, None, None);
+ while let Some(arg) = args.next() {
+ match arg {
+ "-h" | "--help" => return Ok(Command::Help),
+ "--seed" => seed = Some(number(args.next(), "--seed")?),
+ "-n" if sub.is_some() => count = Some(number(args.next(), "-n")?),
+ "--culture" if sub == Some("names") => {
+ let name = args.next().ok_or("--culture needs a value")?;
+ culture = Some(Culture::parse(name).ok_or(format!("no culture called {name:?}"))?);
+ }
+ other => return Err(format!("unexpected argument {other:?}")),
+ }
+ }
+ match sub {
+ None => Ok(Command::Play { seed }),
+ Some("ship") => Ok(Command::Ship {
+ seed: seed.unwrap_or_else(seed::from_clock),
+ which: count.unwrap_or(1),
+ }),
+ _ => Ok(Command::Names(NamesOpts {
+ cultures: culture.map_or(Culture::ALL.to_vec(), |c| vec![c]),
+ count: count.unwrap_or(8) as usize,
+ seed: seed.unwrap_or_else(seed::from_clock),
+ })),
+ }
+}
+
+/// Prints the `which`th derelict you'd board in universe `seed`, everything
+/// on it revealed.
+pub fn print_ship(seed: u64, which: u64) {
+ let crawl = Crawl::new(seed::derive(seed, "derelict", which));
+ let map = &crawl.map;
+ println!(
+ "{}, a derelict {} (universe {seed}, derelict {which})\n",
+ crawl.name, crawl.description
+ );
+ for y in 0..map.height {
+ let row: String = (0..map.width)
+ .map(|x| {
+ let p = Pos::new(x, y);
+ if p == crawl.player {
+ '@'
+ } else if let Some(m) = crawl.monster_at(p) {
+ m.kind.glyph
+ } else if let Some(l) = crawl.loot_at(p) {
+ item_look(&l.item).0
+ } else {
+ tile_look(map.tile(p)).0
+ }
+ })
+ .collect();
+ println!("{}", row.trim_end());
+ }
+ let mut rooms: Vec<&str> = map.rooms.iter().map(|r| r.name).collect();
+ rooms.sort_unstable();
+ rooms.dedup();
+ println!("\n@ you Θ airlock + door * artifact % salvage ! medpatch");
+ println!(
+ "monsters: {}",
+ crawl.monsters.iter().map(|m| m.kind.name).collect::<Vec<_>>().join(", ")
+ );
+ println!("rooms: {}", rooms.join(", "));
+}
+
+fn number(value: Option<&str>, flag: &str) -> Result<u64, String> {
+ let value = value.ok_or(format!("{flag} needs a value"))?;
+ value.parse().map_err(|_| format!("{flag}: {value:?} isn't a whole number"))
+}
+
+pub fn print_names(opts: &NamesOpts) {
+ println!("universe {} (pass --seed {} to get these again)", opts.seed, opts.seed);
+ for &culture in &opts.cultures {
+ // Each culture draws from its own seed, so `--culture` alone prints
+ // the same names as that culture's section of the full list.
+ let mut rng = seed::rng(seed::derive(opts.seed, culture.name(), 0));
+ let people: Vec<_> = (0..opts.count).map(|_| culture.person(&mut rng)).collect();
+ let places: Vec<_> = (0..opts.count).map(|_| culture.place(&mut rng)).collect();
+ println!("\n{}: {}", culture.name().to_uppercase(), culture.blurb());
+ println!(" {:<36}places", "people");
+ for (person, place) in people.iter().zip(&places) {
+ println!(" {person:<36}{place}");
+ }
+ }
+ if opts.cultures.len() == Culture::ALL.len() {
+ let mut rng = seed::rng(seed::derive(opts.seed, "artifacts", 0));
+ println!("\nARTIFACTS: as found -> once identified");
+ for _ in 0..opts.count {
+ let a = names::artifact(&mut rng);
+ println!(" {:<40}{}", a.unidentified, a.true_name);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn args(s: &str) -> Vec<String> {
+ s.split_whitespace().map(String::from).collect()
+ }
+
+ #[test]
+ fn parses_play_and_names() {
+ assert!(matches!(parse(&args("")), Ok(Command::Play { seed: None })));
+ assert!(matches!(parse(&args("--seed 42")), Ok(Command::Play { seed: Some(42) })));
+ let Ok(Command::Names(opts)) = parse(&args("names --culture drift -n 3 --seed 9")) else {
+ panic!("names didn't parse");
+ };
+ assert_eq!((opts.cultures, opts.count, opts.seed), (vec![Culture::Drift], 3, 9));
+ }
+
+ #[test]
+ fn rejects_names_flags_when_playing() {
+ assert!(parse(&args("-n 3")).is_err());
+ assert!(parse(&args("--culture cant")).is_err());
+ assert!(parse(&args("names --culture nobody")).is_err());
+ assert!(parse(&args("--seed lots")).is_err());
+ }
+}
diff --git a/src/dungeon/crawl.rs b/src/dungeon/crawl.rs
new file mode 100644
index 0000000..c5196b9
--- /dev/null
+++ b/src/dungeon/crawl.rs
@@ -0,0 +1,416 @@
+//! The rules of a crawl: moving, fighting, picking things up, and what the
+//! derelict's inhabitants do each turn. Nothing here draws; the scene turns
+//! keys into `Action`s and draws whatever state results.
+
+use rand::RngExt;
+
+use super::fov::{self, Fov};
+use super::ship;
+use super::{Map, Pos, Tile};
+use crate::names::Artifact;
+use crate::seed::{self, Rng};
+
+/// How far your lamp reaches, in cells.
+pub const SIGHT_RADIUS: i32 = 8;
+const PLAYER_HP: i32 = 20;
+/// Your cutting torch.
+const PLAYER_DAMAGE: (i32, i32) = (2, 5);
+const PLAYER_HIT: f64 = 0.8;
+const MONSTER_HIT: f64 = 0.7;
+const MEDPATCH_HEAL: i32 = 8;
+const LOG_LENGTH: usize = 50;
+
+pub struct MonsterKind {
+ pub name: &'static str,
+ pub glyph: char,
+ pub rgb: (u8, u8, u8),
+ pub hp: i32,
+ pub damage: (i32, i32),
+ /// As in "the rust-mite bites you".
+ pub verb: &'static str,
+ /// How common it is, relative to the others.
+ pub weight: u32,
+ /// At most one aboard any ship.
+ pub unique: bool,
+}
+
+#[rustfmt::skip]
+pub const MONSTERS: &[MonsterKind] = &[
+ MonsterKind { name: "rust-mite", glyph: 'm', rgb: (196, 112, 60), hp: 3, damage: (1, 2), verb: "bites", weight: 5, unique: false },
+ MonsterKind { name: "maintenance drone", glyph: 'd', rgb: (150, 175, 200), hp: 6, damage: (1, 3), verb: "zaps", weight: 3, unique: false },
+ MonsterKind { name: "hollow crewman", glyph: 'h', rgb: (160, 200, 150), hp: 9, damage: (2, 4), verb: "claws at", weight: 2, unique: false },
+ MonsterKind { name: "choir-wraith", glyph: 'W', rgb: (200, 150, 255), hp: 14, damage: (3, 6), verb: "sings into", weight: 1, unique: true },
+];
+
+pub struct Monster {
+ pub kind: &'static MonsterKind,
+ pub pos: Pos,
+ pub hp: i32,
+ /// Dormant until it comes into view; then it hunts you.
+ pub awake: bool,
+}
+
+pub enum Item {
+ Artifact(Artifact),
+ Salvage { name: &'static str, value: u32 },
+ Medpatch,
+}
+
+pub struct Loot {
+ pub pos: Pos,
+ pub item: Item,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Outcome {
+ /// Back through the airlock, with everything carried.
+ Escaped,
+ Died {
+ killer: &'static str,
+ },
+}
+
+#[derive(Clone, Copy, Debug)]
+pub enum Action {
+ Move(i32, i32),
+ Wait,
+ UseMedpatch,
+}
+
+pub struct Crawl {
+ /// "the Vexant".
+ pub name: String,
+ /// "Cant hauler".
+ pub description: String,
+ pub map: Map,
+ pub player: Pos,
+ pub hp: i32,
+ pub max_hp: i32,
+ pub monsters: Vec<Monster>,
+ pub loot: Vec<Loot>,
+ /// What you're carrying. It's only yours once you're back through the airlock.
+ pub artifacts: Vec<Artifact>,
+ pub scrip: u32,
+ pub medpatches: u32,
+ pub fov: Fov,
+ /// Cells you've seen at some point, drawn from memory when out of sight.
+ seen: Vec<bool>,
+ /// Messages, oldest first.
+ pub log: Vec<String>,
+ pub turns: u32,
+ pub outcome: Option<Outcome>,
+ /// Dice for fights. Separate from the ship's seed, so the same ship and
+ /// the same moves always play out the same way.
+ rng: Rng,
+}
+
+impl Crawl {
+ pub fn new(seed: u64) -> Crawl {
+ let ship = ship::generate(seed::derive(seed, "ship", 0));
+ let cells = (ship.map.width * ship.map.height) as usize;
+ let mut crawl = Crawl {
+ fov: fov::compute(&ship.map, ship.start, SIGHT_RADIUS),
+ name: ship.name,
+ description: ship.description,
+ map: ship.map,
+ player: ship.start,
+ hp: PLAYER_HP,
+ max_hp: PLAYER_HP,
+ monsters: ship.monsters,
+ loot: ship.loot,
+ artifacts: Vec::new(),
+ scrip: 0,
+ medpatches: 0,
+ seen: vec![false; cells],
+ log: Vec::new(),
+ turns: 0,
+ outcome: None,
+ rng: seed::rng(seed::derive(seed, "dice", 0)),
+ };
+ crawl.say(format!("You board {}, a derelict {}.", crawl.name, crawl.description));
+ crawl.look();
+ crawl
+ }
+
+ pub fn seen(&self, p: Pos) -> bool {
+ self.map.contains(p) && self.seen[self.map.index(p)]
+ }
+
+ pub fn monster_at(&self, p: Pos) -> Option<&Monster> {
+ self.monsters.iter().find(|m| m.pos == p)
+ }
+
+ pub fn loot_at(&self, p: Pos) -> Option<&Loot> {
+ self.loot.iter().find(|l| l.pos == p)
+ }
+
+ /// The name of where you're standing.
+ pub fn location(&self) -> &'static str {
+ self.map.room_at(self.player).map_or("a dark passage", |r| r.name)
+ }
+
+ /// Takes your turn, then everything else's. Does nothing once the crawl
+ /// is over, or if the action wouldn't take any time (walking into a wall).
+ pub fn act(&mut self, action: Action) {
+ if self.outcome.is_some() {
+ return;
+ }
+ let took_time = match action {
+ Action::Wait => true,
+ Action::Move(dx, dy) => self.step(dx, dy),
+ Action::UseMedpatch => self.use_medpatch(),
+ };
+ if !took_time || self.outcome.is_some() {
+ return;
+ }
+ self.turns += 1;
+ self.monsters_act();
+ self.look();
+ }
+
+ fn say(&mut self, message: String) {
+ self.log.push(message);
+ if self.log.len() > LOG_LENGTH {
+ self.log.remove(0);
+ }
+ }
+
+ /// Recomputes what you can see, remembers it, and wakes anything that's
+ /// now in view (and so, by symmetry, can see you).
+ fn look(&mut self) {
+ self.fov = fov::compute(&self.map, self.player, SIGHT_RADIUS);
+ for p in self.map.positions() {
+ if self.fov.contains(p) {
+ let i = self.map.index(p);
+ self.seen[i] = true;
+ }
+ }
+ let mut woken = Vec::new();
+ for m in &mut self.monsters {
+ if !m.awake && self.fov.contains(m.pos) {
+ m.awake = true;
+ woken.push(m.kind.name);
+ }
+ }
+ for name in woken {
+ self.say(format!("A {name} stirs and turns toward you."));
+ }
+ }
+
+ fn step(&mut self, dx: i32, dy: i32) -> bool {
+ let target = self.player.offset(dx, dy);
+ if let Some(i) = self.monsters.iter().position(|m| m.pos == target) {
+ self.attack(i);
+ return true;
+ }
+ match self.map.tile(target) {
+ Tile::Door { open: false } => {
+ self.map.set(target, Tile::Door { open: true });
+ self.say("The door grinds open.".into());
+ true
+ }
+ Tile::Airlock => {
+ self.outcome = Some(Outcome::Escaped);
+ self.say("You cycle the airlock and cast off.".into());
+ true
+ }
+ tile if tile.walkable() => {
+ self.player = target;
+ self.pick_up();
+ true
+ }
+ _ => false,
+ }
+ }
+
+ fn pick_up(&mut self) {
+ while let Some(i) = self.loot.iter().position(|l| l.pos == self.player) {
+ match self.loot.remove(i).item {
+ Item::Artifact(artifact) => {
+ self.say(format!("You find {}.", artifact.unidentified));
+ self.artifacts.push(artifact);
+ }
+ Item::Salvage { name, value } => {
+ self.scrip += value;
+ self.say(format!("You salvage {name} ({value} ¤)."));
+ }
+ Item::Medpatch => {
+ self.medpatches += 1;
+ self.say("You pocket a medpatch.".into());
+ }
+ }
+ }
+ }
+
+ fn use_medpatch(&mut self) -> bool {
+ if self.medpatches == 0 {
+ self.say("You have no medpatches.".into());
+ return false;
+ }
+ if self.hp == self.max_hp {
+ self.say("You're not hurt.".into());
+ return false;
+ }
+ self.medpatches -= 1;
+ let healed = MEDPATCH_HEAL.min(self.max_hp - self.hp);
+ self.hp += healed;
+ self.say(format!("The medpatch stings, then numbs (+{healed})."));
+ true
+ }
+
+ fn attack(&mut self, i: usize) {
+ let name = self.monsters[i].kind.name;
+ self.monsters[i].awake = true;
+ if !self.rng.random_bool(PLAYER_HIT) {
+ self.say(format!("Your torch misses the {name}."));
+ return;
+ }
+ let damage = self.rng.random_range(PLAYER_DAMAGE.0..=PLAYER_DAMAGE.1);
+ self.monsters[i].hp -= damage;
+ if self.monsters[i].hp <= 0 {
+ self.monsters.remove(i);
+ self.say(format!("You burn the {name} down to slag."));
+ } else {
+ self.say(format!("You burn the {name} ({damage})."));
+ }
+ }
+
+ /// Each awake monster attacks if it's next to you, or else takes one
+ /// step along the shortest path toward you, opening doors on the way.
+ fn monsters_act(&mut self) {
+ let dist = self.map.distances_from(self.player);
+ for i in 0..self.monsters.len() {
+ if self.outcome.is_some() {
+ return;
+ }
+ let monster = &self.monsters[i];
+ if !monster.awake {
+ continue;
+ }
+ if monster.pos.steps_to(self.player) == 1 {
+ self.monster_attacks(i);
+ continue;
+ }
+ let here = dist[self.map.index(monster.pos)].unwrap_or(u32::MAX);
+ let next = monster
+ .pos
+ .neighbours()
+ .filter(|&n| self.map.contains(n) && self.monster_at(n).is_none())
+ .filter_map(|n| dist[self.map.index(n)].map(|d| (d, n)))
+ .filter(|&(d, _)| d < here)
+ .min_by_key(|&(d, n)| (d, n.y, n.x));
+ if let Some((_, n)) = next {
+ if self.map.tile(n) == (Tile::Door { open: false }) {
+ self.map.set(n, Tile::Door { open: true });
+ } else {
+ self.monsters[i].pos = n;
+ }
+ }
+ }
+ }
+
+ fn monster_attacks(&mut self, i: usize) {
+ let kind = self.monsters[i].kind;
+ if !self.rng.random_bool(MONSTER_HIT) {
+ self.say(format!("The {} misses you.", kind.name));
+ return;
+ }
+ let damage = self.rng.random_range(kind.damage.0..=kind.damage.1);
+ self.hp = (self.hp - damage).max(0);
+ self.say(format!("The {} {} you ({damage}).", kind.name, kind.verb));
+ if self.hp == 0 {
+ self.outcome = Some(Outcome::Died { killer: kind.name });
+ self.say(format!("You die aboard {}.", self.name));
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A bot that wanders at random, for shaking out panics and broken rules.
+ fn wander(crawl: &mut Crawl, rng: &mut Rng, turns: usize) {
+ const ACTIONS: [Action; 10] = [
+ Action::Move(-1, -1),
+ Action::Move(0, -1),
+ Action::Move(1, -1),
+ Action::Move(-1, 0),
+ Action::Move(1, 0),
+ Action::Move(-1, 1),
+ Action::Move(0, 1),
+ Action::Move(1, 1),
+ Action::Wait,
+ Action::UseMedpatch,
+ ];
+ for _ in 0..turns {
+ crawl.act(ACTIONS[rng.random_range(..ACTIONS.len())]);
+ }
+ }
+
+ #[test]
+ fn random_play_keeps_the_rules() {
+ for seed in 0..40 {
+ let mut crawl = Crawl::new(seed);
+ wander(&mut crawl, &mut seed::rng(seed), 600);
+ assert!(crawl.hp >= 0 && crawl.hp <= crawl.max_hp);
+ assert!(crawl.map.tile(crawl.player).walkable(), "seed {seed}: player in a wall");
+ for m in &crawl.monsters {
+ assert!(crawl.map.tile(m.pos).walkable(), "seed {seed}: {} in a wall", m.kind.name);
+ assert_ne!(m.pos, crawl.player);
+ }
+ if crawl.outcome.is_none() {
+ assert!(crawl.fov.contains(crawl.player));
+ }
+ }
+ }
+
+ #[test]
+ fn same_seed_same_moves_same_story() {
+ let play = || {
+ let mut crawl = Crawl::new(7);
+ wander(&mut crawl, &mut seed::rng(70), 300);
+ crawl.log
+ };
+ assert_eq!(play(), play());
+ }
+
+ #[test]
+ fn the_airlock_takes_you_home() {
+ let mut crawl = Crawl::new(3);
+ let airlock = crawl.player.neighbours().find(|&n| crawl.map.tile(n) == Tile::Airlock);
+ let airlock = airlock.expect("the start is beside the airlock");
+ let (dx, dy) = (airlock.x - crawl.player.x, airlock.y - crawl.player.y);
+ crawl.act(Action::Move(dx, dy));
+ assert_eq!(crawl.outcome, Some(Outcome::Escaped));
+ }
+
+ #[test]
+ fn fighting_kills_things() {
+ let mut crawl = Crawl::new(5);
+ let next_to_player = crawl.player.neighbours().find(|&n| crawl.map.tile(n).walkable());
+ let pos = next_to_player.unwrap();
+ crawl.monsters = vec![Monster { kind: &MONSTERS[0], pos, hp: 3, awake: true }];
+ let (dx, dy) = (pos.x - crawl.player.x, pos.y - crawl.player.y);
+ for _ in 0..50 {
+ crawl.act(Action::Move(dx, dy));
+ if crawl.monsters.is_empty() {
+ break;
+ }
+ }
+ assert!(crawl.monsters.is_empty(), "a rust-mite survived 50 swings");
+ assert!(crawl.log.iter().any(|m| m.contains("slag")));
+ }
+
+ #[test]
+ fn walking_into_walls_takes_no_time() {
+ let mut crawl = Crawl::new(11);
+ let wall = crawl
+ .player
+ .neighbours()
+ .find(|&n| matches!(crawl.map.tile(n), Tile::Wall | Tile::Hull));
+ let wall = wall.unwrap();
+ crawl.act(Action::Move(wall.x - crawl.player.x, wall.y - crawl.player.y));
+ assert_eq!(crawl.turns, 0);
+ }
+}
diff --git a/src/dungeon/fov.rs b/src/dungeon/fov.rs
new file mode 100644
index 0000000..81f8c07
--- /dev/null
+++ b/src/dungeon/fov.rs
@@ -0,0 +1,207 @@
+//! Field of view by symmetric shadowcasting, after Albert Ford's write-up at
+//! <https://www.albertford.com/shadowcasting/>.
+//!
+//! Symmetric means that if you can see a floor cell, someone standing there
+//! can see you. Monsters wake when they come into view, so this keeps
+//! ambushes fair: nothing notices you that you couldn't have seen first.
+
+use super::{Map, Pos};
+
+/// The cells visible from one spot.
+pub struct Fov {
+ width: i32,
+ visible: Vec<bool>,
+}
+
+impl Fov {
+ pub fn contains(&self, p: Pos) -> bool {
+ p.x >= 0
+ && p.y >= 0
+ && p.x < self.width
+ && self.visible.get((p.y * self.width + p.x) as usize).copied().unwrap_or(false)
+ }
+}
+
+/// Everything visible from `origin` within `radius` cells. Walls are visible
+/// (that's how you see a room's shape); cells behind them aren't.
+pub fn compute(map: &Map, origin: Pos, radius: i32) -> Fov {
+ let mut fov = Fov { width: map.width, visible: vec![false; (map.width * map.height) as usize] };
+ let mut reveal = |p: Pos| {
+ if map.contains(p) {
+ fov.visible[map.index(p)] = true;
+ }
+ };
+ reveal(origin);
+ for quadrant in Quadrant::ALL {
+ let mut rows = vec![Row { depth: 1, start: Slope::new(-1, 1), end: Slope::new(1, 1) }];
+ while let Some(mut row) = rows.pop() {
+ if row.depth > radius {
+ continue;
+ }
+ let mut prev_blocked = None;
+ for col in row.min_col()..=row.max_col() {
+ let p = quadrant.cell(origin, row.depth, col);
+ let blocked = map.tile(p).blocks_sight();
+ let in_radius = col * col + row.depth * row.depth <= radius * radius + radius;
+ if in_radius && (blocked || row.is_symmetric(col)) {
+ reveal(p);
+ }
+ if prev_blocked == Some(true) && !blocked {
+ row.start = Slope::left_edge(row.depth, col);
+ }
+ if prev_blocked == Some(false) && blocked {
+ let mut next = row.next();
+ next.end = Slope::left_edge(row.depth, col);
+ rows.push(next);
+ }
+ prev_blocked = Some(blocked);
+ }
+ if prev_blocked == Some(false) {
+ rows.push(row.next());
+ }
+ }
+ }
+ fov
+}
+
+#[derive(Clone, Copy)]
+enum Quadrant {
+ North,
+ East,
+ South,
+ West,
+}
+
+impl Quadrant {
+ const ALL: [Quadrant; 4] = [Quadrant::North, Quadrant::East, Quadrant::South, Quadrant::West];
+
+ /// The cell `depth` rows out from `origin` and `col` columns across.
+ fn cell(self, origin: Pos, depth: i32, col: i32) -> Pos {
+ match self {
+ Quadrant::North => origin.offset(col, -depth),
+ Quadrant::South => origin.offset(col, depth),
+ Quadrant::East => origin.offset(depth, col),
+ Quadrant::West => origin.offset(-depth, col),
+ }
+ }
+}
+
+/// An exact fraction, so shadows land on the same cells every time.
+#[derive(Clone, Copy)]
+struct Slope {
+ num: i32,
+ /// Always positive.
+ den: i32,
+}
+
+impl Slope {
+ const fn new(num: i32, den: i32) -> Slope {
+ Slope { num, den }
+ }
+
+ /// The slope through the left edge of the cell at (`depth`, `col`).
+ const fn left_edge(depth: i32, col: i32) -> Slope {
+ Slope::new(2 * col - 1, 2 * depth)
+ }
+}
+
+#[derive(Clone, Copy)]
+struct Row {
+ depth: i32,
+ start: Slope,
+ end: Slope,
+}
+
+impl Row {
+ /// `depth * start`, rounded with ties going up.
+ fn min_col(&self) -> i32 {
+ (2 * self.depth * self.start.num + self.start.den).div_euclid(2 * self.start.den)
+ }
+
+ /// `depth * end`, rounded with ties going down.
+ fn max_col(&self) -> i32 {
+ -(self.end.den - 2 * self.depth * self.end.num).div_euclid(2 * self.end.den)
+ }
+
+ /// Whether the centre of the cell at `col` is inside this row's slopes.
+ fn is_symmetric(&self, col: i32) -> bool {
+ col * self.start.den >= self.depth * self.start.num
+ && col * self.end.den <= self.depth * self.end.num
+ }
+
+ fn next(&self) -> Row {
+ Row { depth: self.depth + 1, ..*self }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use rand::RngExt;
+
+ use super::*;
+ use crate::dungeon::Tile;
+ use crate::seed;
+
+ fn map_from(rows: &[&str]) -> (Map, Pos) {
+ let mut map = Map::new(rows[0].len() as i32, rows.len() as i32, Tile::Floor);
+ let mut at = Pos::new(0, 0);
+ for (y, row) in rows.iter().enumerate() {
+ for (x, c) in row.chars().enumerate() {
+ let p = Pos::new(x as i32, y as i32);
+ match c {
+ '#' => map.set(p, Tile::Wall),
+ '@' => at = p,
+ _ => {}
+ }
+ }
+ }
+ (map, at)
+ }
+
+ #[test]
+ fn walls_cast_shadows() {
+ let (map, at) = map_from(&[
+ "#########", //
+ "#.......#", "#...@...#", "#..###..#", "#.......#", "#########",
+ ]);
+ let fov = compute(&map, at, 10);
+ assert!(fov.contains(Pos::new(1, 1)), "open floor is visible");
+ assert!(fov.contains(Pos::new(4, 3)), "the wall itself is visible");
+ assert!(!fov.contains(Pos::new(4, 4)), "the cell right behind the wall is hidden");
+ assert!(fov.contains(Pos::new(1, 3)), "cells beside the wall stay visible");
+ }
+
+ #[test]
+ fn radius_limits_sight() {
+ let (map, _) = map_from(&[&".".repeat(30)]);
+ let fov = compute(&map, Pos::new(0, 0), 5);
+ assert!(fov.contains(Pos::new(5, 0)));
+ assert!(!fov.contains(Pos::new(6, 0)));
+ }
+
+ /// The property the module promises: seeing is mutual between floor cells.
+ #[test]
+ fn sight_is_symmetric() {
+ let mut rng = seed::rng(4);
+ for _ in 0..20 {
+ let mut map = Map::new(24, 16, Tile::Floor);
+ for p in map.positions().collect::<Vec<_>>() {
+ if rng.random_bool(0.25) {
+ map.set(p, Tile::Wall);
+ }
+ }
+ let floors: Vec<Pos> =
+ map.positions().filter(|&p| map.tile(p) == Tile::Floor).collect();
+ let views: Vec<Fov> = floors.iter().map(|&p| compute(&map, p, 8)).collect();
+ for (i, &a) in floors.iter().enumerate() {
+ for (j, &b) in floors.iter().enumerate() {
+ assert_eq!(
+ views[i].contains(b),
+ views[j].contains(a),
+ "{a:?} and {b:?} disagree about seeing each other"
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/src/dungeon/mod.rs b/src/dungeon/mod.rs
new file mode 100644
index 0000000..d3227a5
--- /dev/null
+++ b/src/dungeon/mod.rs
@@ -0,0 +1,156 @@
+//! Turn-based dungeons: the map, field of view, derelict-ship generation,
+//! and the rules of a crawl. Drawing lives in `scenes::derelict`.
+
+pub mod crawl;
+pub mod fov;
+pub mod ship;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub struct Pos {
+ pub x: i32,
+ pub y: i32,
+}
+
+impl Pos {
+ pub const fn new(x: i32, y: i32) -> Pos {
+ Pos { x, y }
+ }
+
+ pub const fn offset(self, dx: i32, dy: i32) -> Pos {
+ Pos::new(self.x + dx, self.y + dy)
+ }
+
+ /// Steps from here to `other` when diagonal moves are allowed.
+ pub fn steps_to(self, other: Pos) -> i32 {
+ (self.x - other.x).abs().max((self.y - other.y).abs())
+ }
+
+ /// The eight cells around this one.
+ pub fn neighbours(self) -> impl Iterator<Item = Pos> {
+ const DIRS: [(i32, i32); 8] =
+ [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)];
+ DIRS.into_iter().map(move |(dx, dy)| self.offset(dx, dy))
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Tile {
+ /// Outside the ship.
+ Void,
+ /// The outer skin of the ship.
+ Hull,
+ /// An inside wall.
+ Wall,
+ Floor,
+ /// Floor strewn with wreckage. Walkable; it just looks different.
+ Debris,
+ Door {
+ open: bool,
+ },
+ /// The way out: the airlock your own ship is docked to.
+ Airlock,
+}
+
+impl Tile {
+ /// Whether you can step onto it. Closed doors open when bumped instead,
+ /// and stepping into the airlock leaves the ship.
+ pub fn walkable(self) -> bool {
+ matches!(self, Tile::Floor | Tile::Debris | Tile::Door { open: true })
+ }
+
+ pub fn blocks_sight(self) -> bool {
+ matches!(
+ self,
+ Tile::Void | Tile::Hull | Tile::Wall | Tile::Door { open: false } | Tile::Airlock
+ )
+ }
+}
+
+/// A named area of the map, shown to the player while they stand in it.
+pub struct Room {
+ pub name: &'static str,
+}
+
+pub struct Map {
+ pub width: i32,
+ pub height: i32,
+ tiles: Vec<Tile>,
+ room_of: Vec<Option<u16>>,
+ pub rooms: Vec<Room>,
+}
+
+impl Map {
+ pub fn new(width: i32, height: i32, fill: Tile) -> Map {
+ let cells = (width * height) as usize;
+ Map {
+ width,
+ height,
+ tiles: vec![fill; cells],
+ room_of: vec![None; cells],
+ rooms: Vec::new(),
+ }
+ }
+
+ pub fn contains(&self, p: Pos) -> bool {
+ p.x >= 0 && p.y >= 0 && p.x < self.width && p.y < self.height
+ }
+
+ /// Index into a per-cell `Vec` laid out like the map. Panics off the map.
+ pub fn index(&self, p: Pos) -> usize {
+ assert!(self.contains(p), "{p:?} is off the map");
+ (p.y * self.width + p.x) as usize
+ }
+
+ /// The tile at `p`; everything off the map is `Void`.
+ pub fn tile(&self, p: Pos) -> Tile {
+ if self.contains(p) { self.tiles[self.index(p)] } else { Tile::Void }
+ }
+
+ pub fn set(&mut self, p: Pos, tile: Tile) {
+ let i = self.index(p);
+ self.tiles[i] = tile;
+ }
+
+ pub fn room_at(&self, p: Pos) -> Option<&Room> {
+ if !self.contains(p) {
+ return None;
+ }
+ self.room_of[self.index(p)].map(|r| &self.rooms[usize::from(r)])
+ }
+
+ pub fn add_room(&mut self, name: &'static str) -> u16 {
+ self.rooms.push(Room { name });
+ (self.rooms.len() - 1) as u16
+ }
+
+ pub fn set_room(&mut self, p: Pos, room: Option<u16>) {
+ let i = self.index(p);
+ self.room_of[i] = room;
+ }
+
+ pub fn positions(&self) -> impl Iterator<Item = Pos> + use<> {
+ let (w, h) = (self.width, self.height);
+ (0..h).flat_map(move |y| (0..w).map(move |x| Pos::new(x, y)))
+ }
+
+ /// Steps from `from` to every cell reachable through walkable tiles and
+ /// closed doors, moving diagonally too; `None` where unreachable.
+ pub fn distances_from(&self, from: Pos) -> Vec<Option<u32>> {
+ let mut dist = vec![None; self.tiles.len()];
+ let mut queue = std::collections::VecDeque::from([from]);
+ dist[self.index(from)] = Some(0);
+ while let Some(p) = queue.pop_front() {
+ let d = dist[self.index(p)].unwrap();
+ for n in p.neighbours() {
+ let tile = self.tile(n);
+ if (tile.walkable() || tile == (Tile::Door { open: false }))
+ && dist[self.index(n)].is_none()
+ {
+ dist[self.index(n)] = Some(d + 1);
+ queue.push_back(n);
+ }
+ }
+ }
+ dist
+ }
+}
diff --git a/src/dungeon/ship.rs b/src/dungeon/ship.rs
new file mode 100644
index 0000000..dc9b0b8
--- /dev/null
+++ b/src/dungeon/ship.rs
@@ -0,0 +1,452 @@
+//! Derelict ships.
+//!
+//! A derelict is a hull silhouette, mirrored top to bottom, lying along the
+//! x axis with its engines at the left and its nose at the right. A spine
+//! corridor runs down the middle from the engine room to the bridge, and the
+//! space either side of it is cut into sections, each holding a room above
+//! the spine and one below:
+//!
+//! ```text
+//! ##########
+//! #####.....#..#######
+//! #...#.....#..+.....#####
+//! #...###+#######+######..###
+//! #.............................##
+//! #...###+######+#######..###
+//! #...#......#.......#####
+//! ############ ######
+//! engine room middle bridge
+//! ```
+//!
+//! Every room has a door onto the spine (or onto the room between it and the
+//! spine), so the whole ship is connected; a few extra doors between
+//! neighbouring rooms make loops to run around.
+
+use rand::RngExt;
+use rand::seq::IndexedRandom;
+
+use super::crawl::{Item, Loot, MONSTERS, Monster};
+use super::{Map, Pos, Tile};
+use crate::names::{self, Culture};
+use crate::seed::{self, Rng};
+
+pub struct Derelict {
+ /// "the Vexant".
+ pub name: String,
+ /// "Cant hauler".
+ pub description: String,
+ pub map: Map,
+ /// Where the player comes aboard, just inside the airlock.
+ pub start: Pos,
+ pub monsters: Vec<Monster>,
+ pub loot: Vec<Loot>,
+}
+
+struct RoomKind {
+ name: &'static str,
+ /// How often it turns up, relative to the other middle rooms.
+ weight: u32,
+ /// Chance of an artifact.
+ artifact: f64,
+ /// How many pieces of salvage: anywhere from .0 to .1.
+ salvage: (u32, u32),
+ /// Chance of a medpatch.
+ medpatch: f64,
+}
+
+const ENGINE_ROOM: RoomKind =
+ RoomKind { name: "the engine room", weight: 0, artifact: 0.05, salvage: (2, 3), medpatch: 0.1 };
+const BRIDGE: RoomKind =
+ RoomKind { name: "the bridge", weight: 0, artifact: 0.6, salvage: (0, 2), medpatch: 0.1 };
+
+#[rustfmt::skip]
+const ROOMS: &[RoomKind] = &[
+ RoomKind { name: "a cargo hold", weight: 4, artifact: 0.05, salvage: (2, 4), medpatch: 0.05 },
+ RoomKind { name: "the crew berths", weight: 3, artifact: 0.1, salvage: (0, 1), medpatch: 0.25 },
+ RoomKind { name: "the galley", weight: 2, artifact: 0.02, salvage: (0, 2), medpatch: 0.1 },
+ RoomKind { name: "the med-bay", weight: 1, artifact: 0.05, salvage: (0, 1), medpatch: 1.0 },
+ RoomKind { name: "a machine shop", weight: 2, artifact: 0.05, salvage: (1, 3), medpatch: 0.0 },
+ RoomKind { name: "the armoury", weight: 1, artifact: 0.15, salvage: (1, 2), medpatch: 0.3 },
+ RoomKind { name: "a cryo-vault", weight: 1, artifact: 0.3, salvage: (0, 1), medpatch: 0.2 },
+ RoomKind { name: "the chapel", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0 },
+ RoomKind { name: "the reliquary", weight: 1, artifact: 0.9, salvage: (0, 0), medpatch: 0.0 },
+ RoomKind { name: "the archive", weight: 1, artifact: 0.4, salvage: (0, 1), medpatch: 0.0 },
+ RoomKind { name: "the navigator's cell", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0 },
+ RoomKind { name: "an observation blister", weight: 1, artifact: 0.1, salvage: (0, 1), medpatch: 0.0 },
+];
+
+/// Salvage and its base value; each find varies by ±30%.
+const SALVAGE: &[(&str, u32)] = &[
+ ("a coil of superconductor", 30),
+ ("a cracked reactor baffle", 45),
+ ("a crate of ration bricks", 10),
+ ("an intact navigation core", 90),
+ ("a spool of wiring", 15),
+ ("a stack of hull plates", 25),
+ ("a sealed canister of coolant", 20),
+ ("a working servo", 35),
+ ("a box of votive candles", 8),
+ ("a cantor's data-slate", 40),
+ ("a half-charged fuel cell", 50),
+];
+
+/// A room as generated, before its contents are placed.
+struct Plan {
+ kind: &'static RoomKind,
+ cells: Vec<Pos>,
+}
+
+pub fn generate(seed: u64) -> Derelict {
+ let mut rng = seed::rng(seed);
+ let culture = Culture::ALL[rng.random_range(..Culture::ALL.len())];
+ let name = format!("the {}", culture.word(&mut rng));
+ let length: i32 = rng.random_range(44..=88);
+ let max_half: i32 = rng.random_range(5..=9);
+ let class = match length {
+ ..56 => "cutter",
+ 56..72 => "hauler",
+ _ if rng.random_bool(0.5) => "pilgrim-barge",
+ _ => "reliquary-ship",
+ };
+ let description = format!("{} {class}", culture.adjective());
+
+ let hull = Hull::new(&mut rng, length, max_half);
+ let mut map = hull.map();
+ let plans = carve_rooms(&mut map, &hull, &mut rng);
+ let start = place_airlock(&mut map, &hull, &mut rng);
+
+ for p in map.positions().collect::<Vec<_>>() {
+ if map.tile(p) == Tile::Floor && p != start && rng.random_bool(0.06) {
+ map.set(p, Tile::Debris);
+ }
+ }
+
+ // Everything is connected by construction, but if a hull's odd shape
+ // ever cuts a room in two, wall off the part that can't be reached
+ // rather than strand loot or monsters there.
+ let dist = map.distances_from(start);
+ let reachable = |map: &Map, p: Pos| dist[map.index(p)].is_some();
+ for p in map.positions().collect::<Vec<_>>() {
+ let open = matches!(map.tile(p), Tile::Floor | Tile::Debris | Tile::Door { .. });
+ if open && !reachable(&map, p) {
+ map.set(p, Tile::Wall);
+ map.set_room(p, None);
+ }
+ }
+ let free = |map: &Map, p: Pos| {
+ p != start && matches!(map.tile(p), Tile::Floor | Tile::Debris) && reachable(map, p)
+ };
+
+ let mut loot = Vec::new();
+ for plan in &plans {
+ let cells: Vec<Pos> = plan.cells.iter().copied().filter(|&p| free(&map, p)).collect();
+ if cells.is_empty() {
+ continue;
+ }
+ let kind = plan.kind;
+ if rng.random_bool(kind.artifact) {
+ let pos = *cells.choose(&mut rng).unwrap();
+ loot.push(Loot { pos, item: Item::Artifact(names::artifact(&mut rng)) });
+ }
+ for _ in 0..rng.random_range(kind.salvage.0..=kind.salvage.1) {
+ let pos = *cells.choose(&mut rng).unwrap();
+ let &(name, base) = SALVAGE.choose(&mut rng).unwrap();
+ let value = (f64::from(base) * rng.random_range(0.7..1.3)).round() as u32;
+ loot.push(Loot { pos, item: Item::Salvage { name, value } });
+ }
+ let medpatches = if kind.medpatch >= 1.0 {
+ rng.random_range(1..=2)
+ } else {
+ u32::from(rng.random_bool(kind.medpatch))
+ };
+ for _ in 0..medpatches {
+ loot.push(Loot { pos: *cells.choose(&mut rng).unwrap(), item: Item::Medpatch });
+ }
+ }
+ // Every derelict is worth boarding: if no room rolled an artifact, put
+ // one as far from the airlock as the ship goes.
+ if !loot.iter().any(|l| matches!(l.item, Item::Artifact(_))) {
+ let farthest = map
+ .positions()
+ .filter(|&p| free(&map, p))
+ .max_by_key(|&p| (dist[map.index(p)], p.x, p.y))
+ .expect("a derelict always has floor");
+ loot.push(Loot { pos: farthest, item: Item::Artifact(names::artifact(&mut rng)) });
+ }
+
+ let lairs: Vec<Pos> = map
+ .positions()
+ .filter(|&p| free(&map, p) && dist[map.index(p)].is_some_and(|d| d >= 7))
+ .collect();
+ let mut monsters: Vec<Monster> = Vec::new();
+ for _ in 0..(length / 11 + rng.random_range(0..=2)) {
+ let Some(&pos) = lairs.choose(&mut rng) else { break };
+ if monsters.iter().any(|m| m.pos == pos) {
+ continue;
+ }
+ let kind = MONSTERS.choose_weighted(&mut rng, |k| k.weight).unwrap();
+ if kind.unique && monsters.iter().any(|m| m.kind.name == kind.name) {
+ continue;
+ }
+ monsters.push(Monster { kind, pos, hp: kind.hp, awake: false });
+ }
+
+ Derelict { name, description, map, start, monsters, loot }
+}
+
+struct Hull {
+ /// Half-height of the hull at each x along the ship, stern first.
+ half: Vec<i32>,
+ /// How many columns of `half` are engine block, and how many nose.
+ engine: i32,
+ nose: i32,
+ max_half: i32,
+}
+
+impl Hull {
+ fn new(rng: &mut Rng, length: i32, max_half: i32) -> Hull {
+ let engine = rng.random_range(4..=7);
+ let nose = (f64::from(length) * rng.random_range(0.2..0.32)) as i32;
+ let body = length - engine - nose;
+
+ // A broad engine block, then a body of two to four blocky sections,
+ // each at least 4 high so the rooms either side of the spine fit.
+ let mut half = vec![rng.random_range(max_half - 2..=max_half); engine as usize];
+ let sections = rng.random_range(2..=4);
+ for s in 0..sections {
+ let width = body * (s + 1) / sections - body * s / sections;
+ let h = rng.random_range(4.max(max_half * 3 / 5)..=max_half);
+ half.extend(std::iter::repeat_n(h, width as usize));
+ }
+ // A nose tapering to a point; the exponent sets how blunt it is.
+ let last = f64::from(*half.last().unwrap());
+ let bluntness = rng.random_range(1.0..2.5);
+ for i in 0..nose {
+ let t = f64::from(i + 1) / f64::from(nose);
+ half.push(((last * (1.0 - t.powf(bluntness))).round() as i32).max(1));
+ }
+ Hull { half, engine, nose, max_half }
+ }
+
+ fn length(&self) -> i32 {
+ self.half.len() as i32
+ }
+
+ /// The row the spine runs along.
+ fn spine(&self) -> i32 {
+ self.max_half + 1
+ }
+
+ /// Map x runs from 0 to length + 1, so there's a column of space at each end.
+ fn inside(&self, p: Pos) -> bool {
+ (1..=self.length()).contains(&p.x)
+ && (p.y - self.spine()).abs() <= self.half[(p.x - 1) as usize]
+ }
+
+ /// Cells inside the hull become `Wall` (rooms are carved out of it
+ /// later); the edge of the inside becomes `Hull`.
+ fn map(&self) -> Map {
+ let mut map = Map::new(self.length() + 2, 2 * self.max_half + 3, Tile::Void);
+ for p in map.positions() {
+ if self.inside(p) {
+ let edge = p.neighbours().any(|n| !self.inside(n));
+ map.set(p, if edge { Tile::Hull } else { Tile::Wall });
+ }
+ }
+ map
+ }
+}
+
+fn carve(map: &mut Map, p: Pos, room: u16) {
+ if map.tile(p) == Tile::Wall {
+ map.set(p, Tile::Floor);
+ map.set_room(p, Some(room));
+ }
+}
+
+fn door(map: &mut Map, p: Pos, rng: &mut Rng) {
+ // A quarter of doors were left open, or jammed that way.
+ map.set(p, Tile::Door { open: rng.random_bool(0.25) });
+}
+
+fn carve_rooms(map: &mut Map, hull: &Hull, rng: &mut Rng) -> Vec<Plan> {
+ let (length, cy) = (hull.length(), hull.spine());
+ let mut plans = Vec::new();
+
+ // The engine room and bridge span the ship's full height, so the spine
+ // runs straight into them. Carve them first so the spine's cells there
+ // belong to them (carving only turns walls into floor).
+ let engine_end = hull.engine;
+ let bridge_start = length - hull.nose + 1;
+ for (kind, xs) in [(&ENGINE_ROOM, 2..=engine_end), (&BRIDGE, bridge_start..=length - 1)] {
+ let room = map.add_room(kind.name);
+ let cells: Vec<Pos> =
+ map.positions().filter(|p| xs.contains(&p.x) && map.tile(*p) == Tile::Wall).collect();
+ for &p in &cells {
+ carve(map, p, room);
+ }
+ plans.push(Plan { kind, cells });
+ }
+ let spine = map.add_room("the spine corridor");
+ for x in 2..length {
+ carve(map, Pos::new(x, cy), spine);
+ }
+
+ // Cut the middle into sections 3-8 wide, with a wall column between each.
+ let (lo, hi) = (engine_end + 2, bridge_start - 2);
+ let mut sections = Vec::new();
+ let mut x = lo;
+ while x <= hi {
+ let mut end = (x + rng.random_range(2..=7)).min(hi);
+ if hi - end < 4 {
+ end = hi;
+ }
+ sections.push((x, end));
+ x = end + 2;
+ }
+
+ for &(sa, sb) in &sections {
+ for side in [-1, 1] {
+ // `depth` counts rows out from the spine; row 1 is the wall
+ // between spine and rooms.
+ let at = |x: i32, depth: i32| Pos::new(x, cy + side * depth);
+ let cells: Vec<(Pos, i32)> = (sa..=sb)
+ .flat_map(|x| (2..=hull.max_half).map(move |d| (at(x, d), d)))
+ .filter(|&(p, _)| map.tile(p) == Tile::Wall)
+ .collect();
+ let door_xs: Vec<i32> =
+ (sa..=sb).filter(|&x| map.tile(at(x, 2)) == Tile::Wall).collect();
+ if cells.len() < 4 || door_xs.is_empty() {
+ continue;
+ }
+
+ // Deep enough sections sometimes hold two rooms, one behind the
+ // other, with a wall and a door between them.
+ let deepest = cells.iter().map(|&(_, d)| d).max().unwrap();
+ let mut split = None;
+ if deepest >= 6 && rng.random_bool(0.5) {
+ let s = rng.random_range(4..=deepest - 2);
+ let between: Vec<i32> = (sa..=sb)
+ .filter(|&x| {
+ map.tile(at(x, s - 1)) == Tile::Wall && map.tile(at(x, s + 1)) == Tile::Wall
+ })
+ .collect();
+ if let Some(&x) = between.choose(rng) {
+ split = Some((s, x));
+ }
+ }
+
+ let rooms: Vec<Vec<Pos>> = match split {
+ None => vec![cells.iter().map(|&(p, _)| p).collect()],
+ Some((s, _)) => vec![
+ cells.iter().filter(|&&(_, d)| d < s).map(|&(p, _)| p).collect(),
+ cells.iter().filter(|&&(_, d)| d > s).map(|&(p, _)| p).collect(),
+ ],
+ };
+ for room_cells in rooms {
+ let kind = ROOMS.choose_weighted(rng, |k| k.weight).unwrap();
+ let room = map.add_room(kind.name);
+ for &p in &room_cells {
+ carve(map, p, room);
+ }
+ plans.push(Plan { kind, cells: room_cells });
+ }
+ if let Some((s, x)) = split {
+ door(map, at(x, s), rng);
+ }
+ door(map, at(*door_xs.choose(rng).unwrap(), 1), rng);
+ }
+ }
+
+ // Now and then, a door through the wall column between neighbouring
+ // rooms, so there's more than one way around.
+ let walls = std::iter::once(engine_end + 1).chain(sections.iter().map(|&(_, sb)| sb + 1));
+ for x in walls.collect::<Vec<_>>() {
+ for side in [-1, 1] {
+ let spots: Vec<Pos> = (2..=hull.max_half)
+ .map(|d| Pos::new(x, cy + side * d))
+ .filter(|&p| {
+ map.tile(p) == Tile::Wall
+ && map.tile(p.offset(-1, 0)) == Tile::Floor
+ && map.tile(p.offset(1, 0)) == Tile::Floor
+ })
+ .collect();
+ if !spots.is_empty() && rng.random_bool(0.3) {
+ door(map, *spots.choose(rng).unwrap(), rng);
+ }
+ }
+ }
+ plans
+}
+
+/// Puts the airlock on a flat stretch of outer hull beside a middle room and
+/// returns the cell just inside it. Falls back to the stern, where the spine
+/// always meets the hull.
+fn place_airlock(map: &mut Map, hull: &Hull, rng: &mut Rng) -> Pos {
+ let cy = hull.spine();
+ let middle = hull.engine + 2..=hull.length() - hull.nose - 1;
+ let spots: Vec<(Pos, Pos)> = map
+ .positions()
+ .filter_map(|p| {
+ let side = (p.y - cy).signum();
+ let inward = p.offset(0, -side);
+ let flat =
+ map.tile(p.offset(-1, 0)) == Tile::Hull && map.tile(p.offset(1, 0)) == Tile::Hull;
+ (side != 0
+ && middle.contains(&p.x)
+ && map.tile(p) == Tile::Hull
+ && map.tile(p.offset(0, side)) == Tile::Void
+ && map.tile(inward) == Tile::Floor
+ && flat)
+ .then_some((p, inward))
+ })
+ .collect();
+ let (airlock, start) = spots.choose(rng).copied().unwrap_or((Pos::new(1, cy), Pos::new(2, cy)));
+ map.set(airlock, Tile::Airlock);
+ start
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn derelicts_are_sound() {
+ for seed in 0..300 {
+ let d = generate(seed);
+ let map = &d.map;
+ assert!(map.width <= 90 && map.height <= 21, "seed {seed}: too big");
+ assert_eq!(map.tile(d.start), Tile::Floor, "seed {seed}: start isn't floor");
+ assert!(
+ d.start.neighbours().any(|n| map.tile(n) == Tile::Airlock),
+ "seed {seed}: no airlock beside the start"
+ );
+
+ let dist = map.distances_from(d.start);
+ for p in map.positions() {
+ if map.tile(p).walkable() || matches!(map.tile(p), Tile::Door { .. }) {
+ assert!(dist[map.index(p)].is_some(), "seed {seed}: {p:?} is unreachable");
+ }
+ }
+ for m in &d.monsters {
+ assert!(map.tile(m.pos).walkable(), "seed {seed}: monster in a wall");
+ assert!(dist[map.index(m.pos)] >= Some(7), "seed {seed}: monster too near start");
+ }
+ for l in &d.loot {
+ assert!(map.tile(l.pos).walkable(), "seed {seed}: loot in a wall");
+ assert_ne!(l.pos, d.start);
+ }
+ assert!(d.loot.iter().any(|l| matches!(l.item, Item::Artifact(_))));
+ assert!(map.rooms.len() >= 4, "seed {seed}: only {} rooms", map.rooms.len());
+ assert!(d.monsters.iter().filter(|m| m.kind.unique).count() <= 1);
+ }
+ }
+
+ #[test]
+ fn same_seed_same_ship() {
+ let (a, b) = (generate(99), generate(99));
+ assert_eq!(a.name, b.name);
+ assert_eq!(a.start, b.start);
+ assert!(a.map.positions().all(|p| a.map.tile(p) == b.map.tile(p)));
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..e2b74d3
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,43 @@
+//! Orrery: an ASCII sci-fi-fantasy game.
+//!
+//! `orrery` plays; `orrery names` and `orrery ship` print what the
+//! generators make, without the TUI.
+
+mod app;
+mod cli;
+mod dungeon;
+mod names;
+mod scenes;
+mod seed;
+mod term;
+
+use std::process::ExitCode;
+
+fn main() -> ExitCode {
+ let args: Vec<String> = std::env::args().skip(1).collect();
+ match cli::parse(&args) {
+ Ok(cli::Command::Play { seed }) => match app::run(seed.unwrap_or_else(seed::from_clock)) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(e) => {
+ eprintln!("orrery: {e}");
+ ExitCode::FAILURE
+ }
+ },
+ Ok(cli::Command::Names(opts)) => {
+ cli::print_names(&opts);
+ ExitCode::SUCCESS
+ }
+ Ok(cli::Command::Ship { seed, which }) => {
+ cli::print_ship(seed, which);
+ ExitCode::SUCCESS
+ }
+ Ok(cli::Command::Help) => {
+ print!("{}", cli::USAGE);
+ ExitCode::SUCCESS
+ }
+ Err(msg) => {
+ eprintln!("orrery: {msg}\n\n{}", cli::USAGE);
+ ExitCode::from(2)
+ }
+ }
+}
diff --git a/src/names/artifact.rs b/src/names/artifact.rs
new file mode 100644
index 0000000..af24b3d
--- /dev/null
+++ b/src/names/artifact.rs
@@ -0,0 +1,69 @@
+//! Artifact names: what a thing looks like when you find it, and what it's
+//! called once a scholar has told you what it is.
+
+use rand::RngExt;
+
+use super::{Culture, pick};
+use crate::seed::Rng;
+
+#[derive(Clone, Debug)]
+pub struct Artifact {
+ /// What you see when you find it: "a humming ochre lattice".
+ pub unidentified: String,
+ /// Its true name: "the Lacquered Orrery of Teshemond".
+ pub true_name: String,
+}
+
+pub fn artifact(rng: &mut Rng) -> Artifact {
+ let look = format!("{} {} {}", pick(QUALITIES, rng), pick(COLOURS, rng), pick(SHAPES, rng));
+ let article = if look.starts_with(['a', 'e', 'i', 'o', 'u']) { "an" } else { "a" };
+ let unidentified = format!("{article} {look}");
+
+ let object = pick(OBJECTS, rng);
+ let culture = Culture::ALL[rng.random_range(..Culture::ALL.len())];
+ let true_name = match rng.random_range(0..5) {
+ 0 => format!("the {} {object} of {}", pick(MATERIALS, rng), culture.word(rng)),
+ 1 => format!("the {object} of {}", culture.place(rng)),
+ 2 => format!("{}'s {object}", culture.word(rng)),
+ 3 => format!("the {} {object}", pick(EPITHETS, rng)),
+ _ => format!("the {object} {}", pick(CLAUSES, rng)),
+ };
+ Artifact { unidentified, true_name }
+}
+
+const QUALITIES: &[&str] = &[
+ "humming", "warm", "weightless", "faintly singing", "ice-cold", "flickering", "whispering",
+ "slowly turning", "perfectly still", "pulsing", "dust-caked", "oddly heavy", "tarnished",
+ "ticking", "breathing",
+];
+
+const COLOURS: &[&str] = &[
+ "ochre", "verdigris", "bone-white", "umber", "cerulean", "oxblood", "iridescent", "smoke-grey",
+ "jet-black", "saffron", "rust-red", "pearlescent", "cobalt",
+];
+
+const SHAPES: &[&str] = &[
+ "lattice", "spindle", "disc", "knot", "sphere", "prism", "coil", "shard", "ring", "cube",
+ "cylinder", "helix", "lens", "rod", "tessera",
+];
+
+const MATERIALS: &[&str] = &[
+ "Saltglass", "Black-Iron", "Lacquered", "Bone", "Star-Ivory", "Verdigris", "Voidsteel",
+ "Smoked-Glass", "Amber", "Obsidian", "Gilt", "Quicksilver",
+];
+
+const OBJECTS: &[&str] = &[
+ "Orrery", "Reliquary", "Astrolabe", "Censer", "Lattice", "Mask", "Crown", "Key", "Sigil",
+ "Codex", "Lens", "Spindle", "Compass", "Chalice", "Engine", "Bell", "Heart", "Mirror", "Loom",
+];
+
+const EPITHETS: &[&str] = &[
+ "Weeping", "Ninth", "Hollow", "Unspoken", "Last", "Sleeping", "Burning", "Patient", "Seventh",
+ "Silent", "Drowned", "Unfinished", "Forgotten", "Inverted",
+];
+
+const CLAUSES: &[&str] = &[
+ "That Remembers", "That Was Promised", "Which Does Not Turn", "That Sings at Dusk",
+ "That Counts the Dead", "That Opens Inward", "That Waits", "of Unmaking", "of Seven Silences",
+ "Beneath the Sun",
+];
diff --git a/src/names/mod.rs b/src/names/mod.rs
new file mode 100644
index 0000000..719455f
--- /dev/null
+++ b/src/names/mod.rs
@@ -0,0 +1,319 @@
+//! Name generation.
+//!
+//! Each culture has its own syllables, so names from one region sound related
+//! to each other and different from the next region's. A word is a start
+//! syllable, maybe some middles, and an end; templates such as `"{w} ul-{w}"`
+//! turn words into people and places.
+//!
+//! The lists are meant to be tuned by ear: edit them, then run
+//! `cargo run -- names --culture qaari -n 40` to hear the result.
+//!
+//! Within a culture, syllables are written so that every join works: in most
+//! cultures starts and middles end in a vowel and middles and ends begin with
+//! a consonant, so they always meet vowel-to-consonant. `pronounceable` throws
+//! out whatever still comes out badly.
+
+mod artifact;
+
+pub use artifact::{Artifact, artifact};
+
+use std::fmt::Write as _;
+
+use rand::RngExt;
+use rand::seq::IndexedRandom;
+
+use crate::seed::Rng;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Culture {
+ Qaari,
+ Cant,
+ Houses,
+ Drift,
+}
+
+struct Phonology {
+ starts: &'static [&'static str],
+ middles: &'static [&'static str],
+ ends: &'static [&'static str],
+ /// How many middle syllables a word gets: anywhere from .0 to .1.
+ middles_per_word: (u32, u32),
+ /// Templates for people. `{w}` is a new word, `{n}` a number from 2 to
+ /// 99, `{r}` a Roman numeral. Repeat a template to make it more common.
+ people: &'static [&'static str],
+ /// Templates for places, filled in the same way.
+ places: &'static [&'static str],
+}
+
+const QAARI: Phonology = Phonology {
+ starts: &[
+ "qa", "kha", "sha", "za", "ta", "ha", "ya", "ma", "i", "u", "a", "su", "ze", "ru", "khe",
+ "qe", "na", "ba",
+ ],
+ middles: &[
+ "'a", "'i", "'u", "ssa", "ra", "lu", "shi", "the", "ka", "ma", "zi", "rru", "da", "hi",
+ ],
+ ends: &[
+ "run", "vel", "seem", "taq", "rim", "zar", "dul", "keth", "mah", "resh", "noun", "sir",
+ "qal", "riya", "'at", "'im", "n", "r", "sh", "dah", "zim",
+ ],
+ middles_per_word: (0, 1),
+ people: &["{w} {w}", "{w} ul-{w}", "{w} ul-{w}", "{w} of the {w} Erg"],
+ places: &["{w}", "{w}", "{w} {w}", "Erg {w}", "the {w} Wells", "{w} Hollow"],
+};
+
+// The Cant joins the other way round: starts end in a consonant, ends begin
+// with a vowel. That's where the hard stops come from.
+const CANT: Phonology = Phonology {
+ starts: &[
+ "kor", "vex", "drak", "tor", "brann", "ix", "zed", "kal", "grim", "stak", "vor", "dex",
+ "kryn", "thul", "garr", "oct", "rhun",
+ ],
+ middles: &["ax", "en", "ul", "ir", "ot", "ek"],
+ ends: &["ex", "on", "ax", "ik", "us", "ant", "ek", "ost", "ar", "ion", "um", "a", "o", "is"],
+ middles_per_word: (0, 1),
+ people: &["{w}-{n}", "{w}-{n}", "Cantor {w}-{n}", "{w} {w}", "{w} {r}", "Fabricant {w}"],
+ places: &["{w}-{n}", "{w} Spire", "Foundry {w}", "{w} {r}", "the {w} Engine"],
+};
+
+const HOUSES: Phonology = Phonology {
+ starts: &[
+ "va", "so", "i", "ca", "me", "au", "ve", "o", "se", "the", "ly", "ama", "eu", "ari", "ce",
+ "di", "lu",
+ ],
+ middles: &["ste", "ri", "la", "mo", "ce", "ve", "ra", "the", "li", "ssa", "da"],
+ ends: &[
+ "mond", "stre", "var", "rion", "nelle", "nard", "rine", "lis", "thane", "sande", "vere",
+ "lior", "dane", "rath", "ssandre", "mire",
+ ],
+ middles_per_word: (0, 1),
+ people: &["{w} {w}", "{w} {w}", "{w} of House {w}", "Archon {w} {w}", "{w} {w} the Younger"],
+ places: &["{w}", "{w}'s Reach", "{w} Prime", "New {w}", "{w} {w}", "the {w} Gardens"],
+};
+
+const DRIFT: Phonology = Phonology {
+ starts: &["nu", "o", "y", "ve", "li", "sa", "e", "mi", "no", "u", "ae", "ilo", "wa", "si"],
+ middles: &["va", "lo", "so", "ne", "lla", "sse", "ri", "wa", "le"],
+ ends: &[
+ "nn", "ss", "l", "lei", "ra", "vael", "th", "na", "ssa", "ren", "lue", "wyn", "s", "lith",
+ "mae",
+ ],
+ middles_per_word: (0, 1),
+ people: &["{w}", "{w}", "{w} {w}", "{w} of the {w}"],
+ places: &["{w}", "the {w} Drift", "{w} Anchorage", "{w} Deep"],
+};
+
+const ROMAN: &[&str] = &["II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"];
+
+impl Culture {
+ pub const ALL: [Culture; 4] = [Culture::Qaari, Culture::Cant, Culture::Houses, Culture::Drift];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ Culture::Qaari => "the Qaari",
+ Culture::Cant => "the Cant",
+ Culture::Houses => "the High Houses",
+ Culture::Drift => "the Drift",
+ }
+ }
+
+ /// For describing things: "a Cant hauler", "a High House cutter".
+ pub fn adjective(self) -> &'static str {
+ match self {
+ Culture::Qaari => "Qaari",
+ Culture::Cant => "Cant",
+ Culture::Houses => "High House",
+ Culture::Drift => "Drift",
+ }
+ }
+
+ pub fn blurb(self) -> &'static str {
+ match self {
+ Culture::Qaari => "dune-sea clans: guttural, sun-cracked, apostrophes",
+ Culture::Cant => "derelict machine-cult: hard stops, serial numbers",
+ Culture::Houses => "old nobility: ornate, liquid, Latinate",
+ Culture::Drift => "void-born spacers: soft, breathy, doubled letters",
+ }
+ }
+
+ /// Accepts the short names the CLI uses: qaari, cant, houses, drift.
+ pub fn parse(s: &str) -> Option<Culture> {
+ match s.to_ascii_lowercase().as_str() {
+ "qaari" => Some(Culture::Qaari),
+ "cant" => Some(Culture::Cant),
+ "houses" => Some(Culture::Houses),
+ "drift" => Some(Culture::Drift),
+ _ => None,
+ }
+ }
+
+ fn phonology(self) -> &'static Phonology {
+ match self {
+ Culture::Qaari => &QAARI,
+ Culture::Cant => &CANT,
+ Culture::Houses => &HOUSES,
+ Culture::Drift => &DRIFT,
+ }
+ }
+
+ /// One capitalised word: "Khaseem", "Drakulon", "Ithane", "Nuvael".
+ pub fn word(self, rng: &mut Rng) -> String {
+ let mut candidate = String::new();
+ for _ in 0..100 {
+ candidate = self.syllables(rng).concat();
+ if pronounceable(&candidate) {
+ break;
+ }
+ }
+ capitalise(&candidate)
+ }
+
+ /// Picks a word's syllables, avoiding stutters like "the-the" or "qa-qal"
+ /// (a syllable that opens with the same two letters as the one before).
+ fn syllables(self, rng: &mut Rng) -> Vec<&'static str> {
+ let p = self.phonology();
+ let (min, max) = p.middles_per_word;
+ let middles = rng.random_range(min..=max) as usize;
+ let mut parts = vec![pick(p.starts, rng)];
+ for i in 0..=middles {
+ let list = if i < middles { p.middles } else { p.ends };
+ let prev = parts[parts.len() - 1];
+ let mut next = pick(list, rng);
+ for _ in 0..10 {
+ if prev.len() < 2 || !next.starts_with(&prev[..2]) {
+ break;
+ }
+ next = pick(list, rng);
+ }
+ parts.push(next);
+ }
+ parts
+ }
+
+ pub fn person(self, rng: &mut Rng) -> String {
+ self.expand(pick(self.phonology().people, rng), rng)
+ }
+
+ pub fn place(self, rng: &mut Rng) -> String {
+ self.expand(pick(self.phonology().places, rng), rng)
+ }
+
+ fn expand(self, template: &str, rng: &mut Rng) -> String {
+ let mut out = String::new();
+ let mut rest = template;
+ while let Some(open) = rest.find('{') {
+ out.push_str(&rest[..open]);
+ let close = open + rest[open..].find('}').expect("unclosed { in a name template");
+ match &rest[open + 1..close] {
+ "w" => out.push_str(&self.word(rng)),
+ "n" => write!(out, "{}", rng.random_range(2..100)).unwrap(),
+ "r" => out.push_str(pick(ROMAN, rng)),
+ slot => panic!("unknown slot {{{slot}}} in name template {template:?}"),
+ }
+ rest = &rest[close + 1..];
+ }
+ out.push_str(rest);
+ out
+ }
+}
+
+pub(crate) fn pick(list: &'static [&'static str], rng: &mut Rng) -> &'static str {
+ list.choose(rng).expect("name lists are never empty")
+}
+
+/// Rejects words that are too short, too long, or have clusters no one could
+/// say. The limits are by ear; loosen them if a culture wants harsher words.
+fn pronounceable(word: &str) -> bool {
+ let letters = word.chars().filter(|c| c.is_ascii_alphabetic()).count();
+ if !(3..=11).contains(&letters)
+ || word.starts_with('\'')
+ || word.ends_with('\'')
+ || word.matches('\'').count() > 1
+ {
+ return false;
+ }
+ let (mut vowels, mut consonants, mut same) = (0, 0, 0);
+ let mut prev = None;
+ for c in word.chars() {
+ same = if Some(c) == prev { same + 1 } else { 1 };
+ prev = Some(c);
+ match c {
+ '\'' => (vowels, consonants) = (0, 0),
+ 'a' | 'e' | 'i' | 'o' | 'u' | 'y' => (vowels, consonants) = (vowels + 1, 0),
+ _ => (vowels, consonants) = (0, consonants + 1),
+ }
+ if vowels > 2 || consonants > 3 || same > 2 {
+ return false;
+ }
+ }
+ true
+}
+
+fn capitalise(word: &str) -> String {
+ let mut chars = word.chars();
+ match chars.next() {
+ Some(first) => first.to_uppercase().chain(chars).collect(),
+ None => String::new(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::seed;
+
+ #[test]
+ fn same_seed_same_names() {
+ for culture in Culture::ALL {
+ let a: Vec<_> = (0..20).map(|i| culture.person(&mut seed::rng(i))).collect();
+ let b: Vec<_> = (0..20).map(|i| culture.person(&mut seed::rng(i))).collect();
+ assert_eq!(a, b);
+ }
+ }
+
+ #[test]
+ fn words_are_well_formed() {
+ for culture in Culture::ALL {
+ let mut rng = seed::rng(1);
+ for _ in 0..5000 {
+ let w = culture.word(&mut rng);
+ assert!(w.starts_with(|c: char| c.is_ascii_uppercase()), "{culture:?}: {w:?}");
+ assert!(pronounceable(&w.to_lowercase()), "{culture:?}: {w:?}");
+ }
+ }
+ }
+
+ /// Mostly checks that each syllable list joins up: if a culture's words
+ /// keep failing `pronounceable`, `word` falls back to unpronounceable ones.
+ #[test]
+ fn most_candidates_are_pronounceable() {
+ for culture in Culture::ALL {
+ let mut rng = seed::rng(2);
+ let mut ok = 0;
+ for _ in 0..1000 {
+ ok += usize::from(pronounceable(&culture.syllables(&mut rng).concat()));
+ }
+ assert!(ok > 700, "{culture:?}: only {ok}/1000 candidates were pronounceable");
+ }
+ }
+
+ #[test]
+ fn every_template_expands() {
+ let mut rng = seed::rng(3);
+ for culture in Culture::ALL {
+ let p = culture.phonology();
+ for template in p.people.iter().chain(p.places) {
+ let name = culture.expand(template, &mut rng);
+ assert!(!name.contains('{'), "{template:?} gave {name:?}");
+ }
+ }
+ }
+
+ #[test]
+ fn parses_every_culture() {
+ for culture in Culture::ALL {
+ let short = culture.name().rsplit(' ').next().unwrap();
+ assert_eq!(Culture::parse(short), Some(culture));
+ }
+ }
+}
diff --git a/src/scenes/derelict.rs b/src/scenes/derelict.rs
new file mode 100644
index 0000000..fc18f50
--- /dev/null
+++ b/src/scenes/derelict.rs
@@ -0,0 +1,413 @@
+//! Aboard a derelict: the map, your status, and the log. The rules live in
+//! `dungeon::crawl`; this turns keys into actions and draws the result.
+
+use ratatui::Frame;
+use ratatui::buffer::Buffer;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Flex, Layout, Rect};
+use ratatui::style::{Color, Style, Stylize};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap};
+
+use super::{Scene, Transition};
+use crate::app::Ctx;
+use crate::dungeon::crawl::{Action, Crawl, Item, Outcome, SIGHT_RADIUS};
+use crate::dungeon::{Pos, Tile};
+use crate::seed;
+
+const PANEL_WIDTH: u16 = 34;
+const LOG_HEIGHT: u16 = 7;
+
+type Rgb = (u8, u8, u8);
+/// Cells you've seen but can't see now.
+const REMEMBERED: Rgb = (64, 68, 84);
+/// The hull's outline, seen from outside as you docked.
+const SILHOUETTE: Rgb = (40, 44, 60);
+const STAR: Rgb = (72, 72, 92);
+
+pub struct Derelict {
+ crawl: Crawl,
+ /// Asking whether to leave by the emergency hatch.
+ abandoning: bool,
+ /// Once the crawl is over: a title and lines for the closing summary.
+ ending: Option<(&'static str, Vec<String>)>,
+}
+
+impl Derelict {
+ pub fn new(seed: u64) -> Self {
+ Derelict { crawl: Crawl::new(seed), abandoning: false, ending: None }
+ }
+
+ /// Called once, when the crawl ends: banks what you carried if you got
+ /// out, and writes the summary.
+ fn finish(&mut self, outcome: Outcome, ctx: &mut Ctx) {
+ let c = &self.crawl;
+ let lines = match outcome {
+ Outcome::Escaped => {
+ ctx.hold.artifacts.extend(c.artifacts.iter().cloned());
+ ctx.hold.scrip += c.scrip;
+ let mut lines = vec![
+ format!("You cast off from {}.", c.name),
+ String::new(),
+ format!(
+ "Recovered {} and {} ¤ of salvage.",
+ count(c.artifacts.len(), "artifact"),
+ c.scrip
+ ),
+ ];
+ lines.extend(c.artifacts.iter().map(|a| format!(" * {}", a.unidentified)));
+ lines.push(String::new());
+ lines.push(format!(
+ "Your hold now carries {} and {} ¤.",
+ count(ctx.hold.artifacts.len(), "artifact"),
+ ctx.hold.scrip
+ ));
+ lines
+ }
+ Outcome::Died { killer } => vec![
+ format!("The {killer} brings you down aboard {}.", c.name),
+ "Your crew drags you back to your ship, but".into(),
+ "everything you carried stays with the wreck.".into(),
+ ],
+ };
+ let title = match outcome {
+ Outcome::Escaped => " SAFE ABOARD YOUR SHIP ",
+ Outcome::Died { .. } => " PULLED BACK FROM THE EDGE ",
+ };
+ self.ending = Some((title, lines));
+ }
+
+ fn render_map(&self, buf: &mut Buffer, area: Rect) {
+ let c = &self.crawl;
+ let left = camera(c.player.x, c.map.width, i32::from(area.width));
+ let top = camera(c.player.y, c.map.height, i32::from(area.height));
+ for sy in 0..area.height {
+ for sx in 0..area.width {
+ let p = Pos::new(left + i32::from(sx), top + i32::from(sy));
+ let (glyph, style) = self.look(p);
+ if let Some(cell) = buf.cell_mut((area.x + sx, area.y + sy)) {
+ cell.set_char(glyph).set_style(style);
+ }
+ }
+ }
+ }
+
+ /// How the cell at `p` looks from where you stand: lit by your lamp if
+ /// you can see it, from memory if you've seen it, and otherwise only the
+ /// hull's outline against the stars.
+ fn look(&self, p: Pos) -> (char, Style) {
+ let c = &self.crawl;
+ let tile = c.map.tile(p);
+ if c.fov.contains(p) {
+ if p == c.player {
+ return ('@', Style::new().fg(Color::White).bold());
+ }
+ if let Some(m) = c.monster_at(p) {
+ return (m.kind.glyph, Style::new().fg(rgb(m.kind.rgb, 1.0)).bold());
+ }
+ let dist = f64::from((p.x - c.player.x).pow(2) + (p.y - c.player.y).pow(2)).sqrt();
+ let light = 1.0 - 0.6 * (dist / f64::from(SIGHT_RADIUS)).min(1.0);
+ if let Some(loot) = c.loot_at(p) {
+ let (glyph, colour) = item_look(&loot.item);
+ return (glyph, Style::new().fg(rgb(colour, light.max(0.8))).bold());
+ }
+ let (glyph, colour) = tile_look(tile);
+ return (glyph, Style::new().fg(rgb(colour, light)));
+ }
+ if c.seen(p) {
+ if let Some(loot) = c.loot_at(p) {
+ let (glyph, colour) = item_look(&loot.item);
+ return (glyph, Style::new().fg(rgb(colour, 0.45)));
+ }
+ return (tile_look(tile).0, Style::new().fg(rgb(REMEMBERED, 1.0)));
+ }
+ match tile {
+ Tile::Hull | Tile::Airlock => {
+ (tile_look(tile).0, Style::new().fg(rgb(SILHOUETTE, 1.0)))
+ }
+ Tile::Void if is_star(p) => ('.', Style::new().fg(rgb(STAR, 1.0))),
+ _ => (' ', Style::new()),
+ }
+ }
+
+ fn render_panel(&self, frame: &mut Frame, area: Rect) {
+ let c = &self.crawl;
+ let block = Block::bordered()
+ .border_style(Style::new().dark_gray())
+ .title(Line::from(" DERELICT ").bold())
+ .padding(Padding::horizontal(1));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+ let [info, help] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(5)]).areas(inner);
+
+ let label = |text: &'static str| Span::from(text).dark_gray();
+ let mut lines = vec![
+ Line::from(c.name.as_str()).bold().fg(Color::Yellow),
+ Line::from(c.description.as_str()).italic().dark_gray(),
+ Line::default(),
+ Line::from(vec![label("health "), health_bar(c.hp, c.max_hp, 12)]),
+ Line::from(vec![label("medpatches"), Span::from(format!(" {}", c.medpatches))]),
+ Line::from(vec![label("here "), Span::from(c.location())]),
+ Line::from(vec![label("turn "), Span::from(c.turns.to_string())]),
+ Line::default(),
+ Line::from(vec![label("salvage "), Span::from(format!("{} ¤", c.scrip))]),
+ Line::from(vec![label("artifacts "), Span::from(c.artifacts.len().to_string())]),
+ ];
+ for artifact in &c.artifacts {
+ lines.push(Line::from(format!("* {}", artifact.unidentified)).fg(Color::Yellow));
+ }
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info);
+
+ // Pairs of (key, what it does) on one line.
+ let keys = |pairs: &[(&'static str, &'static str)]| {
+ Line::from(
+ pairs
+ .iter()
+ .flat_map(|&(k, what)| [Span::from(k).bold(), Span::from(what).dark_gray()])
+ .collect::<Vec<_>>(),
+ )
+ };
+ frame.render_widget(
+ Paragraph::new(vec![
+ keys(&[("arrows hjkl yubn", " move")]),
+ keys(&[(".", " wait "), ("m", " medpatch")]),
+ keys(&[("walk into Θ", " leave with loot")]),
+ keys(&[("esc", " abandon ship, loot and all")]),
+ ]),
+ help,
+ );
+ }
+
+ fn render_log(&self, frame: &mut Frame, area: Rect) {
+ let log = &self.crawl.log;
+ let shown = usize::from(area.height.saturating_sub(1));
+ let first = log.len().saturating_sub(shown);
+ let lines: Vec<Line> = log[first..]
+ .iter()
+ .enumerate()
+ .map(|(i, message)| {
+ let age = log.len() - first - 1 - i;
+ let line = Line::from(message.as_str());
+ match age {
+ 0 => line.white(),
+ 1 | 2 => line.gray(),
+ _ => line.dark_gray(),
+ }
+ })
+ .collect();
+ let block = Block::new().borders(Borders::TOP).border_style(Style::new().dark_gray());
+ frame.render_widget(Paragraph::new(lines).block(block), area);
+ }
+}
+
+impl Scene for Derelict {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ if self.ending.is_some() {
+ return Transition::Pop;
+ }
+ if self.abandoning {
+ self.abandoning = false;
+ return match key.code {
+ KeyCode::Char('y' | 'Y') => Transition::Pop,
+ _ => Transition::None,
+ };
+ }
+ // Vi keys, arrows, and the number pad with or without num lock.
+ let action = match key.code {
+ KeyCode::Left | KeyCode::Char('h' | '4') => Action::Move(-1, 0),
+ KeyCode::Right | KeyCode::Char('l' | '6') => Action::Move(1, 0),
+ KeyCode::Up | KeyCode::Char('k' | '8') => Action::Move(0, -1),
+ KeyCode::Down | KeyCode::Char('j' | '2') => Action::Move(0, 1),
+ KeyCode::Home | KeyCode::Char('y' | '7') => Action::Move(-1, -1),
+ KeyCode::PageUp | KeyCode::Char('u' | '9') => Action::Move(1, -1),
+ KeyCode::End | KeyCode::Char('b' | '1') => Action::Move(-1, 1),
+ KeyCode::PageDown | KeyCode::Char('n' | '3') => Action::Move(1, 1),
+ KeyCode::Char('.' | '5') => Action::Wait,
+ KeyCode::Char('m') => Action::UseMedpatch,
+ KeyCode::Esc => {
+ self.abandoning = true;
+ return Transition::None;
+ }
+ _ => return Transition::None,
+ };
+ self.crawl.act(action);
+ if let Some(outcome) = self.crawl.outcome {
+ self.finish(outcome, ctx);
+ }
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, _ctx: &Ctx) {
+ let [main, panel] =
+ Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)])
+ .areas(frame.area());
+ let [map, log] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(LOG_HEIGHT)]).areas(main);
+ self.render_map(frame.buffer_mut(), map);
+ self.render_log(frame, log);
+ self.render_panel(frame, panel);
+
+ if self.abandoning {
+ popup(
+ frame,
+ " ABANDON SHIP? ",
+ vec![
+ format!("Leave {} by the emergency hatch?", self.crawl.name),
+ "Whatever you're carrying stays behind.".into(),
+ String::new(),
+ "[y] abandon [any other key] keep exploring".into(),
+ ],
+ );
+ } else if let Some((title, lines)) = &self.ending {
+ let mut lines = lines.clone();
+ lines.extend([String::new(), "[any key]".into()]);
+ popup(frame, title, lines);
+ }
+ }
+}
+
+/// The left or top edge of the view: keeps `focus` centred, but doesn't
+/// scroll past the map's edges, and centres a map smaller than the view.
+fn camera(focus: i32, map_size: i32, view_size: i32) -> i32 {
+ if map_size <= view_size {
+ -(view_size - map_size) / 2
+ } else {
+ (focus - view_size / 2).clamp(0, map_size - view_size)
+ }
+}
+
+pub(crate) fn tile_look(tile: Tile) -> (char, Rgb) {
+ match tile {
+ Tile::Void => (' ', (0, 0, 0)),
+ Tile::Hull => ('#', (122, 134, 160)),
+ Tile::Wall => ('#', (152, 148, 136)),
+ Tile::Floor => ('.', (124, 118, 104)),
+ Tile::Debris => (',', (160, 126, 92)),
+ Tile::Door { open: false } => ('+', (216, 170, 86)),
+ Tile::Door { open: true } => ('\'', (216, 170, 86)),
+ Tile::Airlock => ('Θ', (90, 222, 200)),
+ }
+}
+
+pub(crate) fn item_look(item: &Item) -> (char, Rgb) {
+ match item {
+ Item::Artifact(_) => ('*', (255, 214, 92)),
+ Item::Salvage { .. } => ('%', (150, 200, 222)),
+ Item::Medpatch => ('!', (240, 96, 96)),
+ }
+}
+
+fn rgb((r, g, b): Rgb, light: f64) -> Color {
+ let scale = |c: u8| (f64::from(c) * light).round() as u8;
+ Color::Rgb(scale(r), scale(g), scale(b))
+}
+
+/// A sprinkling of stars that stays put as the view scrolls.
+fn is_star(p: Pos) -> bool {
+ let cell = (u64::from(p.x as u32) << 32) | u64::from(p.y as u32);
+ seed::derive(cell, "star", 0).is_multiple_of(41)
+}
+
+fn health_bar(hp: i32, max: i32, width: i32) -> Span<'static> {
+ let filled = (hp * width + max - 1) / max;
+ let colour = match hp * 4 / max {
+ 3.. => Color::Green,
+ 2 => Color::Yellow,
+ _ => Color::Red,
+ };
+ let bar = "█".repeat(filled as usize) + &"░".repeat((width - filled) as usize);
+ Span::styled(format!("{bar} {hp}/{max}"), Style::new().fg(colour))
+}
+
+fn count(n: usize, noun: &str) -> String {
+ if n == 1 { format!("1 {noun}") } else { format!("{n} {noun}s") }
+}
+
+fn popup(frame: &mut Frame, title: &str, lines: Vec<String>) {
+ let area = frame.area();
+ let widest = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16;
+ let width = (widest + 4).min(area.width);
+ let height = (lines.len() as u16 + 2).min(area.height);
+ let [popup] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area);
+ let [popup] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(popup);
+ let block = Block::bordered()
+ .border_style(Style::new().fg(Color::Yellow))
+ .title(Line::from(title.to_string()).bold())
+ .padding(Padding::horizontal(1));
+ let lines: Vec<Line> = lines.into_iter().map(Line::from).collect();
+ frame.render_widget(Clear, popup);
+ frame.render_widget(Paragraph::new(lines).block(block), popup);
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::Terminal;
+ use ratatui::backend::TestBackend;
+ use ratatui::crossterm::event::KeyModifiers;
+
+ use super::*;
+ use crate::term::KeyboardCaps;
+
+ fn ctx() -> Ctx {
+ Ctx::new(1, KeyboardCaps { key_release: false })
+ }
+
+ fn press(scene: &mut Derelict, code: KeyCode, ctx: &mut Ctx) -> Transition {
+ scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx)
+ }
+
+ fn screen(scene: &Derelict, ctx: &Ctx) -> String {
+ let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
+ terminal.draw(|frame| scene.render(frame, ctx)).unwrap();
+ terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect()
+ }
+
+ /// The vi key that moves you onto the airlock from the starting cell.
+ fn key_toward_airlock(scene: &Derelict) -> KeyCode {
+ let c = &scene.crawl;
+ let airlock = c.player.neighbours().find(|&n| c.map.tile(n) == Tile::Airlock).unwrap();
+ KeyCode::Char(match (airlock.x - c.player.x, airlock.y - c.player.y) {
+ (-1, -1) => 'y',
+ (0, -1) => 'k',
+ (1, -1) => 'u',
+ (-1, 0) => 'h',
+ (1, 0) => 'l',
+ (-1, 1) => 'b',
+ (0, 1) => 'j',
+ _ => 'n',
+ })
+ }
+
+ #[test]
+ fn escaping_banks_what_you_carry() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::new(3);
+ scene.crawl.scrip = 55;
+ let toward = key_toward_airlock(&scene);
+ assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None));
+ assert_eq!(ctx.hold.scrip, 55);
+ assert!(screen(&scene, &ctx).contains("SAFE ABOARD YOUR SHIP"));
+ assert!(matches!(press(&mut scene, KeyCode::Char(' '), &mut ctx), Transition::Pop));
+ }
+
+ #[test]
+ fn abandoning_leaves_it_behind() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::new(3);
+ scene.crawl.scrip = 55;
+ press(&mut scene, KeyCode::Esc, &mut ctx);
+ assert!(screen(&scene, &ctx).contains("ABANDON SHIP?"));
+ assert!(matches!(press(&mut scene, KeyCode::Char('y'), &mut ctx), Transition::Pop));
+ assert_eq!(ctx.hold.scrip, 0);
+ }
+
+ #[test]
+ fn any_other_key_keeps_exploring() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::new(3);
+ press(&mut scene, KeyCode::Esc, &mut ctx);
+ assert!(matches!(press(&mut scene, KeyCode::Char('x'), &mut ctx), Transition::None));
+ assert!(!screen(&scene, &ctx).contains("ABANDON SHIP?"));
+ }
+}
diff --git a/src/scenes/forge.rs b/src/scenes/forge.rs
new file mode 100644
index 0000000..f856c8f
--- /dev/null
+++ b/src/scenes/forge.rs
@@ -0,0 +1,154 @@
+//! The name forge: browse what the name generator makes for each culture.
+//!
+//! Each forging is `derive(universe, "forge", n)`, so stepping back with ←
+//! regenerates exactly what you saw before.
+
+use ratatui::Frame;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Layout, Rect};
+use ratatui::style::{Color, Style, Stylize};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Paragraph, Wrap};
+
+use super::{Scene, Transition};
+use crate::app::Ctx;
+use crate::names::{self, Artifact, Culture};
+use crate::seed;
+
+const PER_LIST: usize = 4;
+const ARTIFACTS: usize = 5;
+
+struct Column {
+ culture: Culture,
+ people: Vec<String>,
+ places: Vec<String>,
+}
+
+pub struct Forge {
+ forging: u64,
+ columns: Vec<Column>,
+ artifacts: Vec<Artifact>,
+}
+
+impl Forge {
+ pub fn new(ctx: &Ctx) -> Self {
+ let mut forge = Forge { forging: 0, columns: Vec::new(), artifacts: Vec::new() };
+ forge.forge(ctx.seed);
+ forge
+ }
+
+ fn forge(&mut self, universe: u64) {
+ let mut rng = seed::rng(seed::derive(universe, "forge", self.forging));
+ self.columns = Culture::ALL
+ .iter()
+ .map(|&culture| Column {
+ culture,
+ people: (0..PER_LIST).map(|_| culture.person(&mut rng)).collect(),
+ places: (0..PER_LIST).map(|_| culture.place(&mut rng)).collect(),
+ })
+ .collect();
+ self.artifacts = (0..ARTIFACTS).map(|_| names::artifact(&mut rng)).collect();
+ }
+}
+
+fn colour(culture: Culture) -> Color {
+ match culture {
+ Culture::Qaari => Color::Yellow,
+ Culture::Cant => Color::Red,
+ Culture::Houses => Color::Magenta,
+ Culture::Drift => Color::Cyan,
+ }
+}
+
+fn render_column(frame: &mut Frame, column: &Column, area: Rect) {
+ let tint = colour(column.culture);
+ let heading = |text: &'static str| Line::from(text).fg(tint).dim();
+ let mut lines = vec![
+ Line::from(column.culture.blurb()).dark_gray().italic(),
+ Line::default(),
+ heading("PEOPLE"),
+ ];
+ lines.extend(column.people.iter().map(|n| Line::from(n.as_str())));
+ lines.push(Line::default());
+ lines.push(heading("PLACES"));
+ lines.extend(column.places.iter().map(|n| Line::from(n.as_str())));
+
+ let block = Block::bordered()
+ .border_style(Style::new().fg(tint).dim())
+ .title(Line::from(format!(" {} ", column.culture.name())).fg(tint).bold());
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area);
+}
+
+impl Scene for Forge {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ match key.code {
+ KeyCode::Char(' ') | KeyCode::Right | KeyCode::Enter => {
+ self.forging += 1;
+ self.forge(ctx.seed);
+ }
+ KeyCode::Left | KeyCode::Backspace if self.forging > 0 => {
+ self.forging -= 1;
+ self.forge(ctx.seed);
+ }
+ KeyCode::Esc => return Transition::Pop,
+ KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') => return Transition::Pop,
+ _ => {}
+ }
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx) {
+ let [header, cultures, artifacts, footer] = Layout::vertical([
+ Constraint::Length(2),
+ Constraint::Fill(1),
+ Constraint::Length(ARTIFACTS as u16 + 2),
+ Constraint::Length(1),
+ ])
+ .areas(frame.area());
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::from(" THE NAME FORGE").bold(),
+ Span::from(format!(" forging {} of universe {}", self.forging, ctx.seed))
+ .dark_gray(),
+ ])),
+ header,
+ );
+
+ // Four across on wide terminals, two by two otherwise.
+ let cells: Vec<Rect> = if cultures.width >= 120 {
+ Layout::horizontal([Constraint::Fill(1); 4]).split(cultures).to_vec()
+ } else {
+ Layout::vertical([Constraint::Fill(1); 2])
+ .split(cultures)
+ .iter()
+ .flat_map(|row| Layout::horizontal([Constraint::Fill(1); 2]).split(*row).to_vec())
+ .collect()
+ };
+ for (column, cell) in self.columns.iter().zip(cells) {
+ render_column(frame, column, cell);
+ }
+
+ let width = self.artifacts.iter().map(|a| a.unidentified.len()).max().unwrap_or(0);
+ let lines: Vec<Line> = self
+ .artifacts
+ .iter()
+ .map(|a| {
+ Line::from(vec![
+ Span::from(format!("{:<width$}", a.unidentified)).italic().gray(),
+ Span::from(" → ").dark_gray(),
+ Span::from(a.true_name.as_str()).light_yellow(),
+ ])
+ })
+ .collect();
+ let block = Block::bordered()
+ .border_style(Style::new().dark_gray())
+ .title(Line::from(" ARTIFACTS: as found → once identified ").bold());
+ frame.render_widget(Paragraph::new(lines).block(block), artifacts);
+
+ frame.render_widget(
+ Paragraph::new(" [space/→] forge again [←] previous [esc] back").dark_gray(),
+ footer,
+ );
+ }
+}
diff --git a/src/scenes/mod.rs b/src/scenes/mod.rs
new file mode 100644
index 0000000..60db647
--- /dev/null
+++ b/src/scenes/mod.rs
@@ -0,0 +1,74 @@
+//! Scenes: the title screen, the name forge, and derelicts to board;
+//! flight and ports later.
+
+pub mod derelict;
+pub mod forge;
+pub mod title;
+
+use ratatui::Frame;
+use ratatui::crossterm::event::KeyEvent;
+
+use crate::app::Ctx;
+
+/// What a scene wants to happen after it handles a key or a tick.
+pub enum Transition {
+ None,
+ /// Open a scene on top of this one. Popping it comes back here.
+ Push(Box<dyn Scene>),
+ /// Close this scene and return to the one beneath it.
+ Pop,
+ Quit,
+}
+
+/// One screen of the game. The game keeps a stack of them; only the top one
+/// gets keys and ticks and is drawn.
+pub trait Scene {
+ /// A key was pressed, or auto-repeated while held.
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition;
+
+ /// One fixed step of game time; there are `app::TICKS_PER_SECOND` of
+ /// these a second, whatever the frame rate. Turn-based scenes can ignore it.
+ fn tick(&mut self, _ctx: &mut Ctx) -> Transition {
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx);
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::Terminal;
+ use ratatui::backend::TestBackend;
+ use ratatui::crossterm::event::{KeyCode, KeyModifiers};
+
+ use super::*;
+ use crate::term::KeyboardCaps;
+
+ pub fn test_ctx() -> Ctx {
+ Ctx::new(1, KeyboardCaps { key_release: false })
+ }
+
+ /// Draws every scene at several sizes, down to absurdly small ones, to
+ /// catch layout arithmetic that panics.
+ #[test]
+ fn scenes_render_at_any_size() {
+ let mut ctx = test_ctx();
+ let mut abandoning = derelict::Derelict::new(2);
+ abandoning.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut ctx);
+ let scenes: [Box<dyn Scene>; 4] = [
+ Box::new(title::Title::new(&ctx)),
+ Box::new(forge::Forge::new(&ctx)),
+ Box::new(derelict::Derelict::new(1)),
+ Box::new(abandoning),
+ ];
+ for (width, height) in [(160, 50), (80, 24), (40, 12), (5, 3), (1, 1)] {
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
+ for scene in &scenes {
+ for ticks in [0, 12_345] {
+ ctx.ticks = ticks;
+ terminal.draw(|frame| scene.render(frame, &ctx)).unwrap();
+ }
+ }
+ }
+ }
+}
diff --git a/src/scenes/title.rs b/src/scenes/title.rs
new file mode 100644
index 0000000..7c75888
--- /dev/null
+++ b/src/scenes/title.rs
@@ -0,0 +1,224 @@
+//! Title screen: a turning orrery over a drifting starfield.
+//!
+//! Every universe seed gets its own orrery, and everything here moves as a
+//! function of `ctx.ticks`, so it doubles as a check that the tick loop runs.
+
+use std::f64::consts::TAU;
+
+use rand::RngExt;
+use ratatui::Frame;
+use ratatui::buffer::Buffer;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Flex, Layout, Rect};
+use ratatui::style::{Color, Style, Stylize};
+use ratatui::symbols::Marker;
+use ratatui::text::{Line, Span};
+use ratatui::widgets::Paragraph;
+use ratatui::widgets::canvas::{Canvas, Points};
+
+use super::derelict::Derelict;
+use super::forge::Forge;
+use super::{Scene, Transition};
+use crate::app::{Ctx, TICKS_PER_SECOND};
+use crate::seed;
+
+const LOGO: [&str; 4] = [
+ r" ___ ___ ___ ___ ___ __ __",
+ r" / _ \ | _ \ | _ \ | __| | _ \ \ \ / /",
+ r"| (_) | | / | / | _| | / \ V / ",
+ r" \___/ |_|_\ |_|_\ |___| |_|_\ |_| ",
+];
+
+/// Star layers: drift speed in screen-widths per second, glyph, colour.
+/// Nearer layers move faster, which reads as depth.
+const STAR_LAYERS: [(f64, char, Color); 3] =
+ [(0.004, '.', Color::DarkGray), (0.010, '·', Color::Gray), (0.025, '*', Color::White)];
+
+/// The orrery is seen from above at an angle, so circular orbits look like
+/// ellipses this many times as tall as they are wide.
+const TILT: f64 = 0.4;
+
+const PLANET_COLOURS: [Color; 6] = [
+ Color::LightRed,
+ Color::LightBlue,
+ Color::LightGreen,
+ Color::LightMagenta,
+ Color::LightCyan,
+ Color::Rgb(205, 133, 63),
+];
+
+struct Star {
+ x: f64,
+ y: f64,
+ layer: usize,
+}
+
+struct Planet {
+ /// Orbit radius as a fraction of the largest orbit that fits.
+ orbit: f64,
+ /// Seconds per revolution.
+ period: f64,
+ phase: f64,
+ glyph: &'static str,
+ colour: Color,
+}
+
+pub struct Title {
+ stars: Vec<Star>,
+ planets: Vec<Planet>,
+}
+
+impl Title {
+ pub fn new(ctx: &Ctx) -> Self {
+ let mut rng = seed::rng(seed::derive(ctx.seed, "title", 0));
+ let stars = (0..160)
+ .map(|_| Star {
+ x: rng.random(),
+ y: rng.random(),
+ layer: rng.random_range(0..STAR_LAYERS.len()),
+ })
+ .collect();
+ let count = rng.random_range(3..=5);
+ let planets = (0..count)
+ .map(|i| {
+ let orbit = (i as f64 + rng.random_range(0.6..1.0)) / count as f64;
+ Planet {
+ orbit,
+ // Kepler's third law, roughly: outer planets are slower.
+ period: 45.0 * orbit.powf(1.5),
+ phase: rng.random_range(0.0..TAU),
+ glyph: if rng.random_bool(0.5) { "o" } else { "•" },
+ colour: PLANET_COLOURS[rng.random_range(..PLANET_COLOURS.len())],
+ }
+ })
+ .collect();
+ Title { stars, planets }
+ }
+
+ fn draw_stars(&self, buf: &mut Buffer, area: Rect, seconds: f64) {
+ for star in &self.stars {
+ let (speed, glyph, colour) = STAR_LAYERS[star.layer];
+ let x = (star.x - speed * seconds).rem_euclid(1.0);
+ let col = area.x + (x * f64::from(area.width)) as u16;
+ let row = area.y + (star.y * f64::from(area.height)) as u16;
+ if let Some(cell) = buf.cell_mut((col, row)) {
+ cell.set_char(glyph).set_fg(colour);
+ }
+ }
+ }
+
+ fn draw_orrery(&self, frame: &mut Frame, area: Rect, seconds: f64) {
+ // Braille gives each cell 2x4 dots. With these bounds one unit is one
+ // dot, and dots are about square, so geometry here needs no aspect
+ // correction (unlike plain cells, which are twice as tall as wide).
+ let half_w = f64::from(area.width);
+ let half_h = 2.0 * f64::from(area.height);
+ let max_radius = (half_w * 0.95).min(half_h * 0.9 / TILT);
+
+ let canvas = Canvas::default()
+ .marker(Marker::Braille)
+ .x_bounds([-half_w, half_w])
+ .y_bounds([-half_h, half_h])
+ .paint(|c| {
+ for planet in &self.planets {
+ let r = planet.orbit * max_radius;
+ let steps = (r * 2.0) as usize + 16;
+ let ring: Vec<(f64, f64)> = (0..steps)
+ .map(|i| {
+ let a = TAU * i as f64 / steps as f64;
+ (r * a.cos(), r * TILT * a.sin())
+ })
+ .collect();
+ c.draw(&Points { coords: &ring, color: Color::DarkGray });
+ }
+ c.print(0.0, 0.0, Span::styled("☼", Style::new().fg(Color::Yellow).bold()));
+ for planet in &self.planets {
+ let r = planet.orbit * max_radius;
+ let a = planet.phase + TAU * seconds / planet.period;
+ let glyph = Span::styled(planet.glyph, Style::new().fg(planet.colour).bold());
+ c.print(r * a.cos(), r * TILT * a.sin(), glyph);
+ }
+ });
+ frame.render_widget(canvas, area);
+ }
+}
+
+impl Scene for Title {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ match key.code {
+ KeyCode::Char(c) => match c.to_ascii_lowercase() {
+ 'd' => {
+ ctx.boarded += 1;
+ let ship = seed::derive(ctx.seed, "derelict", ctx.boarded);
+ Transition::Push(Box::new(Derelict::new(ship)))
+ }
+ 'n' => Transition::Push(Box::new(Forge::new(ctx))),
+ 'q' => Transition::Quit,
+ _ => Transition::None,
+ },
+ KeyCode::Esc => Transition::Quit,
+ _ => Transition::None,
+ }
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx) {
+ let seconds = ctx.ticks as f64 / f64::from(TICKS_PER_SECOND);
+ let area = frame.area();
+ self.draw_stars(frame.buffer_mut(), area, seconds);
+
+ let [sky, logo, _, menu, hold, footer] = Layout::vertical([
+ Constraint::Fill(1),
+ Constraint::Length(LOGO.len() as u16),
+ Constraint::Length(1),
+ Constraint::Length(4),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas(area);
+
+ self.draw_orrery(frame, sky, seconds);
+
+ let logo_lines: Vec<Line> = LOGO.iter().map(|l| Line::from(*l).bold()).collect();
+ frame.render_widget(Paragraph::new(logo_lines).centered().fg(Color::Yellow), logo);
+
+ let item = |key: &'static str, label: &'static str, built: bool| {
+ let style = if built { Style::new() } else { Style::new().fg(Color::DarkGray) };
+ let mut spans = vec![Span::styled(key, style.bold()), Span::styled(label, style)];
+ if !built {
+ spans.push(Span::styled(" (not yet built)", style.italic()));
+ }
+ Line::from(spans)
+ };
+ let [menu] = Layout::horizontal([Constraint::Length(46)]).flex(Flex::Center).areas(menu);
+ frame.render_widget(
+ Paragraph::new(vec![
+ item("[d] ", "board a derelict", true),
+ item("[n] ", "the name forge", true),
+ item("[f] ", "fly a star system", false),
+ item("[q] ", "leave", true),
+ ]),
+ menu,
+ );
+
+ let h = &ctx.hold;
+ if !h.artifacts.is_empty() || h.scrip > 0 {
+ let plural = if h.artifacts.len() == 1 { "" } else { "s" };
+ let text = format!(
+ "your hold: {} artifact{plural}, {} ¤ of salvage",
+ h.artifacts.len(),
+ h.scrip
+ );
+ frame.render_widget(Paragraph::new(text).centered().fg(Color::Yellow), hold);
+ }
+
+ let keys = if ctx.keyboard.key_release {
+ "held keys detectable (kitty keyboard protocol)"
+ } else {
+ "held keys not detectable: flight will need a throttle"
+ };
+ frame.render_widget(
+ Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray(),
+ footer,
+ );
+ }
+}
diff --git a/src/seed.rs b/src/seed.rs
new file mode 100644
index 0000000..b7491e7
--- /dev/null
+++ b/src/seed.rs
@@ -0,0 +1,93 @@
+//! Stable, hierarchical seeds.
+//!
+//! The universe is a pure function of one `u64`. Each thing derives its seed
+//! from its parent's plus a label and an index:
+//!
+//! ```text
+//! let system = seed::derive(universe, "system", 42);
+//! let wreck = seed::derive(system, "derelict", 3);
+//! ```
+//!
+//! so any one thing can be regenerated without generating everything before
+//! it, and a save file only needs the root seed plus what the player changed.
+//!
+//! Everything here must give the same answer forever: a change silently
+//! reshuffles every universe. That's why this doesn't use std's
+//! `DefaultHasher` (allowed to change between Rust releases) or rand's
+//! `seed_from_u64` (allowed to change between rand releases), and why the
+//! tests pin exact outputs.
+
+use rand::SeedableRng;
+use rand_chacha::ChaCha8Rng;
+
+/// The RNG for all procedural generation. ChaCha's output is specified and
+/// rand_chacha promises not to change it, unlike rand's `StdRng`.
+pub type Rng = ChaCha8Rng;
+
+/// SplitMix64's finalizer: scrambles every input bit into every output bit.
+const fn mix(mut z: u64) -> u64 {
+ z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
+ z ^ (z >> 31)
+}
+
+/// FNV-1a: a tiny hash for turning labels into numbers.
+const fn fnv1a(bytes: &[u8]) -> u64 {
+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
+ let mut i = 0;
+ while i < bytes.len() {
+ hash ^= bytes[i] as u64;
+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
+ i += 1;
+ }
+ hash
+}
+
+/// The seed of the `index`th `label` inside `parent`.
+pub const fn derive(parent: u64, label: &str, index: u64) -> u64 {
+ mix(mix(parent ^ fnv1a(label.as_bytes())).wrapping_add(mix(index ^ 0x9e37_79b9_7f4a_7c15)))
+}
+
+/// An RNG for generating whatever `seed` identifies.
+pub fn rng(seed: u64) -> Rng {
+ let mut bytes = [0u8; 32];
+ let mut state = seed;
+ for chunk in bytes.as_chunks_mut::<8>().0 {
+ state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
+ *chunk = mix(state).to_le_bytes();
+ }
+ Rng::from_seed(bytes)
+}
+
+/// A fresh universe seed for when the player doesn't pick one.
+pub fn from_clock() -> u64 {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_or(0, |d| d.as_nanos() as u64);
+ // Keep seeds short enough to read aloud and retype.
+ mix(nanos ^ u64::from(std::process::id())) % 1_000_000_000
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rand::Rng as _;
+
+ /// If this fails, every existing universe just changed. Only update the
+ /// numbers if you mean to break old seeds and saves.
+ #[test]
+ fn outputs_never_change() {
+ assert_eq!(derive(0, "system", 0), PINNED_DERIVE);
+ assert_eq!(rng(0).next_u64(), PINNED_RNG);
+ }
+ const PINNED_DERIVE: u64 = 11_894_545_496_673_509_984;
+ const PINNED_RNG: u64 = 13_804_888_775_535_289_832;
+
+ #[test]
+ fn labels_and_indices_give_different_seeds() {
+ let a = derive(7, "system", 0);
+ assert_ne!(a, derive(7, "system", 1));
+ assert_ne!(a, derive(7, "derelict", 0));
+ assert_ne!(a, derive(8, "system", 0));
+ }
+}
diff --git a/src/term.rs b/src/term.rs
new file mode 100644
index 0000000..b11bbdc
--- /dev/null
+++ b/src/term.rs
@@ -0,0 +1,50 @@
+//! Terminal setup and teardown.
+
+use std::io::{self, stdout};
+
+use ratatui::DefaultTerminal;
+use ratatui::crossterm::event::{
+ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
+};
+use ratatui::crossterm::{execute, terminal};
+
+/// What this terminal can tell us about the keyboard.
+#[derive(Clone, Copy, Debug)]
+pub struct KeyboardCaps {
+ /// The terminal reports key releases (the kitty keyboard protocol), so a
+ /// scene can know a key is being held. Without it, holding a key looks
+ /// like one press, a ~0.5 s pause, then auto-repeats, and real-time
+ /// controls need a fallback such as a throttle you step up and down.
+ pub key_release: bool,
+}
+
+/// Raw mode, alternate screen, and the kitty keyboard protocol where available.
+pub fn init() -> io::Result<(DefaultTerminal, KeyboardCaps)> {
+ let terminal = ratatui::try_init()?;
+ let key_release = terminal::supports_keyboard_enhancement().unwrap_or(false);
+ if key_release {
+ execute!(
+ stdout(),
+ PushKeyboardEnhancementFlags(
+ KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
+ | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
+ )
+ )?;
+ // ratatui's panic hook restores raw mode and the alternate screen but
+ // not these flags, which would leave the shell receiving kitty-encoded
+ // keys. Pop them first, then hand over to ratatui's hook.
+ let previous = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |info| {
+ let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
+ previous(info);
+ }));
+ }
+ Ok((terminal, KeyboardCaps { key_release }))
+}
+
+pub fn restore(caps: KeyboardCaps) {
+ if caps.key_release {
+ let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
+ }
+ ratatui::restore();
+}