diff options
Diffstat (limited to 'src/scenes/saves.rs')
| -rw-r--r-- | src/scenes/saves.rs | 278 |
1 files changed, 278 insertions, 0 deletions
diff --git a/src/scenes/saves.rs b/src/scenes/saves.rs new file mode 100644 index 0000000..ca78eb2 --- /dev/null +++ b/src/scenes/saves.rs @@ -0,0 +1,278 @@ +//! The saved characters: pick one up where you left them, or delete them. + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Flex, Layout}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Padding, Paragraph}; + +use super::flight; +use super::forge::colour; +use super::keys::{self, ACCENT, DIM}; +use super::{Scene, Transition}; +use crate::app::Ctx; +use crate::save::{self, Slot}; + +/// Lines each save takes in the list, the gap after it included. +const ROWS_EACH: u16 = 3; + +pub struct Saves { + slots: Vec<Slot>, + selected: usize, + /// Asking whether to delete the selected save. + deleting: bool, + problem: Option<String>, +} + +impl Saves { + pub fn new(ctx: &Ctx) -> Saves { + let slots = ctx.saves.as_deref().map(save::list).unwrap_or_default(); + // Whoever you're playing is first, being the most recently saved. + Saves { slots, selected: 0, deleting: false, problem: None } + } + + fn playing(&self, ctx: &Ctx, i: usize) -> bool { + ctx.slot.as_ref() == Some(&self.slots[i].path) + } + + fn load(&mut self, ctx: &mut Ctx) -> Transition { + let Some(slot) = self.slots.get(self.selected) else { return Transition::None }; + if let Err(why) = &slot.summary { + self.problem = Some(format!("That save can't be read: {why}")); + return Transition::None; + } + if !self.playing(ctx, self.selected) { + ctx.autosave(); + if let Err(why) = save::load(&slot.path, ctx) { + self.problem = Some(format!("That save can't be read: {why}")); + return Transition::None; + } + } + Transition::Replace(flight::resume(ctx)) + } + + fn delete(&mut self, ctx: &mut Ctx) { + let path = self.slots[self.selected].path.clone(); + if let Err(why) = save::delete(&path) { + self.problem = Some(format!("Couldn't delete it: {why}")); + return; + } + // Delete who you're playing and there's no one left to save. + if ctx.slot.as_ref() == Some(&path) { + ctx.slot = None; + } + self.slots.remove(self.selected); + self.selected = self.selected.min(self.slots.len().saturating_sub(1)); + } +} + +/// "just now", "5 minutes ago", "3 days ago". +fn ago(seconds: u64) -> String { + let (n, unit) = match seconds { + 0..60 => return "just now".into(), + 60..3600 => (seconds / 60, "minute"), + 3600..86_400 => (seconds / 3600, "hour"), + _ => (seconds / 86_400, "day"), + }; + match n { + 1 if unit == "hour" => "an hour ago".into(), + 1 => format!("a {unit} ago"), + _ => format!("{n} {unit}s ago"), + } +} + +impl Scene for Saves { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + self.problem = None; + if self.deleting { + self.deleting = false; + if matches!(key.code, KeyCode::Char('y' | 'Y')) { + self.delete(ctx); + } + return Transition::None; + } + let last = self.slots.len().saturating_sub(1); + match key.code { + KeyCode::Esc | KeyCode::Char('q') => return Transition::Pop, + KeyCode::Up | KeyCode::Char('k') => self.selected = self.selected.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => self.selected = (self.selected + 1).min(last), + KeyCode::Enter => return self.load(ctx), + KeyCode::Char('d') | KeyCode::Delete if !self.slots.is_empty() => self.deleting = true, + _ => {} + } + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let [area] = + Layout::horizontal([Constraint::Length(84)]).flex(Flex::Center).areas(frame.area()); + let block = Block::bordered() + .border_style(Style::new().fg(DIM)) + .title(Line::from(" SAVED CHARACTERS ").fg(ACCENT).bold()) + .padding(Padding::uniform(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + let [list, _, footer] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1), Constraint::Length(2)]) + .areas(inner); + + let mut lines = Vec::new(); + if self.slots.is_empty() { + lines.push( + Line::from("No one's saved yet. Make a new character from the title.").fg(DIM), + ); + } + // Enough of the list to keep the selected save in view. + let shown = usize::from((list.height / ROWS_EACH).max(1)); + let first = (self.selected + 1).saturating_sub(shown); + let now = save::now(); + for (i, slot) in self.slots.iter().enumerate().skip(first).take(shown) { + let chosen = i == self.selected; + let marker = if chosen { Span::raw("› ").fg(ACCENT).bold() } else { Span::raw(" ") }; + match &slot.summary { + Ok(s) => { + let c = &s.character; + let mut who = vec![ + marker, + Span::raw(c.name.clone()).fg(colour(c.origin)).bold(), + Span::raw(format!(", {}, of {}", c.calling(), c.ship_name())), + ]; + if self.playing(ctx, i) { + who.push(Span::raw(" playing").fg(ACCENT)); + } + lines.push(Line::from(who)); + let boarded = match s.record.boarded { + 1 => "1 wreck boarded".to_string(), + n => format!("{n} wrecks boarded"), + }; + lines.push( + Line::from(format!( + " day {:.1} at {} · {} ¤ · {boarded} · saved {}", + s.day, + s.place, + s.scrip, + ago(now.saturating_sub(s.saved)) + )) + .fg(DIM), + ); + } + Err(why) => { + let file = slot.path.file_name().unwrap_or_default().to_string_lossy(); + lines.push(Line::from(vec![marker, Span::raw(file.into_owned()).fg(DIM)])); + lines.push(Line::from(format!(" can't be read: {why}")).fg(Color::LightRed)); + } + } + lines.push(Line::default()); + } + frame.render_widget(Paragraph::new(lines), list); + + let name = |i: usize| match &self.slots[i].summary { + Ok(s) => s.character.name.clone(), + Err(_) => "this save".into(), + }; + let mut help = Vec::new(); + if self.deleting { + let who = name(self.selected); + help.push(Line::from(format!("Delete {who} for good?")).fg(Color::LightRed)); + help.push(keys::line(&[("y", "delete"), ("any other key", "keep them")])); + } else { + if let Some(problem) = &self.problem { + help.push(Line::from(problem.as_str()).fg(Color::LightRed)); + } else if let Some(dir) = &ctx.saves { + help.push(Line::from(format!("saved in {}", dir.display())).fg(DIM)); + } + help.push(keys::line(&[ + ("↑↓", "choose"), + ("enter", "play"), + ("d", "delete"), + ("esc", "back"), + ])); + } + frame.render_widget(Paragraph::new(help), footer); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::names::Culture; + use crate::save::Character; + use crate::term::KeyboardCaps; + + fn press(scene: &mut Saves, ctx: &mut Ctx, code: KeyCode) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + /// A saves folder with two characters in it, and the game playing the + /// second. + fn two(dir: &std::path::Path) -> Ctx { + let mut ctx = Ctx::new(1, KeyboardCaps { key_release: false }); + ctx.saves = Some(dir.to_path_buf()); + for (name, universe, scrip) in [("Ada Vell", 11, 300), ("Bram Oskel", 22, 5)] { + let character = + Character { name: name.into(), origin: Culture::Cant, ship: "Bolt".into() }; + ctx.begin(universe, character); + ctx.slot = Some(save::new_slot(dir, &ctx.character, universe)); + ctx.hold.scrip = scrip; + ctx.autosave(); + assert!(ctx.save_error.is_none()); + } + ctx + } + + #[test] + fn loading_picks_up_where_they_left_off() { + let dir = save::scratch("saves-load"); + let mut ctx = two(&dir); + let mut saves = Saves::new(&ctx); + assert_eq!(saves.slots.len(), 2); + // Both were saved within the same second, so either may come first. + let ada = saves + .slots + .iter() + .position(|s| s.summary.as_ref().unwrap().character.name == "Ada Vell") + .unwrap(); + ctx.hold.scrip = 6; + saves.selected = ada; + assert!(matches!(press(&mut saves, &mut ctx, KeyCode::Enter), Transition::Replace(_))); + assert_eq!((ctx.character.name.as_str(), ctx.seed, ctx.hold.scrip), ("Ada Vell", 11, 300)); + + // Bram was saved as they were left, scrip and all. + let bram = save::new_slot( + &dir, + &Character { name: "Bram Oskel".into(), origin: Culture::Cant, ship: "Bolt".into() }, + 22, + ); + save::load(&bram, &mut ctx).unwrap(); + assert_eq!(ctx.hold.scrip, 6); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn deleting_asks_first() { + let dir = save::scratch("saves-delete"); + let mut ctx = two(&dir); + let mut saves = Saves::new(&ctx); + press(&mut saves, &mut ctx, KeyCode::Char('d')); + press(&mut saves, &mut ctx, KeyCode::Char('n')); + assert_eq!(saves.slots.len(), 2, "anything but yes keeps it"); + let playing = saves.playing(&ctx, 0); + press(&mut saves, &mut ctx, KeyCode::Char('d')); + press(&mut saves, &mut ctx, KeyCode::Char('y')); + assert_eq!((saves.slots.len(), save::list(&dir).len()), (1, 1)); + assert_eq!(ctx.slot.is_none(), playing, "delete who you're playing and nothing's saved"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn saved_times_read_naturally() { + assert_eq!(ago(5), "just now"); + assert_eq!(ago(60), "a minute ago"); + assert_eq!(ago(3 * 3600 + 5), "3 hours ago"); + assert_eq!(ago(3600), "an hour ago"); + assert_eq!(ago(86_400 * 2), "2 days ago"); + } +} |