diff options
| author | skylar <me@casper> | 2026-11-11 15:24:37 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-11 15:24:37 -0800 |
| commit | 9ccf951473e21a8b90c25161156fa09a9b227a7b (patch) | |
| tree | e7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src/app.rs | |
| parent | d36c54d996f3c14ae811312dbbe621e12a39edde (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/app.rs')
| -rw-r--r-- | src/app.rs | 511 |
1 files changed, 488 insertions, 23 deletions
@@ -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"); + } } |