diff options
| author | skylar <me@casper> | 2026-11-11 15:24:37 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-11 15:24:37 -0800 |
| commit | 9ccf951473e21a8b90c25161156fa09a9b227a7b (patch) | |
| tree | e7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src/scenes/character.rs | |
| parent | d36c54d996f3c14ae811312dbbe621e12a39edde (diff) | |
Orrery 0.1.0: the galaxy, flight, cities, inventory and saves
Grows the derelict crawler into the whole game, and gets it ready to
publish.
- A galaxy of star systems joined by lanes, split between four peoples'
territories, with a star map and a system map. Systems are modelled
with Kepler orbits, moons inside their Hill spheres, belts past the
frost line and stations at Lagrange points.
- Flight around one place at a time: arcade flight with a throttle
fallback where held keys can't be detected, mining rocks for ore,
landing, docking at stations, and boarding wrecks that come and go by
the calendar.
- Your ship to walk about, and capital cities to land at: generated
plans with walls, quarters and streets, building interiors, shops,
taverns, scholars and armourers.
- Fighting with a weapon and a gun ready, six damage types and how each
monster takes them, and armour that guards, wears and can be mended.
- Everything you own has a weight: a vim-keyed inventory, a pack and the
ship's lockers, and trade in ore, salvage, arms and fuel.
- Characters with a people and a ship of their own, saved as versioned
JSON on every scene change and on quit, with a character sheet, a
saves list and a pause menu wherever you are.
- `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the
generators' output without the TUI.
- Release: README install steps for macOS and Linux, a Makefile whose
install also works with macOS's install, an MIT licence, and Cargo
metadata with rust-version 1.88.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'src/scenes/character.rs')
| -rw-r--r-- | src/scenes/character.rs | 821 |
1 files changed, 821 insertions, 0 deletions
diff --git a/src/scenes/character.rs b/src/scenes/character.rs new file mode 100644 index 0000000..1ed2b11 --- /dev/null +++ b/src/scenes/character.rs @@ -0,0 +1,821 @@ +//! Characters: making a new one, and the sheet that shows who you are and +//! what you've done. +//! +//! A new character gets a name, a people they come from and a ship. Each is +//! suggested, ← and → step through more suggestions, and you can type your +//! own instead. They also get a universe to play in, which is only a seed: +//! type in someone else's to fly the same system they do. + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Padding, Paragraph, Wrap}; + +use super::flight; +use super::forge::colour; +use super::inventory::look; +use super::keys::{self, ACCENT, DIM}; +use super::sky::rgb; +use super::{Scene, Transition}; +use crate::app::{Ctx, Kept}; +use crate::dungeon::arms::Dmg; +use crate::dungeon::crawl::{Affinity, MONSTERS}; +use crate::items::{self, Gear, Item}; +use crate::names::Culture; +use crate::save::{self, Character}; +use crate::seed; +use crate::space; + +/// The most letters a name, yours or your ship's, can have. +const NAME_LENGTH: usize = 28; +/// The most digits a universe can have. Any more and it might not fit. +const UNIVERSE_DIGITS: usize = 18; +/// Universes on offer stay short enough to read aloud, like the ones the +/// clock picks. +const UNIVERSES: u64 = 1_000_000_000; + +/// A line you can type into. Until you do, it holds a suggestion, and the +/// first letter you type replaces it. +#[derive(Clone, Debug, Default)] +struct Entry { + text: String, + typed: bool, +} + +impl Entry { + fn typed(text: &str) -> Entry { + Entry { text: text.to_string(), typed: true } + } + + fn suggest(&mut self, text: String) { + self.text = text; + self.typed = false; + } + + /// Takes a key meant for the text: a letter `allowed`, backspace, or + /// ctrl+u to clear the line. Says whether it was one. + fn edit(&mut self, key: KeyEvent, allowed: fn(char) -> bool, longest: usize) -> bool { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('u') if ctrl => self.text.clear(), + KeyCode::Char(c) if !ctrl && allowed(c) => { + if !self.typed { + self.text.clear(); + } + let space_wasted = c == ' ' && (self.text.is_empty() || self.text.ends_with(' ')); + if self.text.chars().count() < longest && !space_wasted { + self.text.push(c); + } + } + KeyCode::Backspace => { + self.text.pop(); + } + _ => return false, + } + self.typed = true; + true + } +} + +fn any_letter(c: char) -> bool { + !c.is_control() +} + +fn digit(c: char) -> bool { + c.is_ascii_digit() +} + +/// A name as typed, with runs of spaces closed up. +fn tidy(name: &str) -> String { + name.split_whitespace().collect::<Vec<_>>().join(" ") +} + +/// A ship's name without the "the" that goes in front of it anyway. +fn ship(name: &str) -> String { + let name = tidy(name); + if name.eq_ignore_ascii_case("the") { + return String::new(); + } + match name.get(..4) { + Some(the) if the.eq_ignore_ascii_case("the ") => name[4..].to_string(), + _ => name, + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Row { + Name, + Origin, + Ship, + Universe, + Begin, +} + +const ROWS: [Row; 5] = [Row::Name, Row::Origin, Row::Ship, Row::Universe, Row::Begin]; + +/// Making a new character. +pub struct Create { + row: Row, + name: Entry, + origin: Culture, + ship: Entry, + universe: Entry, + /// What the universe makes: "the Qeth system, in Qaari space". + system: String, + /// Suggestions are `derive(suggestions, what, n)`, so ← brings back + /// what → stepped past. + suggestions: u64, + /// Which suggestion each line is on. + names: u64, + ships: u64, + universes: u64, + problem: Option<String>, +} + +impl Create { + pub fn new(ctx: &Ctx) -> Create { + let suggestions = seed::from_clock(); + // A game started for a universe (`orrery play --seed`) makes its + // first character there. Otherwise every character gets their own. + let universe = if ctx.slot.is_none() { + ctx.seed + } else { + seed::derive(suggestions, "universe", 0) % UNIVERSES + }; + let mut create = Create { + row: Row::Name, + name: Entry::default(), + origin: space::home_system(universe).culture, + ship: Entry::default(), + universe: Entry { text: universe.to_string(), typed: false }, + system: String::new(), + suggestions, + names: 0, + ships: 0, + universes: 0, + problem: None, + }; + create.describe_universe(); + create.suggest_name(); + create.suggest_ship(); + create + } + + fn rng(&self, what: &str, n: u64) -> seed::Rng { + seed::rng(seed::derive(self.suggestions, what, n)) + } + + fn suggest_name(&mut self) { + let name = self.origin.person(&mut self.rng("name", self.names)); + self.name.suggest(name); + } + + fn suggest_ship(&mut self) { + let ship = self.origin.word(&mut self.rng("ship", self.ships)); + self.ship.suggest(ship); + } + + fn describe_universe(&mut self) { + self.system = match self.universe.text.parse::<u64>() { + Ok(universe) => { + let system = space::home_system(universe); + format!("the {} system, in {} space", system.name, system.culture.adjective()) + } + Err(_) => "a universe is a number".to_string(), + }; + } + + fn move_row(&mut self, by: isize) { + let at = ROWS.iter().position(|&r| r == self.row).unwrap_or(0); + let to = (at as isize + by).clamp(0, ROWS.len() as isize - 1); + self.row = ROWS[to as usize]; + } + + /// ← and →: another suggestion, or another people. + fn step(&mut self, by: i64) { + let next = |n: u64| n.saturating_add_signed(by); + match self.row { + Row::Name => { + self.names = next(self.names); + self.suggest_name(); + } + Row::Ship => { + self.ships = next(self.ships); + self.suggest_ship(); + } + Row::Origin => { + let at = Culture::ALL.iter().position(|&c| c == self.origin).unwrap_or(0); + let count = Culture::ALL.len() as i64; + self.origin = Culture::ALL[(at as i64 + by).rem_euclid(count) as usize]; + // Names you haven't chosen follow where you're from. + if !self.name.typed { + self.suggest_name(); + } + if !self.ship.typed { + self.suggest_ship(); + } + } + Row::Universe => { + self.universes = next(self.universes); + let universe = seed::derive(self.suggestions, "universe", self.universes); + self.universe.suggest((universe % UNIVERSES).to_string()); + self.describe_universe(); + } + Row::Begin => {} + } + } + + /// Makes the character and starts them off aboard their ship, landed on + /// their universe's home world. + fn begin(&mut self, ctx: &mut Ctx) -> Transition { + let name = tidy(&self.name.text); + let ship = ship(&self.ship.text); + let universe = self.universe.text.parse::<u64>(); + let problem = if name.is_empty() { + Some((Row::Name, "You'll need a name.".to_string())) + } else if ship.is_empty() { + Some((Row::Ship, "Your ship will need a name.".to_string())) + } else if universe.is_err() { + Some((Row::Universe, "A universe is a number.".to_string())) + } else { + None + }; + if let Some((row, problem)) = problem { + self.row = row; + self.problem = Some(problem); + return Transition::None; + } + let universe = universe.unwrap(); + let character = Character { name, origin: self.origin, ship }; + let slot = ctx.saves.as_ref().map(|dir| save::new_slot(dir, &character, universe)); + if slot.as_ref().is_some_and(|path| path.exists()) { + self.row = Row::Name; + self.problem = Some(format!( + "There's already a {} in universe {universe}. Load them from the title, or \ + choose another name.", + character.name + )); + return Transition::None; + } + // Whoever you were playing, as you left them. + ctx.autosave(); + ctx.begin(universe, character); + ctx.slot = slot; + ctx.landed = true; + Transition::Replace(flight::resume(ctx)) + } +} + +impl Scene for Create { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + self.problem = None; + match key.code { + KeyCode::Esc => return Transition::Pop, + KeyCode::Up | KeyCode::BackTab => self.move_row(-1), + KeyCode::Down | KeyCode::Tab => self.move_row(1), + KeyCode::Enter if self.row == Row::Begin => return self.begin(ctx), + KeyCode::Enter => self.move_row(1), + KeyCode::Left => self.step(-1), + KeyCode::Right => self.step(1), + _ => match self.row { + Row::Name => { + self.name.edit(key, any_letter, NAME_LENGTH); + } + Row::Ship => { + self.ship.edit(key, any_letter, NAME_LENGTH); + } + Row::Universe => { + if self.universe.edit(key, digit, UNIVERSE_DIGITS) { + self.describe_universe(); + } + } + Row::Origin | Row::Begin => {} + }, + } + Transition::None + } + + fn render(&self, frame: &mut Frame, _ctx: &Ctx) { + let tint = colour(self.origin); + let chosen = |row: Row| self.row == row; + let label = |row: Row, text: &'static str| { + let marker = if chosen(row) { "› " } else { " " }; + let style = if chosen(row) { Style::new().fg(ACCENT).bold() } else { Style::new() }; + vec![Span::styled(marker, style), Span::styled(format!("{text:<10}"), style.fg(DIM))] + }; + let cursor = + |row: Row| if chosen(row) { Span::raw("▏").fg(ACCENT) } else { Span::raw("") }; + let value = |entry: &Entry| { + let span = Span::raw(entry.text.clone()); + if entry.typed { span.bold() } else { span } + }; + + let mut name = label(Row::Name, "name"); + name.extend([value(&self.name), cursor(Row::Name)]); + let mut origin = label(Row::Origin, "from"); + if chosen(Row::Origin) { + origin.extend([ + Span::raw("‹ ").fg(DIM), + Span::raw(self.origin.name()).fg(tint).bold(), + Span::raw(" ›").fg(DIM), + ]); + } else { + origin.push(Span::raw(self.origin.name()).fg(tint)); + } + let mut ship = label(Row::Ship, "ship"); + ship.extend([Span::raw("the "), value(&self.ship), cursor(Row::Ship)]); + let mut universe = label(Row::Universe, "universe"); + universe.extend([value(&self.universe), cursor(Row::Universe)]); + let begin = if chosen(Row::Begin) { + Line::from(vec![Span::raw("› ").fg(ACCENT).bold(), "[ begin ]".fg(ACCENT).bold()]) + } else { + Line::from(" [ begin ]") + }; + + let hint = match self.row { + Row::Name => { + keys::line(&[("type", "your name"), ("← →", "another"), ("enter", "next")]) + } + Row::Origin => keys::line(&[("← →", "where you're from"), ("enter", "next")]), + Row::Ship => { + keys::line(&[("type", "your ship's name"), ("← →", "another"), ("enter", "next")]) + } + Row::Universe => { + keys::line(&[("type", "a universe"), ("← →", "another"), ("enter", "next")]) + } + Row::Begin => keys::line(&[("enter", "begin")]), + }; + let indent = |text: String| Line::from(format!(" {text}")).fg(DIM); + let mut lines = vec![ + Line::from("Who are you, and what do you fly?").fg(DIM).italic(), + Line::default(), + Line::from(name), + Line::from(origin), + indent(self.origin.blurb().to_string()).italic(), + Line::from(ship), + Line::from(universe), + indent(self.system.clone()), + Line::default(), + begin, + Line::default(), + ]; + match &self.problem { + Some(problem) => lines.push(Line::from(problem.as_str()).fg(Color::LightRed)), + None => lines.push(hint), + } + lines.push(keys::line(&[("↑↓ tab", "choose a line"), ("esc", "back")])); + + let area = boxed(frame.area(), 70, lines.len() as u16 + 4); + let block = Block::bordered() + .border_style(Style::new().fg(DIM)) + .title(Line::from(" A NEW CHARACTER ").fg(ACCENT).bold()) + .padding(Padding::uniform(1)); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }).block(block), area); + } +} + +/// A box up to `width` by `height`, in the middle of `area`. +fn boxed(area: Rect, width: u16, height: u16) -> Rect { + let [area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area); + let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area); + area +} + +/// "rust-mites", "hollow crewmen", "bulkhead sentries". +fn plural(name: &str) -> String { + if let Some(stem) = name.strip_suffix("man") { + format!("{stem}men") + } else if let Some(stem) = name.strip_suffix('y') + && !stem.ends_with(['a', 'e', 'o', 'u']) + { + format!("{stem}ies") + } else { + format!("{name}s") + } +} + +/// Your character sheet: who you are, what you carry, what you've done and +/// what you've learned. You can rename your ship here. +#[derive(Default)] +pub struct Sheet { + renaming: Option<Entry>, + /// Aboard a wreck: its name, and what you carry there, which is only + /// yours once you're back through the airlock. + wreck: Option<(String, Gear)>, +} + +impl Sheet { + pub fn new() -> Sheet { + Sheet::default() + } + + /// The sheet as it stands aboard the wreck `name`, carrying `gear`. + pub fn aboard(name: &str, gear: Gear) -> Sheet { + Sheet { renaming: None, wreck: Some((name.to_string(), gear)) } + } + + /// What you carry: aboard a wreck, what you have there. + fn gear<'a>(&'a self, ctx: &'a Ctx) -> &'a Gear { + self.wreck.as_ref().map_or(&ctx.hold.gear, |(_, gear)| gear) + } + + fn who(&self, ctx: &Ctx) -> Vec<Line<'static>> { + let c = &ctx.character; + let ship = match &self.renaming { + Some(entry) => vec![ + Span::raw("the "), + Span::raw(entry.text.clone()).bold(), + Span::raw("▏").fg(ACCENT), + ], + None => vec![Span::raw(c.ship_name())], + }; + let mut calling = vec![Span::raw(format!("{}, captain of ", c.calling()))]; + calling.extend(ship); + let place = &ctx.system.places[ctx.location].name; + let whereabouts = match &self.wreck { + Some((name, _)) => format!("aboard {name}"), + None if ctx.landed => "set down".into(), + None => "flying".into(), + }; + vec![ + Line::from(c.name.clone()).fg(colour(c.origin)).bold(), + Line::from(calling), + Line::from(format!( + "day {:.1} in the {} system, {whereabouts} at {place}", + ctx.day, ctx.system.name + )) + .fg(DIM), + Line::from(format!("universe {}", ctx.seed)).fg(DIM), + ] + } + + fn hold(&self, ctx: &Ctx) -> Vec<Line<'static>> { + let h = &ctx.hold; + let g = self.gear(ctx); + // What's on you and in the lockers, like `Hold::kept`, but with + // what you carry wherever you are. + let kept = || { + let pack = g.pack.iter().enumerate().map(|(i, s)| (Kept::Pack(i), s)); + pack.chain(h.lockers.iter().enumerate().map(|(i, s)| (Kept::Lockers(i), s))) + }; + let count = |matching: fn(&Item) -> bool| -> u32 { + kept().filter(|(_, s)| matching(&s.item)).map(|(_, s)| s.count).sum() + }; + let row = |label: &str, value: String| { + Line::from(vec![Span::raw(format!("{label:<11}")).fg(DIM), Span::raw(value)]) + }; + let mut ore = format!("{}/{} t", h.ore_tons(), h.ore_capacity); + if !h.ore.is_empty() { + let each: Vec<String> = h.ore.iter().map(|(name, t)| format!("{t} {name}")).collect(); + ore = format!("{ore}: {}", each.join(", ")); + } + let mut lines = vec![ + heading("HOLD"), + row("money", format!("{} ¤", h.scrip)), + row("ore", ore), + row("fuel", format!("{}/{} jumps", h.fuel, h.fuel_capacity)), + row("medpatches", count(|i| *i == Item::Medpatch).to_string()), + row("lenses", ctx.lenses.to_string()), + row("carrying", format!("{}/{} kg", items::kg(g.carried()), items::kg(g.capacity))), + row( + "lockers", + format!("{}/{} kg", items::kg(h.locker_mass()), items::kg(items::LOCKER_CAPACITY)), + ), + row("artifacts", count(|i| matches!(i, Item::Artifact(_))).to_string()), + ]; + if h.debt > 0 { + lines.insert(3, row("owed", format!("{} ¤, for a tow", h.debt))); + } + lines.extend(kept().filter_map(|(at, s)| { + let Item::Artifact(a) = &s.item else { return None }; + let name = Span::raw(format!(" * {}{}", a.name(), at.note())); + Some(Line::from(if a.identified { name.fg(Color::LightYellow) } else { name.italic() })) + })); + lines.push(Line::default()); + lines.push(heading("READY AND WORN")); + for w in [&g.melee, &g.gun] { + let item = Item::Weapon(w.clone()); + let (glyph, colour) = look(&item); + lines.push(Line::from(vec![ + Span::raw(format!("{glyph} ")).fg(rgb(colour)), + Span::raw(w.name()), + Span::raw(format!(" {}", w.damage_text())).fg(DIM), + ])); + } + for (slot, a) in &g.worn { + let (glyph, colour) = look(&Item::Armor(a.clone())); + lines.push(Line::from(vec![ + Span::raw(format!("{glyph} ")).fg(rgb(colour)), + Span::raw(a.name()), + Span::raw(format!( + " {}, {}, {}", + slot.name(), + a.guards_text(), + a.durability_text() + )) + .fg(DIM), + ])); + } + let packed = g.pack.len(); + if packed > 0 { + let s = if packed == 1 { "" } else { "s" }; + lines.push(Line::from(format!("and {packed} thing{s} in your pack [i]")).fg(DIM)); + } + lines + } + + fn record(ctx: &Ctx) -> Vec<Line<'static>> { + let r = &ctx.record; + let row = |label: &str, n: u32| { + Line::from(vec![Span::raw(format!("{label:<16}")).fg(DIM), Span::raw(n.to_string())]) + }; + let mut lines = vec![ + heading("RECORD"), + row("wrecks boarded", r.boarded), + row("got back from", r.escaped), + row("brought down", r.downed), + row("abandoned", r.abandoned), + row("killed", r.slain_total()), + row("systems seen", ctx.visited.len() as u32), + ]; + let mut slain: Vec<(&String, &u32)> = r.slain.iter().collect(); + slain.sort_by_key(|&(name, n)| (std::cmp::Reverse(*n), name.clone())); + lines.extend(slain.into_iter().map(|(name, &n)| { + let name = if n == 1 { name.clone() } else { plural(name) }; + Line::from(format!(" {n} {name}")) + })); + lines.push(Line::default()); + lines.push(heading("WHAT YOU KNOW")); + let mut any = false; + for kind in MONSTERS.iter() { + let known: Vec<Dmg> = + Dmg::ALL.into_iter().filter(|&d| ctx.bestiary.contains(&(kind.name, d))).collect(); + if known.is_empty() { + continue; + } + any = true; + let mut spans = vec![Span::raw(format!("{} ", kind.glyph)).fg(rgb(kind.rgb))]; + spans.push(Span::raw(kind.name)); + lines.push(Line::from(spans)); + let mut takes = vec![Span::raw(" ")]; + for (i, dmg) in known.into_iter().enumerate() { + let (word, tint) = match kind.affinity(dmg) { + Affinity::Weak => ("weak to", Color::Green), + Affinity::Normal => ("takes", Color::Gray), + Affinity::Resists => ("resists", Color::Yellow), + Affinity::Immune => ("immune to", Color::Red), + }; + if i > 0 { + takes.push(Span::raw(" · ").fg(DIM)); + } + takes.push(Span::raw(format!("{word} {}", dmg.name())).fg(tint)); + } + lines.push(Line::from(takes)); + } + if !any { + lines.push( + Line::from( + "Nothing yet. Hit what lives aboard wrecks with different kinds of weapon \ + to learn what hurts them.", + ) + .fg(DIM), + ); + } + lines + } +} + +fn heading(text: &'static str) -> Line<'static> { + Line::from(text).fg(ACCENT).dim().bold() +} + +impl Scene for Sheet { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + if let Some(entry) = &mut self.renaming { + match key.code { + KeyCode::Esc => self.renaming = None, + KeyCode::Enter => { + let name = ship(&entry.text); + if !name.is_empty() { + ctx.character.ship = name; + ctx.autosave(); + } + self.renaming = None; + } + _ => { + entry.edit(key, any_letter, NAME_LENGTH); + } + } + return Transition::None; + } + match key.code { + KeyCode::Char('r') => { + self.renaming = Some(Entry::typed(&ctx.character.ship)); + Transition::None + } + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('c' | 'q') => Transition::Pop, + _ => Transition::None, + } + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let (hold, record) = (self.hold(ctx), Sheet::record(ctx)); + // Tall enough for both columns side by side, or one under the + // other when it's too narrow for that; the rest is the header, + // the footer and the border. + let wide = frame.area().width >= 74; + let body = if wide { hold.len().max(record.len()) } else { hold.len() + 1 + record.len() }; + let area = boxed(frame.area(), 100, body as u16 + 9); + let block = Block::bordered() + .border_style(Style::new().fg(DIM)) + .title(Line::from(" CHARACTER ").fg(ACCENT).bold()) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + let [who, _, body, footer] = Layout::vertical([ + Constraint::Length(4), + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(inner); + frame.render_widget(Paragraph::new(self.who(ctx)).wrap(Wrap { trim: false }), who); + + if wide { + let [left, _, right] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(2), + Constraint::Fill(1), + ]) + .areas(body); + frame.render_widget(Paragraph::new(hold).wrap(Wrap { trim: false }), left); + frame.render_widget(Paragraph::new(record).wrap(Wrap { trim: false }), right); + } else { + let mut lines = hold; + lines.push(Line::default()); + lines.extend(record); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), body); + } + + let hints = if self.renaming.is_some() { + keys::line(&[ + ("type", "a new name"), + ("enter", "keep it"), + ("esc", "leave it as it was"), + ]) + } else { + keys::line(&[("r", "rename your ship"), ("esc", "back")]) + }; + frame.render_widget(Paragraph::new(hints), footer); + } +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + use super::*; + use crate::term::KeyboardCaps; + + fn ctx() -> Ctx { + Ctx::new(7, KeyboardCaps { key_release: false }) + } + + fn press(scene: &mut dyn Scene, ctx: &mut Ctx, code: KeyCode) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + fn typing(scene: &mut dyn Scene, ctx: &mut Ctx, text: &str) { + for c in text.chars() { + press(scene, ctx, KeyCode::Char(c)); + } + } + + fn screen(scene: &dyn Scene, ctx: &Ctx) -> String { + let mut terminal = Terminal::new(TestBackend::new(110, 44)).unwrap(); + terminal.draw(|frame| scene.render(frame, ctx)).unwrap(); + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + let cells: Vec<&str> = buffer.content.iter().map(|c| c.symbol()).collect(); + cells.chunks(width).map(|row| row.concat() + "\n").collect() + } + + #[test] + fn you_name_yourself_and_your_ship() { + let dir = save::scratch("create"); + let mut ctx = ctx(); + ctx.saves = Some(dir.clone()); + let mut create = Create::new(&ctx); + assert_eq!(create.universe.text, "7", "the first character plays the universe given"); + typing(&mut create, &mut ctx, "Ysolde Marr"); + press(&mut create, &mut ctx, KeyCode::Down); + press(&mut create, &mut ctx, KeyCode::Right); + press(&mut create, &mut ctx, KeyCode::Down); + typing(&mut create, &mut ctx, "the Long Patience"); + assert!(screen(&create, &ctx).contains("the the Long Patience")); + press(&mut create, &mut ctx, KeyCode::Enter); + press(&mut create, &mut ctx, KeyCode::Enter); + let Transition::Replace(scenes) = press(&mut create, &mut ctx, KeyCode::Enter) else { + panic!("begin starts the game"); + }; + assert_eq!(scenes.len(), 2, "aboard your ship, landed"); + // One step on from the people of the system the universe began in. + let began = ctx.galaxy.sites[ctx.galaxy.home].culture; + let origin = Culture::ALL[(Culture::ALL.iter().position(|&c| c == began).unwrap() + 1) % 4]; + assert_eq!( + ctx.character, + Character { name: "Ysolde Marr".into(), origin, ship: "Long Patience".into() } + ); + assert_eq!(ctx.star, ctx.galaxy.seat(origin), "you start at your own people's seat"); + assert_eq!(ctx.system.culture, origin); + assert_eq!(ctx.visited.iter().copied().collect::<Vec<_>>(), vec![ctx.star]); + assert_eq!((ctx.seed, ctx.landed, ctx.location), (7, true, ctx.system.home_place())); + assert_eq!(ctx.slot, Some(dir.join("ysolde-marr-0000000000000007.json"))); + ctx.autosave(); + assert!(ctx.slot.as_ref().unwrap().exists()); + + // The same name in the same universe would overwrite them. + let mut again = Create::new(&ctx); + again.universe.suggest("7".into()); + typing(&mut again, &mut ctx, "Ysolde Marr"); + again.row = Row::Begin; + assert!(matches!(press(&mut again, &mut ctx, KeyCode::Enter), Transition::None)); + assert!(screen(&again, &ctx).contains("There's already a Ysolde Marr")); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn suggestions_step_back_and_forth_and_follow_where_youre_from() { + let mut ctx = ctx(); + ctx.slot = Some("playing.json".into()); + let mut create = Create::new(&ctx); + assert_ne!(create.universe.text, "7", "later characters get a universe of their own"); + let first = create.name.text.clone(); + press(&mut create, &mut ctx, KeyCode::Right); + assert_ne!(create.name.text, first); + press(&mut create, &mut ctx, KeyCode::Left); + assert_eq!(create.name.text, first); + + create.row = Row::Ship; + typing(&mut create, &mut ctx, "Kettle"); + create.row = Row::Origin; + press(&mut create, &mut ctx, KeyCode::Right); + assert_ne!(create.name.text, first, "a suggested name changes with your people"); + assert_eq!(create.ship.text, "Kettle", "but not one you typed"); + + create.row = Row::Universe; + typing(&mut create, &mut ctx, "12x3"); + assert_eq!(create.universe.text, "123"); + assert!(create.system.contains("system")); + } + + #[test] + fn a_character_needs_names() { + let mut ctx = ctx(); + let mut create = Create::new(&ctx); + let clear = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL); + create.key(clear, &mut ctx); + create.row = Row::Begin; + assert!(matches!(press(&mut create, &mut ctx, KeyCode::Enter), Transition::None)); + assert_eq!(create.row, Row::Name); + assert!(screen(&create, &ctx).contains("You'll need a name.")); + typing(&mut create, &mut ctx, "Oru"); + create.row = Row::Ship; + create.key(clear, &mut ctx); + typing(&mut create, &mut ctx, "The "); + create.row = Row::Begin; + press(&mut create, &mut ctx, KeyCode::Enter); + assert_eq!(create.row, Row::Ship, "\"the\" alone isn't a name"); + } + + #[test] + fn the_sheet_shows_what_youve_done_and_renames_your_ship() { + let mut ctx = ctx(); + ctx.record.boarded = 4; + ctx.record.slain.insert("hollow crewman".into(), 3); + ctx.record.slain.insert("bulkhead sentry".into(), 2); + ctx.bestiary.insert(("rust-mite", Dmg::Blunt)); + let mut sheet = Sheet::new(); + let text = screen(&sheet, &ctx); + assert!(text.contains(&ctx.character.name)); + assert!(text.contains("3 hollow crewmen") && text.contains("2 bulkhead sentries")); + assert!(text.contains("rust-mite") && text.contains("blunt")); + + press(&mut sheet, &mut ctx, KeyCode::Char('r')); + sheet.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), &mut ctx); + typing(&mut sheet, &mut ctx, "Second Wind"); + assert!(matches!(press(&mut sheet, &mut ctx, KeyCode::Enter), Transition::None)); + assert_eq!(ctx.character.ship_name(), "the Second Wind"); + assert!(matches!(press(&mut sheet, &mut ctx, KeyCode::Esc), Transition::Pop)); + } + + #[test] + fn plurals() { + assert_eq!(plural("rust-mite"), "rust-mites"); + assert_eq!(plural("hollow crewman"), "hollow crewmen"); + assert_eq!(plural("bulkhead sentry"), "bulkhead sentries"); + } +} |