//! 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::>().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, } 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::() { 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::(); 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, /// 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> { 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> { 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 = 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> { 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::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![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"); } }