summaryrefslogtreecommitdiff
path: root/src/scenes/saves.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/scenes/saves.rs')
-rw-r--r--src/scenes/saves.rs278
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");
+ }
+}