summaryrefslogtreecommitdiff
path: root/src/scenes/character.rs
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-11 15:24:37 -0800
committerskylar <me@casper>2026-11-11 15:24:37 -0800
commit9ccf951473e21a8b90c25161156fa09a9b227a7b (patch)
treee7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src/scenes/character.rs
parentd36c54d996f3c14ae811312dbbe621e12a39edde (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.rs821
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");
+ }
+}