summaryrefslogtreecommitdiff
path: root/src/app.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.rs')
-rw-r--r--src/app.rs511
1 files changed, 488 insertions, 23 deletions
diff --git a/src/app.rs b/src/app.rs
index e3fe72a..e09f4af 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,14 +1,25 @@
//! The game loop and the scene stack.
+use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::io;
+use std::path::PathBuf;
use std::time::{Duration, Instant};
use ratatui::DefaultTerminal;
-use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
-use crate::names::Artifact;
+use crate::dungeon::Pos;
+use crate::dungeon::arms::Dmg;
+use crate::dungeon::crawl::SavedCrawl;
+use crate::items::{self, Gear, Item, Stack};
+use crate::save::{self, Character, Record};
+use crate::scenes::inventory::Sort;
use crate::scenes::{Scene, Transition, title::Title};
+use crate::seed;
+use crate::space::galaxy::{self, Galaxy};
+use crate::space::system::{System, Wreck};
use crate::term::{self, KeyboardCaps};
+use crate::trade::Rack;
/// Simulation rate. Real-time scenes (flight) move by this many steps a
/// second; turn-based scenes (dungeons) can ignore ticks and react to keys.
@@ -17,11 +28,19 @@ const TICK: Duration = Duration::from_nanos(1_000_000_000 / TICKS_PER_SECOND as
/// After a stall (suspended with ^Z, a slow terminal), run at most this many
/// missed ticks, then carry on from now instead of fast-forwarding.
const MAX_CATCH_UP: u32 = 5;
+/// Without key releases, a held key's auto-repeats look like presses. After
+/// the scene changes, presses are dropped for as long as they keep coming
+/// closer together than this, as auto-repeats do.
+const REPEAT_GAP: Duration = Duration::from_millis(150);
/// State every scene can see.
pub struct Ctx {
/// The universe seed. Everything procedural derives from it (see `seed`).
pub seed: u64,
+ /// Who you're playing.
+ pub character: Character,
+ /// What they've done.
+ pub record: Record,
/// Ticks since the game started.
pub ticks: u64,
pub keyboard: KeyboardCaps,
@@ -29,36 +48,425 @@ pub struct Ctx {
/// `derive(seed, "derelict", n)`.
pub boarded: u64,
pub hold: Hold,
+ /// Wrecks you've boarded, by seed. There's nothing left aboard them.
+ pub stripped: HashSet<u64>,
+ /// Every star system, and the lanes between them.
+ pub galaxy: Galaxy,
+ /// Which of the galaxy's systems you're in: an index into `galaxy.sites`.
+ pub star: usize,
+ /// The systems you've been to. They, and the systems a lane joins to
+ /// them, are charted: you can plot a route through them.
+ pub visited: BTreeSet<usize>,
+ /// The star system you're in, generated whole.
+ pub system: System,
+ /// What to tell you when you next take the controls, after a journey
+ /// the map can describe better than the flight can.
+ pub arrival_note: Option<String>,
+ /// Days since the game began. It moves on only when you travel, and
+ /// everything in the system moves with it.
+ pub day: f64,
+ /// The day you set out for where you are now, while you're still
+ /// there. Wrecks that were calling then are still here, even if their
+ /// beacons have failed on the way: you came by their last fix. `None`
+ /// after a jump or a tow, which bring you somewhere you didn't choose.
+ pub departed: Option<f64>,
+ /// Where in the system you are: an index into `system.places`.
+ pub location: usize,
+ /// Whether your ship is set down at `location`, rather than flying.
+ pub landed: bool,
+ /// Better lenses fitted to the mining laser.
+ pub lenses: u32,
+ /// How each kind of monster takes each kind of damage, as far as you've
+ /// found out. Unlike what you carry, you keep this whatever happens.
+ pub bestiary: BTreeSet<(&'static str, Dmg)>,
+ /// The wreck you're aboard, as it stood after your last move, so a save
+ /// can bring you back to it. `None` when you're not in one.
+ pub crawl: Option<SavedCrawl>,
+ /// Armourers' racks you've traded at this fortnight, as you left them.
+ pub racks: Vec<Rack>,
+ /// How your inventory's lists are sorted.
+ pub inventory_sort: Sort,
+ /// The folder saves go in. Without one, nothing is saved.
+ pub saves: Option<PathBuf>,
+ /// This character's save file. There's none until you've made or
+ /// loaded a character, and nothing is saved until there is.
+ pub slot: Option<PathBuf>,
+ /// Why the last save failed, if it did.
+ pub save_error: Option<String>,
+ /// Keys held down right now. Only kept on terminals that report key
+ /// releases (`keyboard.key_release`); elsewhere there's no telling when
+ /// a key is let go, and this stays empty.
+ held: HashSet<KeyCode>,
+ /// Keys that were down when the scene last changed. They don't count as
+ /// held, and their auto-repeats are dropped, until they're let go: the
+ /// step that takes you out through an airlock shouldn't carry on
+ /// walking you about on the other side.
+ carried: HashSet<KeyCode>,
+ /// The same for terminals that don't report releases: while the scene
+ /// has only just changed, when the last key came.
+ settling: Option<Instant>,
}
impl Ctx {
pub fn new(seed: u64, keyboard: KeyboardCaps) -> Ctx {
- Ctx { seed, ticks: 0, keyboard, boarded: 0, hold: Hold::default() }
+ let galaxy = galaxy::generate(seed);
+ let star = galaxy.home;
+ let system = galaxy.system(star);
+ // Someone to be until you make a character of your own.
+ let culture = system.culture;
+ let character = Character {
+ name: culture.person(&mut seed::rng(seed::derive(seed, "you", 0))),
+ origin: culture,
+ ship: culture.word(&mut seed::rng(seed::derive(seed, "your ship", 0))),
+ };
+ Ctx {
+ character,
+ record: Record::default(),
+ landed: false,
+ crawl: None,
+ racks: Vec::new(),
+ inventory_sort: Sort::default(),
+ saves: None,
+ slot: None,
+ save_error: None,
+ location: system.home_place(),
+ system,
+ galaxy,
+ star,
+ visited: BTreeSet::from([star]),
+ arrival_note: None,
+ day: 0.0,
+ departed: None,
+ lenses: 0,
+ bestiary: BTreeSet::new(),
+ seed,
+ ticks: 0,
+ keyboard,
+ boarded: 0,
+ hold: Hold::default(),
+ stripped: HashSet::new(),
+ held: HashSet::new(),
+ carried: HashSet::new(),
+ settling: None,
+ }
+ }
+
+ /// Starts a new game for `character` in universe `seed`, keeping only
+ /// what belongs to the terminal: the clock, the keyboard, where saves go.
+ /// The slot is cleared; whoever calls this decides where the new game
+ /// saves.
+ /// You start at the seat of your own people, on their homeworld.
+ pub fn begin(&mut self, seed: u64, character: Character) {
+ let mut fresh = Ctx::new(seed, self.keyboard);
+ let seat = fresh.galaxy.seat(character.origin);
+ if seat != fresh.star {
+ fresh.visited.clear();
+ fresh.enter_system(seat);
+ fresh.location = fresh.system.home_place();
+ }
+ fresh.character = character;
+ fresh.ticks = self.ticks;
+ fresh.saves = self.saves.take();
+ fresh.held = std::mem::take(&mut self.held);
+ fresh.carried = std::mem::take(&mut self.carried);
+ fresh.settling = self.settling;
+ *self = fresh;
+ }
+
+ /// Arrives in another system, at its edge, in flight.
+ pub fn enter_system(&mut self, star: usize) {
+ self.star = star;
+ self.system = self.galaxy.system(star);
+ self.visited.insert(star);
+ self.location = self.system.edge_place();
+ self.landed = false;
+ self.departed = None;
+ }
+
+ /// Flies to `place` in this system, `days` away.
+ pub fn travel(&mut self, place: usize, days: f64) {
+ self.departed = Some(self.day);
+ self.day += days;
+ self.location = place;
+ }
+
+ /// The wrecks you know of in this system that you haven't stripped:
+ /// those whose beacons are calling, and those here you came by a
+ /// failed beacon's last fix. Oldest first.
+ pub fn wrecks(&self) -> Vec<Wreck> {
+ let s = &self.system;
+ let mut wrecks = s.beacons(self.day, &self.stripped);
+ wrecks.retain(|w| w.place != self.location);
+ wrecks.extend(s.wrecks_at(self.location, self.day, self.departed, &self.stripped));
+ wrecks.sort_by(|a, b| a.arrived.total_cmp(&b.arrived));
+ wrecks
+ }
+
+ /// Whether you're out of fuel somewhere no one sells any.
+ pub fn stranded(&self) -> bool {
+ self.hold.fuel == 0 && !self.system.sells_fuel()
+ }
+
+ /// Saves the game, if there's a character to save.
+ pub fn autosave(&mut self) {
+ self.save_error = save::write(self).err().map(|e| format!("couldn't save: {e}"));
+ }
+
+ pub fn is_held(&self, code: KeyCode) -> bool {
+ let code = normalise(code);
+ self.held.contains(&code) && !self.carried.contains(&code)
+ }
+
+ /// Whether the top scene should get this key event. Keeps track of
+ /// which keys are held, too.
+ fn admit(&mut self, key: &KeyEvent, now: Instant) -> bool {
+ let code = normalise(key.code);
+ if self.keyboard.key_release {
+ // Scenes get presses and auto-repeats; releases only update
+ // which keys are held.
+ return match key.kind {
+ KeyEventKind::Release => {
+ self.held.remove(&code);
+ self.carried.remove(&code);
+ false
+ }
+ KeyEventKind::Repeat if self.carried.contains(&code) => false,
+ _ => {
+ self.held.insert(code);
+ self.carried.remove(&code);
+ true
+ }
+ };
+ }
+ match self.settling {
+ _ if key.kind == KeyEventKind::Release => false,
+ Some(last) if now.saturating_duration_since(last) < REPEAT_GAP => {
+ self.settling = Some(now);
+ false
+ }
+ _ => {
+ self.settling = None;
+ true
+ }
+ }
+ }
+
+ /// What's on screen changed under keys that are still down, without
+ /// the scene changing (a summary popped up over it): treat them as the
+ /// scene changing would, so a held key doesn't dismiss it unread.
+ pub fn settle(&mut self) {
+ self.scene_changed(Instant::now());
+ }
+
+ /// The top scene changed: keys still down were for the old one.
+ fn scene_changed(&mut self, now: Instant) {
+ self.carried.clone_from(&self.held);
+ if !self.keyboard.key_release {
+ self.settling = Some(now);
+ }
}
}
-/// What you've brought back from expeditions.
-#[derive(Default)]
+/// Letters count as the same key whatever the shift state, so releasing
+/// shift mid-hold doesn't leave a key stuck down.
+fn normalise(code: KeyCode) -> KeyCode {
+ match code {
+ KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()),
+ other => other,
+ }
+}
+
+/// Everything you own: money, ore and fuel, what you carry, and what's
+/// stowed aboard your ship.
pub struct Hold {
- pub artifacts: Vec<Artifact>,
- /// Salvage, counted by what it'll sell for.
+ /// Money, in ¤.
pub scrip: u32,
+ /// Tons of each kind of ore.
+ pub ore: BTreeMap<&'static str, u32>,
+ /// Tons of ore the hold can carry; cargo racks add more.
+ pub ore_capacity: u32,
+ /// What's on you: worn, ready and in your pack. It goes everywhere you
+ /// go, wrecks too, and if you don't get out of one, all but your own
+ /// torch and rivet gun stays there.
+ pub gear: Gear,
+ /// The ship's lockers, safe aboard whatever happens to you. Shops can
+ /// reach them, and send what you buy to them.
+ pub lockers: Vec<Stack>,
+ /// What you've put down on your ship's deck, and where.
+ pub deck: Vec<(Pos, Stack)>,
+ /// Jumps' worth of fuel in the tank. Each jump burns one, however long.
+ pub fuel: u32,
+ /// How many jumps' worth the tank holds; extra tanks add more.
+ pub fuel_capacity: u32,
+ /// What you owe, in ¤: a tow you couldn't pay for. What you earn pays it
+ /// off first.
+ pub debt: u32,
+}
+
+/// Where something you own is kept, for a shop.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Kept {
+ Pack(usize),
+ Lockers(usize),
+}
+
+impl Kept {
+ /// "", or " (in your ship)".
+ pub fn note(self) -> &'static str {
+ match self {
+ Kept::Pack(_) => "",
+ Kept::Lockers(_) => " (in your ship)",
+ }
+ }
+}
+
+impl Default for Hold {
+ fn default() -> Hold {
+ Hold {
+ scrip: 0,
+ ore: BTreeMap::new(),
+ ore_capacity: Hold::BASE_ORE_CAPACITY,
+ gear: Gear::default(),
+ lockers: Vec::new(),
+ deck: Vec::new(),
+ fuel: Hold::BASE_FUEL,
+ fuel_capacity: Hold::BASE_FUEL,
+ debt: 0,
+ }
+ }
+}
+
+impl Hold {
+ /// Tons of ore the hold carries before any racks are fitted.
+ pub const BASE_ORE_CAPACITY: u32 = 40;
+ /// Jumps the tank holds before any extra tanks are fitted.
+ pub const BASE_FUEL: u32 = 4;
+
+ /// Takes in money, paying off any debt first. Returns how much went to
+ /// the debt.
+ pub fn earn(&mut self, amount: u32) -> u32 {
+ let paid = amount.min(self.debt);
+ self.debt -= paid;
+ self.scrip += amount - paid;
+ paid
+ }
+
+ /// Pays a fee from what you have, and owes the rest. Returns how much
+ /// is owed.
+ pub fn charge(&mut self, fee: u32) -> u32 {
+ let paid = fee.min(self.scrip);
+ self.scrip -= paid;
+ self.debt += fee - paid;
+ fee - paid
+ }
+
+ pub fn ore_tons(&self) -> u32 {
+ self.ore.values().sum()
+ }
+
+ /// Everything a shop can reach: what's in your pack, then what's in
+ /// your lockers.
+ pub fn kept(&self) -> impl Iterator<Item = (Kept, &Stack)> {
+ let pack = self.gear.pack.iter().enumerate().map(|(i, s)| (Kept::Pack(i), s));
+ pack.chain(self.lockers.iter().enumerate().map(|(i, s)| (Kept::Lockers(i), s)))
+ }
+
+ pub fn kept_mut(&mut self, at: Kept) -> Option<&mut Stack> {
+ match at {
+ Kept::Pack(i) => self.gear.pack.get_mut(i),
+ Kept::Lockers(i) => self.lockers.get_mut(i),
+ }
+ }
+
+ /// Takes up to `count` of what's kept `at`.
+ pub fn take_kept(&mut self, at: Kept, count: u32) -> Stack {
+ match at {
+ Kept::Pack(i) => items::take(&mut self.gear.pack, i, count),
+ Kept::Lockers(i) => items::take(&mut self.lockers, i, count),
+ }
+ }
+
+ /// You lose everything you were `carrying` but your own torch and rivet
+ /// gun. If you'd left one in the lockers, you take it out again.
+ pub fn lose_carried(&mut self, carrying: &Gear) {
+ let lockers = &mut self.lockers;
+ self.gear = carrying.stripped(|gun| {
+ let own = |s: &Stack| matches!(&s.item, Item::Weapon(w) if w.kit && w.is_gun() == gun);
+ let i = lockers.iter().position(own)?;
+ match items::take(lockers, i, 1).item {
+ Item::Weapon(w) => Some(w),
+ _ => None,
+ }
+ });
+ }
+
+ /// How many artifacts you have, on you and aboard.
+ pub fn artifacts(&self) -> u32 {
+ self.kept().filter(|(_, s)| matches!(s.item, Item::Artifact(_))).map(|(_, s)| s.count).sum()
+ }
+
+ /// How many medpatches you have, on you and aboard.
+ pub fn medpatches(&self) -> u32 {
+ self.kept().filter(|(_, s)| s.item == Item::Medpatch).map(|(_, s)| s.count).sum()
+ }
+
+ /// Puts something you've bought in your pack if there's room, and in
+ /// your lockers if not. Says whether it's on you.
+ pub fn deliver(&mut self, stack: Stack) -> bool {
+ if self.gear.fits(&stack.item) >= stack.count {
+ self.gear.add(stack);
+ true
+ } else {
+ items::stow(&mut self.lockers, stack);
+ false
+ }
+ }
+
+ pub fn locker_mass(&self) -> u32 {
+ items::pile_mass(&self.lockers)
+ }
+
+ pub fn ore_room(&self) -> u32 {
+ self.ore_capacity.saturating_sub(self.ore_tons())
+ }
}
-pub fn run(seed: u64) -> io::Result<()> {
+/// Plays. Given a universe, starts a new character in it; otherwise picks up
+/// the character you last played, if there is one.
+pub fn run(universe: Option<u64>) -> io::Result<()> {
let (mut terminal, keyboard) = term::init()?;
- let ctx = Ctx::new(seed, keyboard);
- let result = run_loop(&mut terminal, ctx);
+ let mut ctx = Ctx::new(universe.unwrap_or_else(seed::from_clock), keyboard);
+ ctx.saves = save::default_dir();
+ if universe.is_none()
+ && let Some(dir) = &ctx.saves
+ {
+ let last = save::list(dir).into_iter().find(|s| s.summary.is_ok());
+ if let Some(slot) = last {
+ let _ = save::load(&slot.path, &mut ctx);
+ }
+ }
+ let result = run_loop(&mut terminal, &mut ctx);
+ ctx.autosave();
term::restore(keyboard);
result
}
-fn run_loop(terminal: &mut DefaultTerminal, mut ctx: Ctx) -> io::Result<()> {
- let mut stack: Vec<Box<dyn Scene>> = vec![Box::new(Title::new(&ctx))];
+/// After the stack changes: the new top scene catches up, and the game is
+/// saved.
+fn changed(stack: &mut [Box<dyn Scene>], ctx: &mut Ctx) {
+ ctx.scene_changed(Instant::now());
+ if let Some(top) = stack.last_mut() {
+ top.on_top(ctx);
+ }
+ ctx.autosave();
+}
+
+fn run_loop(terminal: &mut DefaultTerminal, ctx: &mut Ctx) -> io::Result<()> {
+ let mut stack: Vec<Box<dyn Scene>> = vec![Box::new(Title::new(ctx))];
let mut next_tick = Instant::now() + TICK;
while let Some(top) = stack.last() {
- terminal.draw(|frame| top.render(frame, &ctx))?;
+ terminal.draw(|frame| top.render(frame, ctx))?;
// Wait for input until the next tick is due, then take whatever else
// is already queued without waiting again.
@@ -71,23 +479,24 @@ fn run_loop(terminal: &mut DefaultTerminal, mut ctx: Ctx) -> io::Result<()> {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(());
}
- // Scenes get presses and auto-repeats. Releases only arrive on
- // terminals with `ctx.keyboard.key_release`; when flight needs
- // held keys, track them here before this filter.
- if key.kind == KeyEventKind::Release {
+ if !ctx.admit(&key, Instant::now()) {
continue;
}
let Some(top) = stack.last_mut() else { break };
- let transition = top.key(key, &mut ctx);
- apply(&mut stack, transition);
+ let transition = top.key(key, ctx);
+ if apply(&mut stack, transition) {
+ changed(&mut stack, ctx);
+ }
}
let mut caught_up = 0;
while Instant::now() >= next_tick {
let Some(top) = stack.last_mut() else { break };
ctx.ticks += 1;
- let transition = top.tick(&mut ctx);
- apply(&mut stack, transition);
+ let transition = top.tick(ctx);
+ if apply(&mut stack, transition) {
+ changed(&mut stack, ctx);
+ }
next_tick += TICK;
caught_up += 1;
if caught_up == MAX_CATCH_UP {
@@ -98,13 +507,69 @@ fn run_loop(terminal: &mut DefaultTerminal, mut ctx: Ctx) -> io::Result<()> {
Ok(())
}
-fn apply(stack: &mut Vec<Box<dyn Scene>>, transition: Transition) {
+/// Changes the scene stack, and says whether it did.
+fn apply(stack: &mut Vec<Box<dyn Scene>>, transition: Transition) -> bool {
match transition {
- Transition::None => {}
+ Transition::None => return false,
Transition::Push(scene) => stack.push(scene),
+ Transition::Open(scenes) => stack.extend(scenes),
Transition::Pop => {
stack.pop();
}
+ Transition::Replace(scenes) => {
+ stack.pop();
+ stack.extend(scenes);
+ }
+ Transition::Title => stack.truncate(1),
Transition::Quit => stack.clear(),
}
+ true
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn event(code: KeyCode, kind: KeyEventKind) -> KeyEvent {
+ KeyEvent::new_with_kind(code, KeyModifiers::NONE, kind)
+ }
+
+ #[test]
+ fn going_to_the_title_closes_everything_above_it() {
+ let ctx = Ctx::new(1, KeyboardCaps { key_release: false });
+ let mut stack: Vec<Box<dyn Scene>> = vec![Box::new(Title::new(&ctx))];
+ assert!(apply(&mut stack, Transition::Open(crate::scenes::flight::resume(&ctx))));
+ assert!(stack.len() > 1);
+ assert!(apply(&mut stack, Transition::Title));
+ assert_eq!(stack.len(), 1);
+ }
+
+ #[test]
+ fn keys_held_through_a_change_of_scene_stay_behind() {
+ let mut ctx = Ctx::new(1, KeyboardCaps { key_release: true });
+ let now = Instant::now();
+ assert!(ctx.admit(&event(KeyCode::Left, KeyEventKind::Press), now));
+ assert!(ctx.is_held(KeyCode::Left));
+ ctx.scene_changed(now);
+ assert!(!ctx.is_held(KeyCode::Left));
+ assert!(!ctx.admit(&event(KeyCode::Left, KeyEventKind::Repeat), now));
+ assert!(ctx.admit(&event(KeyCode::Right, KeyEventKind::Press), now), "others get through");
+ assert!(!ctx.admit(&event(KeyCode::Left, KeyEventKind::Release), now));
+ assert!(ctx.admit(&event(KeyCode::Left, KeyEventKind::Press), now));
+ assert!(ctx.is_held(KeyCode::Left));
+ }
+
+ #[test]
+ fn without_key_releases_auto_repeats_stay_behind() {
+ let mut ctx = Ctx::new(1, KeyboardCaps { key_release: false });
+ let start = Instant::now();
+ let at = |ms| start + Duration::from_millis(ms);
+ let left = event(KeyCode::Left, KeyEventKind::Press);
+ ctx.scene_changed(start);
+ assert!(!ctx.admit(&left, at(30)));
+ assert!(!ctx.admit(&left, at(60)));
+ assert!(!ctx.admit(&left, at(150)));
+ assert!(ctx.admit(&left, at(400)), "a press after a pause is a new one");
+ assert!(ctx.admit(&left, at(420)), "and from then on they all count");
+ }
}