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 | |
| 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>
48 files changed, 25856 insertions, 338 deletions
@@ -780,6 +780,8 @@ dependencies = [ "rand 0.10.3", "rand_chacha", "ratatui", + "serde", + "serde_json", ] [[package]] @@ -1268,6 +1270,19 @@ dependencies = [ ] [[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1816,3 +1831,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" @@ -2,8 +2,17 @@ name = "orrery" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "An ASCII sci-fi-fantasy game for the terminal: derelicts, star systems, mining and cities" +license = "MIT" +repository = "https://git.skylarcloud.xyz/orrery.git" +keywords = ["game", "terminal", "roguelike", "space", "ascii"] +categories = ["games", "command-line-utilities"] +publish = false [dependencies] rand = "0.10.3" rand_chacha = "0.10.0" ratatui = "0.30.2" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The Orrery contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0e2ecc5 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +PREFIX ?= $(HOME)/.local +BINDIR = $(PREFIX)/bin +APPDIR = $(PREFIX)/share/applications +ICONDIR = $(PREFIX)/share/icons/hicolor/scalable/apps +ID = local.skylar.Orrery +BIN = orrery +UNAME := $(shell uname -s) + +build: + cargo build --release --locked + +run: + cargo run --release --locked + +# Plain mkdir and install -m, not install -D, which macOS's install lacks. +# The apps-menu launcher is for Linux desktops only; launchers don't search +# ~/.local/bin, so its Exec gets the full path. +install: build + mkdir -p $(BINDIR) + install -m 755 target/release/$(BIN) $(BINDIR)/$(BIN) +ifneq ($(UNAME),Darwin) + mkdir -p $(ICONDIR) $(APPDIR) + install -m 644 data/$(ID).svg $(ICONDIR)/$(ID).svg + sed 's|@BINDIR@|$(BINDIR)|' data/$(ID).desktop > $(APPDIR)/$(ID).desktop +endif + +uninstall: + rm -f $(BINDIR)/$(BIN) $(ICONDIR)/$(ID).svg $(APPDIR)/$(ID).desktop + +.PHONY: build run install uninstall @@ -4,56 +4,590 @@ An ASCII sci-fi-fantasy game for the terminal: a loose wrapping around a few minigames (dungeon crawling, flying star systems, mining), with procedurally generated everything. +## Installing + +Orrery is built from source. It needs git, Rust 1.88 or newer, and a +terminal, and nothing else: no libraries to install, no assets to fetch. + +### macOS + +In Terminal (Applications → Utilities → Terminal): + +1. Install Apple's command-line tools, which bring git and the linker Rust + builds with: + + ```sh + xcode-select --install + ``` + + A window asks to install them; click Install and wait for it to finish. + If it says they're already installed, go on to the next step. + +2. Install Rust: + + ```sh + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + ``` + + Press enter for the standard installation. Then quit Terminal (⌘Q) and + open it again, so it can find `cargo`. + +3. Download Orrery and build it. The first build takes a minute or two: + + ```sh + git clone https://git.skylarcloud.xyz/orrery.git + cd orrery + cargo install --locked --path . + ``` + +4. Play: + + ```sh + orrery + ``` + + If Terminal says `command not found`, run it as `~/.cargo/bin/orrery`. + +To update to the latest version later: + ```sh -cargo run # play (a random universe) -cargo run -- --seed 1312 # play a specific universe +cd ~/orrery +git pull +cargo install --locked --path . +``` + +Your saves carry over. They live in `~/.local/share/orrery/saves`, and +`cargo uninstall orrery` removes the game but leaves them. + +**Which terminal.** Orrery plays in Terminal, with two caveats, and +[Ghostty](https://ghostty.org) or [kitty](https://sw.kovidgoyal.net/kitty/) +avoid both: + +- Terminal on macOS older than 26 (Tahoe) can't show 24-bit colour, so + Orrery's colours come out wrong there. +- Terminal can't tell when a key is held down, so in flight `↑` and `↓` + step a throttle up and down instead of thrusting while held. Ghostty and + kitty can; the title screen says which kind you're in. + +Give it a big window, about 120 columns by 40 rows or more. Where the +keys below say `ctrl`, that's the Control key, not ⌘. + +### Linux + +Install git and Rust 1.88 or newer (from [rustup](https://rustup.rs) or +your distribution), then: + +```sh +git clone https://git.skylarcloud.xyz/orrery.git +cd orrery +make install +``` + +`make install` copies `orrery` to `~/.local/bin` and adds an apps-menu +launcher that opens it in kitty; `make uninstall` removes both. For the +game alone, `cargo install --locked --path .` puts it in `~/.cargo/bin`. + +## Running from a checkout + +```sh +cargo run # play: carry on as whoever you last played +cargo run -- --seed 1312 # make a new character in a specific universe cargo run -- names # print generated names, no TUI cargo run -- names --culture qaari -n 40 cargo run -- ship --seed 1312 -n 2 # print the 2nd derelict you'd board +cargo run -- weapons -n 20 # print weapons as you'd find them aboard wrecks +cargo run -- armour -n 20 # print armour as you'd find it aboard wrecks +cargo run -- galaxy --seed 1312 # print the star map: every system, its lanes, who holds it +cargo run -- system --seed 1312 # print the star system the universe began in +cargo run -- system --seed 1312 -n 7 # print the 7th system as `galaxy` numbers them +cargo run -- town --seed 1312 --inside # print its capital, and one of each kind of building inside cargo test ``` +## Characters and saves + +You play a character: a salvager with a name, a people they come from, and +a ship of their own. Press `n` on the title screen to make one. Each line +offers a suggestion, `←`/`→` steps through more of them, and you can type +your own instead. Choosing where you're from changes the names on offer, +unless you've typed one, and where you start: on the homeworld of your own +people, at their seat (see The star map). A character also gets a universe, +which is only a seed: type in someone else's to fly the same galaxy they +do. You start landed, aboard your ship. + +`esc` opens a menu wherever you are in the game: flying, aboard your ship, +in a city, at a dock, in a wreck. To quit, choose "Save and quit to the +title" from it, and on the title `esc` quits Orrery; `esc` again just closes +the menu. The menu also has whatever `esc` used to do there (abandon ship, +take off, back to your ship, back out to the street), and keeps everything +still while it's open. Anything else open (the inventory, a shop +counter, a map, aiming) closes first. + +The game also saves itself every time the screen changes (landing, +boarding, opening the map, coming back aboard) and when you quit, even with +ctrl+c. Next time, `orrery` picks up the character you last played where +you left them, and `enter` on the title carries on. `l` lists every saved +character, to play one or delete one; `c`, on the title, in flight or +aboard your ship, shows your character sheet: what's in your hold, on you +and in your lockers, the wrecks you've boarded and how you left them, what +you've killed, and what you've learned about what hurts it. You can rename +your ship there (`r`). + +Saves are JSON, one file per character, in `$XDG_DATA_HOME/orrery/saves` +(`~/.local/share/orrery/saves` by default). A save holds the universe seed +and what you've changed in it: your hold, where you are and what day it is, +which wrecks you've stripped, what you've learned. The universe itself is +made again from the seed. Quit halfway through a derelict and you come back +aboard it, just as you left it. + ## Derelicts -Press `d` on the title screen to board a derelict. Each one is a ship-shaped +Fly alongside a wreck and board it (see Flight), or press `d` on the title +screen to board one straight away. Each one is a ship-shaped map: an engine room at the stern, a bridge at the nose, a spine corridor between them, and rooms either side (cargo holds, a reliquary, a med-bay...). You see only what your lamp reaches. Monsters stay dormant until they come into view, then hunt you. -Walk over things to pick them up: `*` artifacts, `%` salvage, `!` medpatches -(press `m` to use one). Walk back into the airlock `Θ` to leave with what -you're carrying, which goes into your hold. If you die or abandon ship with -`esc`, you lose what you were carrying, but your hold is safe. +Walk over things to pick them up: `*` artifacts, `(` salvage, `!` medpatches +(press `m` to use one), `)` and `}` weapons, `[` armour. What won't fit in your pack +stays where it is, and so does what you put down yourself; `g` (or `,`) +picks it up, and lists what's there when there's more than one thing. Walk +back into the airlock `Θ` to leave with what you're carrying. If you die or +abandon ship (from the `esc` menu), you lose everything you carried but your +own torch and rivet gun. What's in your ship's lockers is safe (see What you +carry). + +Salvage is heavy (a stack of hull plates is 12 kg) and sells to a city's ore +broker or at a station's market. + +### Fighting + +You fight with two weapons ready: one to hit with, used by walking into +something, and a gun, fired with `f`. You start with your cutting torch +and a rivet gun, and find more aboard: `)` blades and clubs, `}` guns. +Machine shops have tools, armouries have the best stock, chapels and +reliquaries have stranger things. Each weapon is rolled from a kind (a +cleaver, a scattergun, a psalm-horn...) and a condition: worn ones hit +softer, fine ones carry an affix ("serrated", "long-barrelled"), and the +rare relic has two and a name of its own. + +- **Fire** (`f`): aim at the nearest thing in sight, `tab` for the next, + or move the reticle anywhere. The panel shows the odds of a hit. Shots + stray up to the gun's *spread* either side, so the odds fall with + distance; scatterguns fire several pellets that each stray on their own. + Guns hold a few rounds, and reloading (`r`) takes a turn. Gunfire is + loud: it wakes anything sleeping nearby. +- **Ready** (`i`, then `w` on a weapon): readying another weapon takes a + turn, and the one in your hand goes into your pack. The inventory shows + every weapon's stats. + +Each kind of damage does something besides hurt: + +| Damage | Also | +| --- | --- | +| cut | the living bleed, a little less each turn | +| blunt | knocks back; into a bulkhead, it staggers them | +| pierce | goes through a monster's armour; fast rounds go through into what's behind | +| burn | sets things alight for a few turns | +| shock | may stun, costing the target its next turn | +| hymn | may steady you as it lands | + +And every monster takes each kind its own way: rust-mites crack under +blows, drones short out, rivets pass straight through a choir-wraith. +Hitting something tells you how it took it, and the panel keeps what +you've learned, from wreck to wreck. Bulkhead sentries shoot from where +they're bolted; bilge-hounds run two steps to your one. Claws make you +bleed, and a medpatch stops it. Welder drones spray fire from a few cells +off and set you alight: you burn 1–2 a turn for a couple of turns, until +the flames go out or a medpatch smothers them, and your torch does +nothing to them. Cargo loaders lumber a step only every other turn, so you +can get away, but they hit hard, and often knock you back a cell; into a +bulkhead, you reel and lose a turn. + +### Armour + +You can wear a piece on your head, body, hands and feet. Each guards +against certain kinds of blow, taking that much off one of that kind (a +blow always does at least 1): a flight-jacket guards cut 2 and blunt 1 and +keeps a claw wound from bleeding, insulated gauntlets guard shock, a +choir-hood hymn, a vac-helmet pierce and blunt. A rust-mite's bite through +a vac-helmet shows as "pierce 2 - 1 = 1". Armouries and crew berths have +the most, chapels and reliquaries warded pieces. Like weapons, each piece +is rolled from a kind and a condition: worn ones don't last as long, fine +ones carry an affix ("insulated", "reinforced", "styptic-lined"), and the +rare relic has two and a name of its own. + +Every blow a piece softens wears it by 1 (shown as "durability 12/30"), +and so does every turn of burning a burn guard softens. Below half it +guards 1 less against everything; worn out, it guards nothing at all, +though it stays on you. An armourer mends it, for more the more worn and +the finer it is: about half its price from worn out. Armour weighs what it +weighs, and counts against what you can carry like anything else. + +## What you carry + +Everything you own is a thing with a weight. What you carry is limited by +weight alone: 25 kg to begin with, more with a bigger pack from a city's +shipyard (a frame pack, +10 kg, then a hauler rig, +10 more). Your ship's +lockers hold the rest, up to 400 kg, and they're safe: only what you carry +goes aboard a wreck. + +`i` opens your inventory wherever you're on foot: in a wreck, aboard your +ship, in a city. It's laid out like Cataclysm: DDA's: what you wear and +have ready at the top, then your pack by kind (weapons, armor, food, +medical, artifacts, salvage), and all about the one picked out beside +them: a weapon you're not holding says how it measures up against the one +you have ready in its place, and armour against what you wear in its +place. Aboard your ship the lockers sit beside your pack; at an +armourer's, the rack; elsewhere, whatever's on the floor where you stand. + +Every row shows what it weighs and what it would fetch. At an armourer's +that's their price; anywhere else it's about what a typical buyer would +give, marked `~`: an armourer for arms and armour, a broker for salvage, +a scholar for an artifact once one has told you what it is (`?` until +then). Things no one buys show no price. `o` sorts both lists, within each +kind, by name, weight, price, price per kilo, durability (the most worn +armour first), strength (a weapon's average damage, armour's total guard) +or how recently you came by it; `O` turns the sort round. Your sort is +saved with your game. + +| Key | Does | +| --- | --- | +| `j` `k` | move | +| `gg` `G` | top, end; `5G` goes to the fifth | +| `ctrl-d` `ctrl-u` | half a page down, up | +| `h` `l` | your pack, or what's beside it | +| `/` | find by name or kind; `esc` clears it | +| `o` `O` | sort by the next thing, turn the sort round | +| `enter` | what you can do with it, as a menu | +| `w` | ready a weapon, swapping it for the one in hand | +| `W` `T` | wear armor, take it off | +| `E` | eat | +| `a` | use a medpatch, in a wreck | +| `d` `D` | drop one, drop the whole stack | +| `m` `M` | move one, or the whole stack, to the lockers, or take it from them or the floor | +| `b` `s` | at an armourer's: buy what's on the rack, sell a weapon or armour of yours | +| `r` `R` | at an armourer's: mend a piece of armour, or all of yours at once | +| `esc` `q` `i` | close | + +Counts work as they do in vim: `3d` drops three. There's nothing to eat +aboard the wrecks yet: eating is there for when there is. + +In a wreck, readying, wearing, taking off, eating and using a medpatch each +take a turn, and close the inventory so you see what came of it. Looking, +sorting and dropping are free. + +What you put down stays where you put it: on your ship's deck for good, in +a wreck with the wreck, in a city until you leave it. That goes for your own +cutting torch and rivet gun too, once something else is ready in their +place: you can't put down the last thing you have to hit with, or to shoot +with. If you lose everything aboard a wreck, your torch and rivet gun come +back with you, if you had them; a hand left empty takes yours from the +lockers, or a spare from the ship's stores. + +## Flight + +Take off from the pilot's seat to fly. Each star system is modelled properly: planets on Kepler orbits around the star (periods +from Kepler's third law, eccentric orbits that speed up near the star), moons +inside their planets' Hill spheres, an asteroid belt past the frost line, +and stations at the Lagrange points 60° ahead of or behind a planet. + +You never fly at that scale. You fly around one *place* at a time: in orbit +around a planet or moon, among the rocks of the belt, or by a station. Each +place is its own local map, lit from wherever the star really is on that +date, with nearby moons or a looming parent planet hanging in the sky. + +- **Fly:** turn, thrust, brake (`r` or `↓`). It's arcade flight: nothing + pulls on you. Without held keys, `↑`/`↓` step a throttle instead. +- **Mine:** the laser (`space`) wears rocks down; they split and spill ore, + which you scoop up by flying over it. The hold takes 40 tons. +- **Land or dock** (`l`) when slow and over a world, or close to a station. + On a world where people live you get up in the cockpit and walk aft + through your ship to the airlock (see Your ship), which opens onto the + landing field outside its capital (see Cities). Anywhere without a city + takes you straight to a simple screen about the place: at a station, a + dock that buys ore and salvage (`s`) and sells fuel (`f`). `l` there + takes off, or undocks, again. +- **Get up** (`e`) when slow, to walk about your ship while it holds + station. +- **Board wrecks** (`b`): blinking red beacons, also flagged `!` on the map. + Wrecks come and go. Each beacon calls for three to six weeks and then + fails, and the wreck is lost; new ones drift in all the while (a couple + at a time in most systems, more in graveyards). A wreck whose beacon is + calling when you set out for it is still there when you arrive, even if + the beacon fails on the way: you come by its last fix. It stays while + you're there, as does a wreck you've stripped, and is gone the next time + you come. The system map says how long each beacon has been calling, and + dims the `!` and warns when one is failing, so it'll wait for you only if + you go straight there. +- **Travel** (`m`): the system map lists every place and how many days the + trip takes. Trips are quicker than real ones, and far places cost only a + little more than near ones: a moon is hours away, most planets two to + five days, and the far edge of the widest systems about ten. Trips aim + for where the destination will be when you arrive, and the calendar + moves on, so every planet does too. Each place is marked + with what you'll find there: `⌂` a capital city, `¤` a station that only + trades ore, `·` neither (empty worlds, giants with no ground, the belt). + Choosing a place also shows a survey of it: temperature on a thermometer, + with its swing over the year, day length, gravity, air, sky and how many + people live there. `g` swaps it for the star map. +- **Jump** (`g`): the star map, to leave the system (see below). + +## The star map + +A universe is a galaxy of about ninety star systems, joined by lanes. You +can only jump along a lane, so to reach a far system you jump through the +ones between. Each jump takes a few days, longer for longer lanes, and +burns a jump's worth of fuel however long it is. You come out at the edge +of the new system, by its outermost planet or its belt, and fly in from +there. + +Every system is held by one of the four peoples. Each has a seat, a system +with their homeworld and its great capital, and their territory grows out +from it along the lanes, so territories come in ragged patches of +different sizes. Near a border the neighbours settle too, and keep the odd +station; old battlefields cluster there. + +Systems come in kinds: + +| Kind | What's there | +| --- | --- | +| seat | a people's homeworld and great capital, busy stations | +| settled | a capital and settled worlds, a station or two | +| frontier | a small capital or an outpost station, little else | +| desolate | no one at all, dead rock and ice, often a red dwarf. No fuel | +| rubble field | few planets, and a wide belt thick with ore, worked by mining stations | +| graveyard | an old battlefield: wrecks everywhere, few people | + +The map (`g` in flight, or from the system map) scrolls to follow your +choice, with an inset of the whole galaxy. Each people's space is a stipple +in their colour, brighter along the borders. + +- **Charted space.** You know the way through systems you've been to and + those one lane from them. Choose any of those (arrows, or `tab` for the + lanes from here) and the map plots the fewest jumps there; `enter` makes + them. Further out are faint uncharted dots, and the four seats, which + everyone knows. What's in a system you haven't been to you'll only know + from tavern talk: they'll tell you which neighbours are dead, or rich. +- **Fuel.** The tank holds 4 jumps. Stations sell fuel at the dock (`f`) + and every city's shipyard sells it, along with extra tanks (+2 jumps + each, two of them). +- **Stranded.** Run dry in a system where no one sells fuel and `t` on the + star map calls a tow to the nearest system that does. It's slow and + dear: what you can't pay, you owe, and what you earn (ore, artifacts, + salvage) pays it off first. + +## Your ship + +Your ship is somewhere you walk, too: the same dart you fly, with the +cockpit in its nose, an engine room in each swept-back wing, and the +airlock in the notch at the stern, and the name you gave it. + +When you land outside a city you get up from the pilot's seat, and the way +out is at the other end of the ship: walk aft, down the corridor and through the +hold, into the airlock `Θ`. Coming back, you climb in by the same airlock, +and to take off you walk forward to the cockpit and sit in the pilot's seat +`╥`. + +Out in space, `e` gets you up from the controls whenever you're flying +slowly, and the ship holds station until you sit back down. If a wreck is +alongside, the airlock boards it; otherwise there's only vacuum out there. + +On the way through you pass what you've gathered: the hold has a bin for +every five tons of ore you can carry, more as cargo racks are fitted, and +they fill with what you've mined. The shelf in the crew quarters shows the +artifacts in your lockers, and the med-bay cabinet the medpatches; the +weapons locker beside the shelf opens the lockers, as `i` does anywhere +aboard. `g` picks up what you've put down on the deck. Bump anything to +look at it. Shift and a direction runs, stopping at each doorway; the +`esc` menu tells you which way to go. + +## Cities + +Land on a settled world or moon and you set down on a landing field outside +its capital. Each city is generated from the place, so it's the same city +every time you come back, and its size comes from how many people live on +the world: from a walled town of about a hundred buildings to a sprawl of +several hundred. Its streets, roofs and talk come from the culture that +lives there: Qaari cisterns, Cant furnaces and pipework, High House +fountains, Drift scrap. + +The world it stands on has a say too: + +| World | Outside the walls | Inside | +| --- | --- | --- | +| Desert | sand, cacti `ψ`, boulders | few parks, dry scrub, palms, small cisterns | +| Ice | snow, ice spires `▲`, boulders | frost-ferns, glass-pines, frozen ponds | +| Ocean | open sea `≈`, a black-sand shore | green gardens; the city's an island, its landing field on a spit and its other roads causeways out to sea | +| Jungle | undergrowth and thick trees | parks everywhere, trees crowding the yards | +| Barren rock | grey dust, crags, crystals `♦` | rust lichen, crystal spires, no ponds | +| Forge | ash, basalt, burning vents `^` | ash-moss, fungus stalks `τ`, pools of lava | + +The weather on the day you land shows on worlds with air and water: frost +from about 4°C down, freezing the ponds below about -4°C, and gardens going +brown from 30°C up, the ponds dry by 46°C. And every city's ground is tinted +a little its own way, so two on the same kind of world don't look alike. + +Walk in through the gate. Avenues run from the gates to the market square, +and the rest of the city is split into quarters, each with its own name and +its own streets: alleys and tenements in the poor quarter, workshops and +warehouses in the works, big houses and wide streets on the rise around the +palace, shrines round the temple, parks with ponds in the gardens. No two +cities are laid out alike. + +Every building is a roof from the street. Step through an open door and the +building becomes its own map, drawn larger, with rooms laid out for what it +is: a house's kitchen and bedrooms, a palace's throne room and council +chamber, a temple's nave, a tavern's taproom, a bathhouse's baths. Some +doors are barred. You leave by the door you came in by, or from the `esc` +menu. + +Shops have their trade painted on the roof. Walk up to a shop's counter, or +bump the keeper, to trade: + +- **Ore broker** (`ORE`): buys all your ore at this city's prices, and + salvage, a stack at a time or all of it. +- **Doctor**: sells medpatches, up to 9 on you and aboard together. +- **Scholar**: for a fee, tells you what an artifact really is, then offers + to buy it. An artifact is worth the same everywhere, give or take what + this scholar likes. +- **Tavern**: a round buys you a rumour about the rest of the system: where + wrecks went dark, where an ore sells best. Bigger cities have more than + one. +- **Shipyard**: fuel, cargo racks (+20 tons of ore space, three sets), + better laser lenses (two, each cutting faster), extra fuel tanks (+2 + jumps, two of them) and bigger packs (+10 kg, two of them). +- **Armourer** (`ARMS`, usually in the works quarter): buys and sells + weapons and armour, and mends armour. Its counter opens your inventory + with the rack beside it, every price shown: `b` buys, `s` sells, `r` + mends the piece picked out and `R` everything of yours that needs it, on + you and aboard. They pay about half what they'd charge, and less for + worn armour. A rack holds six to ten weapons and two to five pieces of + armour and is restocked every fortnight; what you sell goes on it, so you + can buy it back until then. Each people stocks its own sort: Qaari blades, + wraps and veils against burns and cuts; Cant shock and burn tools, and + insulated plate; High House duelling pieces and fine plate and warded + pieces, in fine condition; Drift guns and patched vac-gear. About one + rack in five has a relic weapon, at a price, and now and then a relic + piece of armour. You can sell a weapon you have ready if you've another + of its sort to ready in its place, even your own torch or rivet gun; and + armour off your back. + +Brokers, scholars and armourers deal in what you carry and what's in your +ship's lockers alike; the lockers' are marked "(in your ship)". What you +buy that won't fit in your pack is sent to your ship. Prices differ from +city to city but stay the same in any one. + +People walking about step aside as you walk through them. People who stay +put (stallholders, guards, anyone at work indoors) are in the way, and +bumping them is how you talk to them. `t` talks to whoever is next to you; +passers-by will sometimes point you to a shop you haven't found yet. + +- **Run:** shift and a direction runs until something changes: a turning, + a door, someone in the way. +- **Map** (`m` or `tab`): the whole city, with its quarters, the shops, the + palace and the temple marked. +- **Inventory** (`i`): what you carry. `g` picks up what you put down. +- **Leave:** walk out the door you came in by, and back to your ship; or + pick it from the `esc` menu. ## Layout | File | What it does | | --- | --- | -| `src/app.rs` | Game loop (fixed 30 Hz tick) and the scene stack | -| `src/scenes/` | One file per screen. A scene handles keys, ticks and drawing, and returns a `Transition` (push, pop, quit) | +| `src/app.rs` | Game loop (fixed 30 Hz tick), the scene stack, and saving whenever it changes | +| `src/save.rs` | Characters, what they've done, and save files: writing, listing, loading what still fits | +| `src/scenes/` | One file per screen. A scene handles keys, ticks and drawing, and returns a `Transition` (push, pop, replace, quit). `inventory.rs` isn't a screen but a panel the scenes you walk in open over themselves; it asks its scene to carry out what you choose | +| `src/items.rs` | Everything you can own: items, stacks, weights, what you wear, have ready and carry, and bigger packs | | `src/seed.rs` | Stable seed derivation: the whole universe is a function of one `u64` | | `src/names/` | Name generator: per-culture syllables and templates, plus artifact names | -| `src/dungeon/` | Map and tiles; `fov.rs` (sight), `ship.rs` (generating derelicts), `crawl.rs` (turn rules, monsters, loot) | +| `src/dungeon/` | Map and tiles; `fov.rs` (sight), `ship.rs` (generating derelicts), `crawl.rs` (turn rules, fighting, monsters, loot), `arms.rs` (damage types, weapon kinds and affixes, generating weapons, the geometry of a shot), `armour.rs` (armour kinds and affixes, guards and wear, generating armour) | +| `src/town/` | Capital cities: `mod.rs` (tiles, buildings, people), `plan.rs` (generating the city: walls, gates, quarters, streets, buildings, shops), `interior.rs` (generating a building's inside when you enter), `words.rs` (names and what people say) | +| `src/trade.rs` | Prices and what they buy: ore markets, medpatches, scholars, arms and armour and mending, what a typical buyer pays, fittings, fuel, tows and debt, tavern rumours | +| `src/space/` | `galaxy.rs` (the systems, lanes, territories and routes), `orbit.rs` (Kepler orbits, trip times), `system.rs` (generating star systems of each kind, and their places), `survey.rs` (each place's temperature, day, gravity, air and sky, for show), `locale.rs` (each place's local map), `flight.rs` (flight and mining rules) | | `src/term.rs` | Terminal setup, including the kitty keyboard protocol where supported | -| `src/cli.rs` | Command-line arguments and `orrery names` | +| `src/cli.rs` | Command-line arguments, `orrery names`, `ship`, `weapons`, `armour`, `galaxy`, `system` and `town` | ## Things to know - **Seeds must stay stable.** `seed::derive` and `seed::rng` give the same output forever, and a test pins them. If you change them, every universe and save file changes too. +- **Saves outlive code.** A save names ores and monsters by name, and loading + drops what no longer exists rather than refusing the save. Adding a field + is fine if it has a `#[serde(default)]`; anything older Orreries can't + read means bumping `save::VERSION`. +- **Weights are grams**, in `u32`, shown as kg to a decimal place. Your + torch and rivet gun are kit (`Item::is_kit`): they go anywhere anything + else does, but survive losing what you carried (`Hold::lose_carried`). + Each hand always holds a weapon of its sort; `Gear::remove` won't empty + one, and `Gear::mend` puts right a save that says otherwise. +- **A wreck in progress is saved as what's changed.** `ctx.crawl` holds + `Crawl::saved()` after every move: where you are, what's alive, what's + lying about, what you carry, the doors open and the cells seen. The ship + is made again from its seed (`Crawl::resume`), and the dice roll afresh + from there. A saved wreck that no longer fits the ship its seed makes is + dropped on loading, and you're back as you were before you boarded. +- **A weapon's price comes from its stats** (`arms::value`): its average + damage times how surely it lands, plus its extra, raised to the 1.5 and + multiplied up for a fine one or a relic. A piece of armour's + (`armour::value`) comes from what it guards against, whether it stops + bleeding and how long it lasts, raised to the 1.25 and multiplied up the + same way; worn down it's worth less (`armour::worth`), by just what + mending it would cost (`armour::mending`), so buying one worn out and + mending it costs what a whole one does. An armourer's `trade::Terms` set + what they ask, pay and charge to mend against those. Racks are kept in + `Ctx::racks` (saved) only for the fortnight they're stocked for; + `trade::rack` stocks one afresh when the fortnight turns, and a rack + saved before armour has none until then. +- **A city's world changes its plan, but not its weather.** `town::Land` + holds the world's `Terrain` and today's frost and drought. Only the + terrain goes into `town::generate` (`plan::Lay`), so a city is laid out + the same whenever you land; the weather and `Town::tint` only change how + `scenes::town::theme` draws it. +- **Pause menus are panels, not scenes**, like the inventory + (`scenes::pause`): the scene beneath stays drawn, and stops ticking. + `Transition::Title` closes everything above the title screen, which is + always at the bottom of the stack. +- **Save version 2 is the inventory.** Version 1 saves are converted as + they load (`save::from_before`): weapons and medpatches go on you, + artifacts into the lockers, and whatever's too heavy to carry into the + lockers too. - **Names are tuned by ear.** Edit the syllable lists in `src/names/mod.rs`, then run `cargo run -- names --culture <name> -n 40`. - **Held keys.** Only terminals with the kitty keyboard protocol report key releases. The title screen shows whether yours does. Real-time controls - need a fallback for terminals that don't. + need a fallback for terminals that don't. Keys held down when the scene + changes stay with the old scene: they don't count as held, and their + auto-repeats are dropped, until they're let go (`Ctx::admit`), so the + step out of an airlock doesn't walk you straight back in. - **Cells are about twice as tall as they are wide.** For geometry, either double x, or draw on a braille `Canvas`, where the dots are roughly square - (the title screen's orrery does this). + (the title screen's orrery does this). Local flight maps use one unit per + cell across and two per cell down. +- **Three coordinate systems.** The galaxy is in light-years, y up. Star + systems are in AU and days, y up, anticlockwise. Local maps are y down, + like the screen. +- **Wrecks are made by the calendar.** `System::wrecks_on(day)` makes them + in ten-day batches, each seeded from the system and the batch's number, + so any day's wrecks are always the same and nothing about them needs + saving except which you've stripped, and the day you set out for where + you are (`Ctx::departed`): `System::wrecks_at` adds the wrecks calling + then, so one whose beacon failed on the way is still there. Naming a + wreck generates its whole ship, so a system caches batches it has made. +- **The first system never changes.** The system a universe started + everyone in, before there was a galaxy, is still generated by + `system::generate` from its seed alone, drawing from the RNG exactly as + it did; old saves are there. Other systems are made by + `system::generate_as` from what the galaxy decided about them, and can + change freely. ## Next -1. System flight and asteroid mining -2. Glue: galaxy map, ports, a market to sell salvage, scholars to identify - artifacts, quest templates +1. Astrology and culture woven into the sky (to be designed) +2. Glue: quest templates, and reasons to cross the map + +## Licence + +MIT. See `LICENSE`. diff --git a/data/local.skylar.Orrery.desktop b/data/local.skylar.Orrery.desktop new file mode 100644 index 0000000..434efe3 --- /dev/null +++ b/data/local.skylar.Orrery.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=Orrery +GenericName=Space Game +Comment=ASCII space game: jump between star systems across four peoples' territory, walk your own ship, board derelicts for salvage, mine asteroids, explore cities +Exec=kitty --class local.skylar.Orrery --title Orrery @BINDIR@/orrery +Icon=local.skylar.Orrery +Terminal=false +Categories=Game;AdventureGame; +Keywords=space;ascii;roguelike;terminal;stars;city;spaceship;galaxy;map;inventory;salvage;weapons;armor;armour;trading; +StartupWMClass=local.skylar.Orrery diff --git a/data/local.skylar.Orrery.svg b/data/local.skylar.Orrery.svg new file mode 100644 index 0000000..54610ce --- /dev/null +++ b/data/local.skylar.Orrery.svg @@ -0,0 +1,16 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> + <rect x="2" y="2" width="60" height="60" rx="12" fill="#14182b"/> + <g fill="#8a93b8"> + <circle cx="12" cy="12" r="1"/> + <circle cx="50" cy="10" r="0.8"/> + <circle cx="53" cy="51" r="1"/> + <circle cx="13" cy="53" r="0.8"/> + </g> + <g fill="none" stroke="#c9a85a" stroke-width="1.6" transform="rotate(-20 32 32)"> + <ellipse cx="32" cy="32" rx="14" ry="8"/> + <ellipse cx="32" cy="32" rx="25" ry="14"/> + </g> + <circle cx="32" cy="32" r="6" fill="#f2c14e"/> + <circle cx="45.2" cy="27.2" r="2.6" fill="#6fb3d2"/> + <circle cx="24.4" cy="47.7" r="3.4" fill="#d9734e"/> +</svg> @@ -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"); + } } @@ -1,28 +1,62 @@ -//! Command-line arguments, `orrery names` and `orrery ship`. +//! Command-line arguments, `orrery names`, `ship`, `weapons`, `armour`, +//! `galaxy`, `system` and `town`. + +use std::collections::HashMap; use crate::dungeon::Pos; use crate::dungeon::crawl::Crawl; +use crate::dungeon::{armour, arms}; +use crate::items::Item; use crate::names::{self, Culture}; -use crate::scenes::derelict::{item_look, tile_look}; +use crate::scenes::derelict::tile_look; +use crate::scenes::inventory::look; use crate::seed; +use crate::space::Vec2; +use crate::space::galaxy::{self, HALF_HEIGHT, HALF_WIDTH}; +use crate::space::system::{Landing, PlaceKind, SystemKind}; +use crate::space::{orbit, survey}; +use crate::{scenes, town}; pub const USAGE: &str = "\ usage: orrery [--seed N] orrery names [--culture NAME] [-n COUNT] [--seed N] orrery ship [-n WHICH] [--seed N] + orrery weapons [-n COUNT] [--seed N] + orrery armour [-n COUNT] [--seed N] + orrery galaxy [--seed N] + orrery system [-n WHICH] [--seed N] + orrery town [-n WHICH] [--inside] [--seed N] - --seed N universe seed; the same seed always makes the same universe + --seed N universe seed; the same seed always makes the same universe. + To play, without it you carry on as whoever you last played; + with it, you make a new character in that universe names print generated names instead of playing --culture NAME qaari, cant, houses or drift (default: all four) -n COUNT how many of each kind to print (default: 8) ship print a whole derelict, as you'd find it on boarding -n WHICH which derelict: the first you'd board, the second... (default: 1) + weapons print weapons as you might find them aboard wrecks + -n COUNT how many (default: 12) + armour print armour as you might find it aboard wrecks + -n COUNT how many (default: 12) + galaxy print the star map: every system, its lanes and who holds it + system print a star system: orbits, moons, places + -n WHICH which system, numbered as `galaxy` lists them (default: the + one the universe started in) + town print a capital city in the system you start in + -n WHICH which capital: the homeworld's is 1, then outwards (default: 1) + --inside print the inside of one building of each kind, too "; pub enum Command { Play { seed: Option<u64> }, Names(NamesOpts), Ship { seed: u64, which: u64 }, + Weapons { seed: u64, count: usize }, + Armour { seed: u64, count: usize }, + Galaxy { seed: u64 }, + System { seed: u64, which: Option<u64> }, + Town { seed: u64, which: u64, inside: bool }, Help, } @@ -34,13 +68,22 @@ pub struct NamesOpts { pub fn parse(args: &[String]) -> Result<Command, String> { let mut args = args.iter().map(String::as_str).peekable(); - let sub = args.next_if(|a| matches!(*a, "names" | "ship")); - let (mut seed, mut culture, mut count) = (None, None, None); + let sub = args.next_if(|a| { + matches!(*a, "names" | "ship" | "weapons" | "armour" | "galaxy" | "system" | "town") + }); + let (mut seed, mut culture, mut count, mut inside) = (None, None, None, false); while let Some(arg) = args.next() { match arg { "-h" | "--help" => return Ok(Command::Help), "--seed" => seed = Some(number(args.next(), "--seed")?), - "-n" if sub.is_some() => count = Some(number(args.next(), "-n")?), + "--inside" if sub == Some("town") => inside = true, + "-n" if matches!( + sub, + Some("names" | "ship" | "weapons" | "armour" | "system" | "town") + ) => + { + count = Some(number(args.next(), "-n")?) + } "--culture" if sub == Some("names") => { let name = args.next().ok_or("--culture needs a value")?; culture = Some(Culture::parse(name).ok_or(format!("no culture called {name:?}"))?); @@ -54,6 +97,23 @@ pub fn parse(args: &[String]) -> Result<Command, String> { seed: seed.unwrap_or_else(seed::from_clock), which: count.unwrap_or(1), }), + Some("weapons") => Ok(Command::Weapons { + seed: seed.unwrap_or_else(seed::from_clock), + count: count.unwrap_or(12) as usize, + }), + Some("armour") => Ok(Command::Armour { + seed: seed.unwrap_or_else(seed::from_clock), + count: count.unwrap_or(12) as usize, + }), + Some("galaxy") => Ok(Command::Galaxy { seed: seed.unwrap_or_else(seed::from_clock) }), + Some("system") => { + Ok(Command::System { seed: seed.unwrap_or_else(seed::from_clock), which: count }) + } + Some("town") => Ok(Command::Town { + seed: seed.unwrap_or_else(seed::from_clock), + which: count.unwrap_or(1), + inside, + }), _ => Ok(Command::Names(NamesOpts { cultures: culture.map_or(Culture::ALL.to_vec(), |c| vec![c]), count: count.unwrap_or(8) as usize, @@ -80,7 +140,7 @@ pub fn print_ship(seed: u64, which: u64) { } else if let Some(m) = crawl.monster_at(p) { m.kind.glyph } else if let Some(l) = crawl.loot_at(p) { - item_look(&l.item).0 + look(&l.stack.item).0 } else { tile_look(map.tile(p)).0 } @@ -91,14 +151,296 @@ pub fn print_ship(seed: u64, which: u64) { let mut rooms: Vec<&str> = map.rooms.iter().map(|r| r.name).collect(); rooms.sort_unstable(); rooms.dedup(); - println!("\n@ you Θ airlock + door * artifact % salvage ! medpatch"); + println!( + "\n@ you Θ airlock + door * artifact ( salvage ! medpatch ) weapon }} gun [ armor % food" + ); println!( "monsters: {}", crawl.monsters.iter().map(|m| m.kind.name).collect::<Vec<_>>().join(", ") ); + let weapons: Vec<String> = crawl + .loot + .iter() + .filter_map(|l| match &l.stack.item { + Item::Weapon(w) => Some(format!("{} ({})", w.name(), w.damage_text())), + _ => None, + }) + .collect(); + println!("weapons: {}", weapons.join(", ")); + let armour: Vec<String> = crawl + .loot + .iter() + .filter_map(|l| match &l.stack.item { + Item::Armor(a) => Some(format!("{} ({})", a.name(), a.guards_text())), + _ => None, + }) + .collect(); + println!("armour: {}", armour.join(", ")); println!("rooms: {}", rooms.join(", ")); } +/// Prints `count` weapons as you might find them aboard wrecks in universe +/// `seed`, for tuning the tables in `dungeon::arms`. +pub fn print_weapons(seed: u64, count: usize) { + print!("{}", arms::catalogue(&mut seed::rng(seed::derive(seed, "catalogue", 0)), count)); +} + +/// Prints `count` pieces of armour as you might find them aboard wrecks in +/// universe `seed`, for tuning the tables in `dungeon::armour`. +pub fn print_armour(seed: u64, count: usize) { + let mut rng = seed::rng(seed::derive(seed, "armour catalogue", 0)); + print!("{}", armour::catalogue(&mut rng, count)); +} + +/// Prints the galaxy of universe `seed`: a map, then every system by who +/// holds it, numbered for `orrery system -n`. +pub fn print_galaxy(seed: u64) { + let g = galaxy::generate(seed); + // Two light-years to a column, four to a row, so it fits a terminal. + let (cols, rows) = ((HALF_WIDTH * 1.2) as usize, (HALF_HEIGHT * 0.6) as usize); + let cell = |p: Vec2| { + let col = ((p.x / HALF_WIDTH * 1.1 + 1.1) / 2.2 * cols as f64) as usize; + let row = ((1.1 - p.y / HALF_HEIGHT * 1.1) / 2.2 * rows as f64) as usize; + (col.min(cols - 1), row.min(rows - 1)) + }; + let mut grid = vec![vec![' '; cols]; rows]; + for (i, s) in g.sites.iter().enumerate() { + for &j in s.lanes.iter().filter(|&&j| j > i) { + let to = g.sites[j].pos; + let steps = (s.pos.distance(to) * 2.0) as usize; + for k in 1..steps { + let (c, r) = cell(s.pos + (to - s.pos) * (k as f64 / steps as f64)); + grid[r][c] = '·'; + } + } + } + for (i, s) in g.sites.iter().enumerate() { + let (c, r) = cell(s.pos); + let letter = s.culture.adjective().chars().next().unwrap(); + grid[r][c] = if g.is_seat(i) { letter } else { letter.to_ascii_lowercase() }; + } + println!("A galaxy of {} systems (universe {seed})\n", g.sites.len()); + for row in grid { + println!(" {}", row.into_iter().collect::<String>().trim_end()); + } + println!("\n Q/q Qaari C/c Cant H/h High Houses D/d Drift (capitals: seats)"); + for c in Culture::ALL { + let held: Vec<usize> = (0..g.sites.len()).filter(|&i| g.sites[i].culture == c).collect(); + let seat = &g.sites[g.seat(c)].name; + println!("\n{}: {} systems, seat {seat}", c.name().to_uppercase(), held.len()); + for i in held { + let s = &g.sites[i]; + let start = if i == g.home { " (the universe began here)" } else { "" }; + let border = s.neighbour.map_or(String::new(), |n| format!(", borders {}", n.name())); + let lanes = match s.lanes.len() { + 1 => "1 lane".to_string(), + n => format!("{n} lanes"), + }; + println!( + " {:>3} {:<16}{:<22}{:<16}{lanes}{border}{start}", + i + 1, + s.name, + s.kind.name(), + s.star.name + ); + } + } + let count = |k: SystemKind| g.sites.iter().filter(|s| s.kind == k).count(); + let kinds: Vec<String> = + SystemKind::ALL.into_iter().map(|k| format!("{} {}", count(k), k.name())).collect(); + println!("\n{}", kinds.join(", ")); +} + +/// Prints a star system in universe `seed`: the `which`th as `galaxy` +/// numbers them, or the one the universe started in. +pub fn print_system(seed: u64, which: Option<u64>) { + let g = galaxy::generate(seed); + let site = match which { + Some(n) if (1..=g.sites.len() as u64).contains(&n) => n as usize - 1, + Some(n) => { + println!("There's no system {n}: they're numbered 1 to {}.", g.sites.len()); + return; + } + None => g.home, + }; + let s = g.system(site); + let kind = match s.kind { + SystemKind::Seat => format!("the seat of {}", s.culture.name()), + k => format!("{} held by {}", k.name(), s.culture.name()), + }; + println!("The {} system, a {}: {kind} (universe {seed})", s.name, s.star.name); + println!("\n {:<26}{:>7}{:>6}{:>9} what", "planet", "AU", "e", "year"); + for (i, p) in s.planets.iter().enumerate() { + let people = p.people.map_or(String::new(), |c| format!(", {}", c.adjective())); + let home = if i == s.home { " (home)" } else { "" }; + println!( + " {:<26}{:>7.2}{:>6.2}{:>8.0}d {}{people}{home}", + p.name, p.orbit.a, p.orbit.e, p.orbit.period, p.kind.name + ); + for m in &p.moons { + let people = m.people.map_or(String::new(), |c| format!(", {}", c.adjective())); + let km = m.orbit.a * 149.6e6; + println!( + " moon {:<21}{:>6.0}k km{:>7.1}d {}{people}", + m.name, + km / 1000.0, + m.orbit.period, + m.kind.name + ); + } + } + println!("\n {} lies from {:.2} to {:.2} AU", s.belt.name, s.belt.inner, s.belt.outer); + for st in &s.stations { + let point = if st.leading { "L4" } else { "L5" }; + println!(" {} sits at {}'s {point} point", st.name, s.planets[st.planet].name); + } + + let home = s.home_place(); + let from = s.position(home, 0.0); + println!( + "\n places (⌂ capital, ¤ trade only, · neither), and the trip from {} on day 0:", + s.places[home].name + ); + // The wrecks calling on day 0, when a new game starts. + let calling = s.wrecks_on(0.0); + for (i, place) in s.places.iter().enumerate() { + let indent = if matches!(place.kind, PlaceKind::Moon(..) | PlaceKind::Station(_)) { + " " + } else { + " " + }; + let trip = if i == home { + "you are here".to_string() + } else { + format!("{:.1} days", orbit::plan_trip(from, 0.0, |d| s.position(i, d))) + }; + let wrecks: Vec<String> = calling + .iter() + .filter(|w| w.place == i) + .map(|w| format!("wreck: {}, {} (until day {:.0})", w.name, w.description, w.leaves)) + .collect(); + let mark = match s.landing(i) { + Landing::Capital(_) => "⌂", + Landing::Market(_) => "¤", + _ => "·", + }; + let name = format!("{indent}{}", place.name); + println!("{mark} {name:<30}{trip:<14}{}", wrecks.join("; ")); + } +} + +/// Prints the `which`th capital in the system you start in, in universe +/// `seed`: the homeworld's first, then the others from the star outwards. +/// With `inside`, prints the inside of one building of each kind too. +pub fn print_town(seed: u64, which: u64, inside: bool) { + let s = crate::space::home_system(seed); + let home = s.home_place(); + let mut capitals: Vec<(usize, Culture)> = (0..s.places.len()) + .filter_map(|i| match s.landing(i) { + Landing::Capital(c) => Some((i, c)), + _ => None, + }) + .collect(); + capitals.sort_by_key(|&(i, _)| i != home); + let Some(&(place, culture)) = capitals.get(which.saturating_sub(1) as usize) else { + println!("There are only {} capitals in this system.", capitals.len()); + return; + }; + let people = survey::survey(&s, place, 0.0).population; + let land = town::Land::of(&s, place, 0.0).expect("a capital is on a planet or moon"); + let t = town::generate(town::seed_for(s.seed, place), culture, town::size_for(people), land); + let theme = scenes::town::theme(&t); + println!( + "{}, the capital of {}: a city of {} on {:?} ground, {} by {} \ + (universe {seed}, capital {which} of {})\n", + t.name, + s.places[place].name, + culture.name(), + t.land.terrain, + t.width(), + t.height(), + capitals.len() + ); + let folk: HashMap<Pos, ()> = t.site.people.iter().map(|p| (p.pos, ())).collect(); + for y in 0..t.height() { + let row: String = (0..t.width()) + .map(|x| { + let p = Pos::new(x, y); + if folk.contains_key(&p) { + '&' + } else { + scenes::town::street_look(&t, p, &theme, 0).0 + } + }) + .collect(); + println!("{}", row.trim_end()); + } + println!("\nquarters:"); + for q in &t.quarters { + println!(" {:<28}{:?}", q.name, q.kind); + } + println!("\nshops:"); + let wide = t.shops.iter().map(|s| s.name.chars().count()).max().unwrap_or(0) + 2; + for shop in &t.shops { + let b = &t.buildings[shop.building]; + let quarter = t.quarter_at(b.door).map_or("", |q| q.name.as_str()); + println!( + " {:<wide$}{:<10}kept by {}, in {quarter}", + shop.name, + shop.kind.sign(), + shop.keeper + ); + } + let mut kinds: Vec<(String, usize)> = Vec::new(); + for b in &t.buildings { + let kind = format!("{:?}", b.kind); + match kinds.iter_mut().find(|(k, _)| *k == kind) { + Some((_, n)) => *n += 1, + None => kinds.push((kind, 1)), + } + } + let kinds: Vec<String> = kinds.iter().map(|(k, n)| format!("{n} {k}")).collect(); + println!( + "\n{} buildings: {}\n{} people in the streets", + t.buildings.len(), + kinds.join(", "), + t.site.people.len() + ); + if !inside { + return; + } + let mut shown: Vec<String> = Vec::new(); + for (b, building) in t.buildings.iter().enumerate() { + let kind = format!("{:?}", building.kind); + if building.barred || shown.contains(&kind) { + continue; + } + shown.push(kind); + let inside = t.enter(b); + println!( + "\n{} ({} rooms, {} people)", + building.name, + inside.room_count(), + inside.site.people.len() + ); + for y in 0..inside.site.height { + let row: String = (0..inside.site.width) + .map(|x| { + let p = Pos::new(x, y); + if p == inside.start { + '@' + } else if inside.site.person_at(p).is_some() { + '&' + } else { + scenes::town::inside_look(&t, &inside, p, &theme, 0).0 + } + }) + .collect(); + println!(" {}", row.trim_end()); + } + } +} + fn number(value: Option<&str>, flag: &str) -> Result<u64, String> { let value = value.ok_or(format!("{flag} needs a value"))?; value.parse().map_err(|_| format!("{flag}: {value:?} isn't a whole number")) diff --git a/src/dungeon/armour.rs b/src/dungeon/armour.rs new file mode 100644 index 0000000..80357ea --- /dev/null +++ b/src/dungeon/armour.rs @@ -0,0 +1,526 @@ +//! Armour: the kinds there are, the pieces you find aboard wrecks and buy +//! at an armourer's, what they guard against, and what they're worth. +//! +//! Each piece guards against certain kinds of damage, taking that much off +//! a blow of that kind (a blow always does at least 1). Every blow a piece +//! softens wears it down a little: below half its durability it guards 1 +//! less against everything, and worn out it guards nothing at all, though +//! it stays on you. An armourer mends it, for more the more worn it is and +//! the finer. +//! +//! Like a weapon, a piece is a kind rolled up with a condition: worn ones +//! don't last as long, fine ones carry an affix ("insulated", +//! "reinforced"), and the rare relic carries two and a name of its own. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +use rand::RngExt; +use rand::seq::IndexedRandom; +use serde::{Deserialize, Serialize}; + +use super::arms::{self, Condition, Dmg, Source}; +use crate::items::Slot; +use crate::names::{self, Culture, pick}; +use crate::seed::Rng; + +/// A piece of armour. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Armor { + /// What it's called, without an article: "insulated vac-helmet", or for + /// a relic its own name, "the Iron Vow". + pub title: String, + /// What it is: "a fine insulated vac-helmet". For a relic, the only + /// place that says what kind of thing it is. + pub description: String, + pub slot: Slot, + /// What it takes off a blow of each kind, while it's in good repair. + #[serde(default)] + pub guards: BTreeMap<Dmg, i32>, + /// Whether it keeps a wound closed: a cut that lands doesn't bleed. + #[serde(default)] + pub stanches: bool, + /// What's left of it: one less for every blow it softens. + #[serde(default)] + pub durability: u32, + /// What it has when it's whole. + #[serde(default)] + pub max_durability: u32, + #[serde(default = "sound")] + pub condition: Condition, + /// A relic with its own name. + #[serde(default)] + pub named: bool, + /// Gloves and boots: "a pair of mag-boots". + #[serde(default)] + pub pair: bool, + /// What it weighs, in grams. + pub mass: u32, +} + +fn sound() -> Condition { + Condition::Sound +} + +impl Armor { + /// "a vac-helmet", "a pair of mag-boots", "the Iron Vow". + pub fn name(&self) -> String { + if self.named { + self.title.clone() + } else if self.pair { + format!("a pair of {}", self.title) + } else { + names::a(&self.title) + } + } + + /// "your vac-helmet", "the Iron Vow": the subject of a sentence. + pub fn yours(&self) -> String { + if self.named { self.title.clone() } else { format!("your {}", self.title) } + } + + /// "the vac-helmet", "the Iron Vow". + pub fn the(&self) -> String { + if self.named { self.title.clone() } else { format!("the {}", self.title) } + } + + /// "is", or "are" for a pair of something. + pub fn is(&self) -> &'static str { + if self.pair && !self.named { "are" } else { "is" } + } + + /// Below half its durability: it guards 1 less against everything. + pub fn thin(&self) -> bool { + self.durability * 2 < self.max_durability + } + + /// Worn out: it guards nothing until it's mended. + pub fn ruined(&self) -> bool { + self.durability == 0 + } + + /// What it takes off a blow of `dmg` as it is now. + pub fn guard(&self, dmg: Dmg) -> i32 { + let whole = self.guards.get(&dmg).copied().unwrap_or(0); + match () { + _ if self.ruined() => 0, + _ if self.thin() => (whole - 1).max(0), + _ => whole, + } + } + + /// Whether it keeps a wound closed as it is now. + pub fn stanching(&self) -> bool { + self.stanches && !self.ruined() + } + + /// What it guards against when it's whole: "pierce 1 · blunt 1 · stops + /// bleeding". + pub fn guards_text(&self) -> String { + let mut parts: Vec<String> = self + .guards + .iter() + .filter(|&(_, &g)| g > 0) + .map(|(d, g)| format!("{} {g}", d.name())) + .collect(); + if self.stanches { + parts.push("stops bleeding".into()); + } + if parts.is_empty() { + return "guards nothing".into(); + } + parts.join(" · ") + } + + /// "durability 12/30". + pub fn durability_text(&self) -> String { + format!("durability {}/{}", self.durability, self.max_durability) + } + + /// One blow softened: it wears a little. Says so when that leaves it + /// thin or ruined. + pub fn wear_down(&mut self) -> Option<String> { + let (thin, ruined) = (self.thin(), self.ruined()); + self.durability = self.durability.saturating_sub(1); + let who = capitalise(&self.yours()); + if self.ruined() && !ruined { + Some(format!("{who} {} worn out: it guards nothing until it's mended.", self.is())) + } else if self.thin() && !thin { + Some(format!("{who} {} wearing thin: it guards 1 less.", self.is())) + } else { + None + } + } + + /// Makes it whole again. + pub fn repair(&mut self) { + self.durability = self.max_durability; + } + + /// Puts right whatever a save file got wrong: numbers that make sense, + /// and durability for a piece saved before it had any. + pub fn mend(&mut self) { + if self.max_durability == 0 { + self.max_durability = 20; + self.durability = 20; + } + self.durability = self.durability.min(self.max_durability); + self.guards.retain(|_, g| *g > 0); + for g in self.guards.values_mut() { + *g = (*g).min(9); + } + } +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) +} + +struct Kind { + /// What it might be called; one is picked for each piece found. + nouns: &'static [&'static str], + slot: Slot, + /// Gloves and boots. + pair: bool, + guards: &'static [(Dmg, i32)], + stanches: bool, + durability: u32, + /// What one weighs, in grams. + mass: u32, + /// How common it is, relative to the others. + weight: u32, + /// Where it's most likely to turn up. + found: &'static [Source], + /// Whose armourers stock it most. + people: Option<Culture>, +} + +use Dmg::{Blunt, Burn, Cut, Hymn, Pierce, Shock}; +use Slot::{Body, Feet, Hands, Head}; +use Source::{Armoury, Crew, Rites, Tools}; + +const QAARI: Option<Culture> = Some(Culture::Qaari); +const CANT: Option<Culture> = Some(Culture::Cant); +const HOUSES: Option<Culture> = Some(Culture::Houses); +const DRIFT: Option<Culture> = Some(Culture::Drift); + +#[rustfmt::skip] +const KINDS: &[Kind] = &[ + // Heads. + Kind { nouns: &["vac-helmet", "pressure helm", "bubble-helm"], slot: Head, pair: false, guards: &[(Pierce, 1), (Blunt, 1)], stanches: false, durability: 24, mass: 1800, weight: 4, found: &[Tools, Armoury, Crew], people: DRIFT }, + Kind { nouns: &["choir-hood", "cantor's cowl", "psalm-veil"], slot: Head, pair: false, guards: &[(Hymn, 2)], stanches: false, durability: 16, mass: 400, weight: 2, found: &[Rites], people: HOUSES }, + Kind { nouns: &["dust-veil", "head-wrap", "sun-hood"], slot: Head, pair: false, guards: &[(Burn, 1), (Cut, 1)], stanches: false, durability: 16, mass: 300, weight: 2, found: &[Crew], people: QAARI }, + Kind { nouns: &["lineman's hood", "rubber cowl", "dielectric cap"], slot: Head, pair: false, guards: &[(Shock, 2)], stanches: false, durability: 18, mass: 700, weight: 2, found: &[Tools], people: CANT }, + Kind { nouns: &["crested helm", "sallet", "visored helm"], slot: Head, pair: false, guards: &[(Cut, 1), (Blunt, 1), (Hymn, 1)], stanches: false, durability: 30, mass: 2200, weight: 1, found: &[Armoury], people: HOUSES }, + // Bodies. + Kind { nouns: &["flight-jacket", "deck coat", "quilted jerkin"], slot: Body, pair: false, guards: &[(Cut, 2), (Blunt, 1)], stanches: true, durability: 26, mass: 2500, weight: 4, found: &[Crew], people: None }, + Kind { nouns: &["ceramic vest", "flak vest", "plate carrier"], slot: Body, pair: false, guards: &[(Pierce, 1), (Cut, 1), (Blunt, 1)], stanches: false, durability: 30, mass: 5500, weight: 3, found: &[Armoury], people: DRIFT }, + Kind { nouns: &["hardsuit", "pressure suit", "vac-suit"], slot: Body, pair: false, guards: &[(Pierce, 2), (Blunt, 1)], stanches: false, durability: 34, mass: 7500, weight: 2, found: &[Tools, Armoury], people: DRIFT }, + Kind { nouns: &["lineman's apron", "dielectric coat", "rubber smock"], slot: Body, pair: false, guards: &[(Shock, 2), (Burn, 1)], stanches: false, durability: 26, mass: 4000, weight: 2, found: &[Tools], people: CANT }, + Kind { nouns: &["coil-plate", "grounded cuirass", "dielectric plate"], slot: Body, pair: false, guards: &[(Shock, 2), (Cut, 1), (Blunt, 1)], stanches: false, durability: 36, mass: 7000, weight: 1, found: &[Armoury, Tools], people: CANT }, + Kind { nouns: &["cuirass", "breastplate", "half-plate"], slot: Body, pair: false, guards: &[(Cut, 2), (Blunt, 2)], stanches: false, durability: 40, mass: 8000, weight: 1, found: &[Armoury], people: HOUSES }, + Kind { nouns: &["surplice", "chasuble", "vestment"], slot: Body, pair: false, guards: &[(Hymn, 2)], stanches: false, durability: 18, mass: 1200, weight: 2, found: &[Rites], people: HOUSES }, + Kind { nouns: &["dust-cloak", "layered robe", "sun-wrap"], slot: Body, pair: false, guards: &[(Burn, 2), (Cut, 1)], stanches: false, durability: 22, mass: 1800, weight: 2, found: &[Crew], people: QAARI }, + // Hands. + Kind { nouns: &["lineman's gauntlets", "rubber gauntlets", "dielectric gloves"], slot: Hands, pair: true, guards: &[(Shock, 2)], stanches: false, durability: 18, mass: 600, weight: 3, found: &[Tools], people: CANT }, + Kind { nouns: &["work gloves", "rigger's gloves", "welding gloves"], slot: Hands, pair: true, guards: &[(Cut, 1), (Burn, 1)], stanches: false, durability: 14, mass: 400, weight: 4, found: &[Tools, Crew], people: None }, + Kind { nouns: &["hand-wraps", "sand-gloves", "palm-wraps"], slot: Hands, pair: true, guards: &[(Burn, 1), (Cut, 1)], stanches: false, durability: 12, mass: 200, weight: 2, found: &[Crew], people: QAARI }, + Kind { nouns: &["vac-gloves", "suit gloves", "pressure gloves"], slot: Hands, pair: true, guards: &[(Pierce, 1)], stanches: false, durability: 16, mass: 500, weight: 2, found: &[Tools, Armoury], people: DRIFT }, + Kind { nouns: &["gauntlets", "mailed gloves", "vambraces"], slot: Hands, pair: true, guards: &[(Cut, 1), (Blunt, 1)], stanches: false, durability: 26, mass: 1200, weight: 2, found: &[Armoury], people: HOUSES }, + Kind { nouns: &["psalm-cuffs", "chime-bracers", "prayer-cords"], slot: Hands, pair: true, guards: &[(Hymn, 1), (Cut, 1)], stanches: false, durability: 16, mass: 300, weight: 1, found: &[Rites], people: HOUSES }, + // Feet. + Kind { nouns: &["mag-boots", "deck boots", "grav-boots"], slot: Feet, pair: true, guards: &[(Pierce, 1), (Blunt, 1)], stanches: false, durability: 30, mass: 2400, weight: 4, found: &[Tools, Crew], people: DRIFT }, + Kind { nouns: &["lineman's boots", "rubber boots", "dielectric boots"], slot: Feet, pair: true, guards: &[(Shock, 1)], stanches: false, durability: 22, mass: 1600, weight: 2, found: &[Tools], people: CANT }, + Kind { nouns: &["sand-boots", "dune-shoes", "wrapped sandals"], slot: Feet, pair: true, guards: &[(Burn, 1), (Cut, 1)], stanches: false, durability: 16, mass: 800, weight: 2, found: &[Crew], people: QAARI }, + Kind { nouns: &["greaves", "sabatons", "plated boots"], slot: Feet, pair: true, guards: &[(Cut, 1), (Blunt, 1)], stanches: false, durability: 34, mass: 3000, weight: 1, found: &[Armoury], people: HOUSES }, + Kind { nouns: &["pilgrim's sandals", "vigil slippers", "chapel boots"], slot: Feet, pair: true, guards: &[(Hymn, 1)], stanches: false, durability: 14, mass: 500, weight: 1, found: &[Rites], people: HOUSES }, +]; + +/// A piece found where `source` things turn up, not in the best of repair. +/// `better` shifts the odds toward finer ones: an armoury keeps its stock in +/// better shape than a berth does. +pub fn find(rng: &mut Rng, source: Source, better: bool) -> Armor { + let kind = KINDS + .choose_weighted(rng, |k| k.weight * if k.found.contains(&source) { 5 } else { 1 }) + .expect("there are kinds of armour"); + let roll = rng.random_range(0..100) + if better { 25 } else { 0 }; + let condition = match roll { + ..25 => Condition::Worn, + 25..65 => Condition::Sound, + 65..94 => Condition::Fine, + _ => Condition::Relic, + }; + let mut a = forge(rng, kind, condition); + let left = match condition { + Condition::Worn => rng.random_range(0.3..0.7), + Condition::Sound => rng.random_range(0.55..1.0), + Condition::Fine => rng.random_range(0.75..1.0), + Condition::Relic => 1.0, + }; + a.durability = ((f64::from(a.max_durability) * left).round() as u32).clamp(1, a.max_durability); + a +} + +/// A piece for an armourer's rack where `culture` lives, mended whole. Each +/// people favours its own: Qaari wraps and veils against burns and cuts, +/// Cant insulated plate against shocks, High House fine plate and warded +/// pieces against hymns, Drift patched vac-gear against piercing. A `relic` +/// is rare and dear. +pub fn stocked(rng: &mut Rng, culture: Culture, relic: bool) -> Armor { + let kind = KINDS + .choose_weighted(rng, |k| k.weight * if k.people == Some(culture) { 5 } else { 1 }) + .expect("there are kinds of armour"); + let fine = if culture == Culture::Houses { 20 } else { 0 }; + let condition = match rng.random_range(0..100) + fine { + _ if relic => Condition::Relic, + ..15 => Condition::Worn, + 15..70 => Condition::Sound, + _ => Condition::Fine, + }; + forge(rng, kind, condition) +} + +/// What a piece is worth to an armourer whole, in scrip: more for guarding +/// more, keeping wounds closed and lasting longer, and a good deal more for +/// a fine one or a relic. +pub fn value(a: &Armor) -> u32 { + let guards: i32 = a.guards.values().map(|&g| g.max(0)).sum(); + let guards = f64::from(guards) + if a.stanches { 1.5 } else { 0.0 }; + let lasting = 0.6 + f64::from(a.max_durability.min(60)) / 75.0; + let condition = match a.condition { + Condition::Worn => 0.8, + Condition::Sound => 1.0, + Condition::Fine => 1.25, + Condition::Relic => 3.0, + }; + (12.0 * (guards * lasting).powf(1.25) * condition).round().max(1.0) as u32 +} + +/// What it's worth as it is: half its whole value when it's worn out, the +/// rest coming back as it's mended. +pub fn worth(a: &Armor) -> f64 { + f64::from(value(a)) - mending(a) +} + +/// What mending it whole is worth: half its value, for one worn out. +pub fn mending(a: &Armor) -> f64 { + if a.max_durability == 0 { + return 0.0; + } + let wear = f64::from(a.max_durability.saturating_sub(a.durability)); + f64::from(value(a)) * 0.5 * wear / f64::from(a.max_durability) +} + +fn forge(rng: &mut Rng, kind: &Kind, condition: Condition) -> Armor { + let noun = pick(kind.nouns, rng); + let mut a = Armor { + title: String::new(), + description: String::new(), + slot: kind.slot, + guards: kind.guards.iter().copied().collect(), + stanches: kind.stanches, + durability: 0, + max_durability: kind.durability, + condition, + named: false, + pair: kind.pair, + mass: kind.mass, + }; + // No two quite alike. + a.max_durability = (a.max_durability as i32 + rng.random_range(-2..=2)).max(6) as u32; + a.mass = (f64::from(a.mass) * rng.random_range(0.9..1.1)).round() as u32; + + let mut words = Vec::new(); + match condition { + Condition::Worn => { + a.max_durability = (f64::from(a.max_durability) * 0.7).round() as u32; + words.push(pick(WORN, rng)); + } + Condition::Sound => {} + Condition::Fine => words.extend(affixes(rng, &mut a, noun, 1)), + Condition::Relic => { + a.max_durability = (f64::from(a.max_durability) * 1.25).round() as u32; + words.extend(affixes(rng, &mut a, noun, 2)); + } + } + a.durability = a.max_durability; + + let mut what = words.join(" "); + if !what.is_empty() { + what.push(' '); + } + what.push_str(noun); + a.description = if a.pair { format!("a pair of {what}") } else { names::a(&what) }; + if condition == Condition::Relic { + a.title = arms::relic_name(rng); + a.named = true; + } else { + a.title = what; + } + a +} + +/// Up to `n` different affixes that suit the piece, applied; returns their +/// words. None that the noun already says. +fn affixes(rng: &mut Rng, a: &mut Armor, noun: &str, n: usize) -> Vec<&'static str> { + let mut words = Vec::new(); + for _ in 0..n { + let fits: Vec<&Affix> = AFFIXES + .iter() + .filter(|f| (f.fits)(a) && !words.contains(&f.word) && !noun.contains(f.word)) + .collect(); + let Some(affix) = fits.choose(rng) else { break }; + (affix.apply)(a); + words.push(affix.word); + } + words +} + +struct Affix { + word: &'static str, + fits: fn(&Armor) -> bool, + apply: fn(&mut Armor), +} + +fn more(a: &mut Armor, dmg: Dmg) { + *a.guards.entry(dmg).or_insert(0) += 1; +} + +fn scaled(x: u32, k: f64) -> u32 { + (f64::from(x) * k).round() as u32 +} + +#[rustfmt::skip] +const AFFIXES: &[Affix] = &[ + Affix { word: "padded", fits: |_| true, apply: |a| more(a, Blunt) }, + Affix { word: "mail-lined", fits: |a| a.slot != Feet, apply: |a| more(a, Cut) }, + Affix { word: "ceramic-plated", fits: |a| matches!(a.slot, Head | Body), apply: |a| { more(a, Pierce); a.mass = scaled(a.mass, 1.2) } }, + Affix { word: "insulated", fits: |_| true, apply: |a| more(a, Shock) }, + Affix { word: "fireproofed", fits: |_| true, apply: |a| more(a, Burn) }, + Affix { word: "warded", fits: |_| true, apply: |a| more(a, Hymn) }, + Affix { word: "reinforced", fits: |_| true, apply: |a| { a.max_durability = scaled(a.max_durability, 1.5); a.mass = scaled(a.mass, 1.15) } }, + Affix { word: "styptic-lined", fits: |a| a.slot == Body && !a.stanches, apply: |a| a.stanches = true }, + Affix { word: "featherweight", fits: |a| a.mass >= 1000, apply: |a| a.mass = scaled(a.mass, 0.65) }, +]; + +/// How a worn piece looks. +const WORN: &[&str] = + &["scuffed", "patched", "battered", "taped-up", "grimy", "scorched", "stained", "threadbare"]; + +/// A few lines describing `n` pieces found in a run of wrecks, for +/// `orrery armour`. +pub fn catalogue(rng: &mut Rng, n: usize) -> String { + let mut out = String::new(); + let sources = [Armoury, Crew, Tools, Rites]; + for i in 0..n { + let a = find(rng, sources[i % sources.len()], false); + let _ = match a.named { + true => writeln!(out, "{} ({}, {})", a.name(), a.description, a.slot.name()), + false => writeln!(out, "{} ({})", a.name(), a.slot.name()), + }; + let _ = writeln!(out, " {}", a.guards_text()); + let _ = writeln!( + out, + " {} · {:.1} kg · worth {} ¤ whole", + a.durability_text(), + f64::from(a.mass) / 1000.0, + value(&a) + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::seed; + + #[test] + fn found_armour_is_sound() { + let mut rng = seed::rng(7); + let mut titles = std::collections::HashSet::new(); + for i in 0..2000 { + let source = [Tools, Armoury, Crew, Rites][i % 4]; + let a = find(&mut rng, source, i % 3 == 0); + assert!(a.max_durability >= 4, "{a:?}"); + assert!((1..=a.max_durability).contains(&a.durability), "{a:?}"); + assert!(a.guards.values().all(|&g| (1..=5).contains(&g)), "{a:?}"); + assert!(!a.guards.is_empty() && a.mass > 0); + assert!(!a.title.is_empty() && !a.description.is_empty()); + assert!(!a.description.contains(" "), "{}", a.description); + let mut saved = a.clone(); + saved.mend(); + assert_eq!(saved, a, "nothing to put right"); + titles.insert(a.title.clone()); + } + assert!(titles.len() > 150, "only {} different pieces", titles.len()); + } + + #[test] + fn worn_thin_it_guards_less_and_worn_out_nothing() { + let mut a = stocked(&mut seed::rng(1), Culture::Drift, false); + a.guards = [(Pierce, 2), (Blunt, 1)].into_iter().collect(); + a.max_durability = 10; + a.durability = 10; + assert_eq!((a.guard(Pierce), a.guard(Blunt), a.guard(Cut)), (2, 1, 0)); + let mut news = Vec::new(); + for _ in 0..10 { + news.extend(a.wear_down()); + } + assert_eq!(news.len(), 2, "once when it's thin, once when it's gone: {news:?}"); + assert!(news[0].contains("wearing thin") && news[1].contains("worn out")); + assert_eq!(a.guard(Pierce), 0); + a.durability = 4; + assert_eq!((a.guard(Pierce), a.guard(Blunt)), (1, 0), "1 less below half"); + a.repair(); + assert_eq!(a.guard(Pierce), 2); + } + + #[test] + fn mending_costs_more_the_more_worn_and_the_finer() { + let mut rng = seed::rng(2); + let mut a = stocked(&mut rng, Culture::Houses, false); + let whole = f64::from(value(&a)); + assert_eq!(mending(&a), 0.0); + a.durability = 0; + assert!((mending(&a) - whole / 2.0).abs() < 1e-9, "half its price from ruined"); + assert!((worth(&a) - whole / 2.0).abs() < 1e-9); + a.durability = a.max_durability / 2; + let half_worn = mending(&a); + assert!(half_worn > 0.0 && half_worn < whole / 2.0); + let mut relic = a.clone(); + relic.condition = Condition::Relic; + assert!(mending(&relic) > half_worn, "the finer, the dearer"); + } + + #[test] + fn racks_lean_to_their_peoples_armour() { + let mut rng = seed::rng(3); + for culture in Culture::ALL { + let ours = (0..400) + .filter(|_| { + let a = stocked(&mut rng, culture, false); + KINDS.iter().any(|k| { + k.people == Some(culture) && k.nouns.iter().any(|n| a.title.ends_with(n)) + }) + }) + .count(); + assert!(ours > 160, "{} stock only {ours} of their own in 400", culture.name()); + let a = stocked(&mut rng, culture, false); + assert_eq!(a.durability, a.max_durability, "a rack's pieces are whole"); + } + let relic = stocked(&mut rng, Culture::Cant, true); + assert!(relic.named && relic.condition == Condition::Relic); + } + + #[test] + fn pairs_read_as_pairs() { + let mut rng = seed::rng(4); + let boots = + (0..).map(|_| find(&mut rng, Tools, false)).find(|a| a.pair && !a.named).unwrap(); + assert!(boots.name().starts_with("a pair of ")); + assert_eq!(boots.is(), "are"); + assert!(boots.yours().starts_with("your ")); + } +} diff --git a/src/dungeon/arms.rs b/src/dungeon/arms.rs new file mode 100644 index 0000000..d37e1c8 --- /dev/null +++ b/src/dungeon/arms.rs @@ -0,0 +1,679 @@ +//! Weapons: the kinds of damage, the kinds of weapon, the ones you find +//! aboard wrecks (each with its own name and stats), and the geometry of a +//! shot. The rules for using them live in `crawl`. +//! +//! A found weapon is a kind (a cleaver, a scattergun...) rolled up with a +//! condition: worn things hit softer, fine ones carry an affix ("serrated", +//! "long-barrelled"), and the rare relic carries two and a name of its own. + +use std::fmt::Write as _; + +use rand::RngExt; +use rand::seq::IndexedRandom; +use serde::{Deserialize, Serialize}; + +use super::Pos; +use crate::names::{self, Culture, pick}; +use crate::seed::Rng; + +type Rgb = (u8, u8, u8); + +/// The kinds of damage. Each does something besides hurting (see `about`), +/// and every monster takes each kind differently. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Dmg { + Cut, + Blunt, + Pierce, + Burn, + Shock, + Hymn, +} + +impl Dmg { + pub const ALL: [Dmg; 6] = [Dmg::Cut, Dmg::Blunt, Dmg::Pierce, Dmg::Burn, Dmg::Shock, Dmg::Hymn]; + + pub fn name(self) -> &'static str { + match self { + Dmg::Cut => "cut", + Dmg::Blunt => "blunt", + Dmg::Pierce => "pierce", + Dmg::Burn => "burn", + Dmg::Shock => "shock", + Dmg::Hymn => "hymn", + } + } + + pub fn rgb(self) -> Rgb { + match self { + Dmg::Cut => (230, 90, 90), + Dmg::Blunt => (200, 170, 130), + Dmg::Pierce => (200, 205, 215), + Dmg::Burn => (255, 150, 60), + Dmg::Shock => (110, 200, 255), + Dmg::Hymn => (200, 150, 255), + } + } + + /// What it does besides the damage. + pub fn about(self) -> &'static str { + match self { + Dmg::Cut => "Cuts make the living bleed, a little less each turn.", + Dmg::Blunt => "Blows knock things back; into a bulkhead, they stagger.", + Dmg::Pierce => "Goes through armour; fast rounds go on into what's behind.", + Dmg::Burn => "Sets things alight for a few turns.", + Dmg::Shock => "Can stun: the target loses its next turn.", + Dmg::Hymn => "Old songs against what shouldn't be. Can steady you.", + } + } + + /// "slashes", as in "your cleaver slashes the rust-mite". + pub fn verb(self) -> &'static str { + match self { + Dmg::Cut => "slashes", + Dmg::Blunt => "smashes", + Dmg::Pierce => "stabs", + Dmg::Burn => "burns", + Dmg::Shock => "jolts", + Dmg::Hymn => "rings against", + } + } +} + +/// What makes a weapon a gun. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Gun { + /// How far a shot flies, in cells. + pub range: i32, + /// How far a shot can stray either side of where you aim, in degrees. + /// Most shots stray less than this; up close it barely matters. + pub spread: f64, + /// Projectiles per shot. Each strays on its own. + pub pellets: u32, + /// Shots before you have to reload. + pub magazine: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Weapon { + /// What it's called, without an article: "serrated cleaver", or for a + /// relic its own name, "Khaseem's Mercy". + pub title: String, + /// What it is: "a fine serrated cleaver". For a relic, the only place + /// that says what kind of thing it is. + pub description: String, + pub dmg: Dmg, + pub damage: (i32, i32), + /// For blows, the chance one lands. Guns ignore it: where a shot lands + /// is down to spread. + pub accuracy: f64, + /// The chance a hit lands twice as hard. + pub crit: f64, + /// How strongly its damage's extra takes hold: bleeding added, turns + /// alight, % chance to knock back, % to stun, things a round passes + /// through, % chance to steady you. See `effect_text`. + pub effect: u32, + pub gun: Option<Gun>, + /// Rounds left in the magazine. + pub loaded: u32, + /// Part of your own kit, not something you found: never dropped, and + /// never lost. + pub kit: bool, + /// A relic with its own name. + pub named: bool, + pub condition: Condition, + /// What it weighs, in grams. Saves from before weight have none, and + /// `mend` estimates it. + #[serde(default)] + pub mass: u32, +} + +impl Weapon { + /// "a serrated cleaver", "Khaseem's Mercy". + pub fn name(&self) -> String { + if self.named { + self.title.clone() + } else if self.kit { + format!("your {}", self.title) + } else { + names::a(&self.title) + } + } + + /// "your serrated cleaver", "Khaseem's Mercy": the subject of a sentence. + pub fn yours(&self) -> String { + if self.named { self.title.clone() } else { format!("your {}", self.title) } + } + + pub fn is_gun(&self) -> bool { + self.gun.is_some() + } + + /// "bleeds 2", "knockback 35%"; nothing if it does nothing extra. + pub fn effect_text(&self) -> Option<String> { + let n = self.effect; + if n == 0 { + return None; + } + Some(match self.dmg { + Dmg::Cut => format!("bleeds {n}"), + Dmg::Blunt => format!("knockback {n}%"), + Dmg::Pierce => format!("passes through {n}"), + Dmg::Burn => format!("ignites {n}"), + Dmg::Shock => format!("stuns {n}%"), + Dmg::Hymn => format!("steadies {n}%"), + }) + } + + /// "cut 3–6", the damage. + pub fn damage_text(&self) -> String { + let (lo, hi) = self.damage; + let pellets = self.gun.map_or(1, |g| g.pellets); + let times = if pellets > 1 { format!("×{pellets}") } else { String::new() }; + let damage = if lo == hi { lo.to_string() } else { format!("{lo}–{hi}") }; + format!("{} {damage}{times}", self.dmg.name()) + } + + /// Every stat, for the pack screen: "cut 3–6 · hits 80% · crit 5%". + pub fn stats(&self) -> Vec<String> { + let mut first = vec![self.damage_text()]; + match self.gun { + Some(g) => { + first.push(format!("range {}", g.range)); + first.push(format!("spread ±{}°", trim(g.spread))); + } + None => first.push(format!("hits {}%", percent(self.accuracy))), + } + let mut second = Vec::new(); + if let Some(g) = self.gun { + second.push(format!("{}/{} loaded", self.loaded, g.magazine)); + } + second.push(format!("crit {}%", percent(self.crit))); + second.extend(self.effect_text()); + vec![first.join(" · "), second.join(" · ")] + } + + pub fn reload(&mut self) { + if let Some(g) = self.gun { + self.loaded = g.magazine; + } + } + + /// Puts right whatever a save file got wrong: numbers that make sense, + /// and a weight for a weapon saved before things had one. + pub fn mend(&mut self) { + self.damage.0 = self.damage.0.max(1); + self.damage.1 = self.damage.1.max(self.damage.0); + self.accuracy = self.accuracy.clamp(0.0, 1.0); + self.crit = self.crit.clamp(0.0, 1.0); + if let Some(g) = &mut self.gun { + g.range = g.range.max(1); + g.pellets = g.pellets.clamp(1, 12); + g.magazine = g.magazine.max(1); + g.spread = if g.spread.is_finite() { g.spread.clamp(0.0, 90.0) } else { 0.0 }; + self.loaded = self.loaded.min(g.magazine); + } + if self.mass == 0 { + self.mass = self.estimated_mass(); + } + } + + /// What a weapon saved without a weight probably weighs: its kind's, + /// going by what it's called. + fn estimated_mass(&self) -> u32 { + let own = [cutting_torch(), rivet_gun()]; + if self.kit { + return own.iter().find(|w| w.title == self.title).map_or(2000, |w| w.mass); + } + KINDS + .iter() + .find(|k| { + k.nouns.iter().any(|n| self.description.contains(n) || self.title.contains(n)) + }) + .map_or(if self.is_gun() { 3000 } else { 1500 }, |k| k.mass) + } +} + +fn percent(p: f64) -> i32 { + (p * 100.0).round() as i32 +} + +/// 1.5 as "1.5", 6.0 as "6". +fn trim(x: f64) -> String { + let s = format!("{x:.1}"); + s.strip_suffix(".0").map_or(s.clone(), str::to_string) +} + +/// Where a kind of weapon turns up most. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Source { + /// Engine rooms, holds, machine shops. + Tools, + /// Armouries and bridges. + Armoury, + /// Berths and galleys: what the crew kept to hand. + Crew, + /// Chapels and reliquaries. + Rites, +} + +struct Kind { + /// What it might be called; one is picked for each weapon found. + nouns: &'static [&'static str], + dmg: Dmg, + damage: (i32, i32), + accuracy: f64, + effect: u32, + gun: Option<Gun>, + /// How common it is, relative to the others. + weight: u32, + /// Where it's most likely to turn up. + found: &'static [Source], + /// What one weighs, in grams. + mass: u32, +} + +const fn gun(range: i32, spread: f64, pellets: u32, magazine: u32) -> Option<Gun> { + Some(Gun { range, spread, pellets, magazine }) +} + +use Source::{Armoury, Crew, Rites, Tools}; + +#[rustfmt::skip] +const KINDS: &[Kind] = &[ + // Blows: walk into something to use them. + Kind { nouns: &["cleaver", "hacking-blade", "machete"], dmg: Dmg::Cut, damage: (3, 6), accuracy: 0.80, effect: 1, gun: None, weight: 4, found: &[Crew, Tools], mass: 1200 }, + Kind { nouns: &["shiv", "boarding knife", "galley knife"], dmg: Dmg::Cut, damage: (1, 4), accuracy: 0.95, effect: 2, gun: None, weight: 4, found: &[Crew], mass: 300 }, + Kind { nouns: &["bone-saw", "chain-blade", "rip-saw"], dmg: Dmg::Cut, damage: (2, 7), accuracy: 0.70, effect: 3, gun: None, weight: 2, found: &[Tools], mass: 1800 }, + Kind { nouns: &["crowbar", "pry-bar", "spanner"], dmg: Dmg::Blunt, damage: (2, 6), accuracy: 0.80, effect: 35, gun: None, weight: 5, found: &[Tools], mass: 1600 }, + Kind { nouns: &["bulkhead maul", "sledge", "breaching hammer"], dmg: Dmg::Blunt, damage: (4, 9), accuracy: 0.65, effect: 70, gun: None, weight: 2, found: &[Tools, Armoury], mass: 5000 }, + Kind { nouns: &["boarding spike", "stiletto", "awl"], dmg: Dmg::Pierce, damage: (2, 5), accuracy: 0.85, effect: 0, gun: None, weight: 3, found: &[Armoury, Crew], mass: 500 }, + Kind { nouns: &["thermal lance", "plasma cutter", "heat-knife"], dmg: Dmg::Burn, damage: (3, 7), accuracy: 0.75, effect: 2, gun: None, weight: 2, found: &[Tools, Armoury], mass: 2500 }, + Kind { nouns: &["arc baton", "shock-prod", "stun-maul"], dmg: Dmg::Shock, damage: (2, 4), accuracy: 0.85, effect: 35, gun: None, weight: 3, found: &[Armoury], mass: 1100 }, + Kind { nouns: &["censer-flail", "chime-staff", "bell-hammer"], dmg: Dmg::Hymn, damage: (2, 5), accuracy: 0.80, effect: 30, gun: None, weight: 2, found: &[Rites], mass: 2200 }, + Kind { nouns: &["tuning-blade", "psalter's sword", "cantor's knife"], dmg: Dmg::Hymn, damage: (3, 6), accuracy: 0.75, effect: 20, gun: None, weight: 1, found: &[Rites], mass: 1400 }, + // Guns: `f` to aim and fire. + Kind { nouns: &["slug pistol", "service pistol", "hand-cannon"], dmg: Dmg::Pierce, damage: (2, 5), accuracy: 1.0, effect: 0, gun: gun(7, 6.0, 1, 6), weight: 4, found: &[Armoury, Crew], mass: 1100 }, + Kind { nouns: &["rail rifle", "long-gun", "coilgun"], dmg: Dmg::Pierce, damage: (4, 8), accuracy: 1.0, effect: 1, gun: gun(12, 1.5, 1, 2), weight: 1, found: &[Armoury], mass: 4500 }, + Kind { nouns: &["scattergun", "blunderbuss", "grapeshot pistol"], dmg: Dmg::Blunt, damage: (1, 3), accuracy: 1.0, effect: 15, gun: gun(5, 16.0, 5, 2), weight: 3, found: &[Armoury, Crew], mass: 3200 }, + Kind { nouns: &["flamer", "incinerator", "torch-gun"], dmg: Dmg::Burn, damage: (1, 3), accuracy: 1.0, effect: 3, gun: gun(4, 12.0, 3, 3), weight: 2, found: &[Tools, Armoury], mass: 6000 }, + Kind { nouns: &["arc caster", "spark-thrower", "coil projector"], dmg: Dmg::Shock, damage: (2, 5), accuracy: 1.0, effect: 40, gun: gun(5, 3.0, 1, 3), weight: 2, found: &[Armoury, Tools], mass: 3500 }, + Kind { nouns: &["needler", "flechette gun", "dart pistol"], dmg: Dmg::Cut, damage: (1, 2), accuracy: 1.0, effect: 1, gun: gun(7, 8.0, 3, 4), weight: 3, found: &[Crew, Armoury], mass: 1300 }, + Kind { nouns: &["harpoon gun", "bolt-thrower", "spear-gun"], dmg: Dmg::Cut, damage: (3, 7), accuracy: 1.0, effect: 2, gun: gun(6, 4.0, 1, 1), weight: 2, found: &[Tools], mass: 3800 }, + Kind { nouns: &["psalm-horn", "choir-lantern", "hymnal projector"], dmg: Dmg::Hymn, damage: (2, 6), accuracy: 1.0, effect: 25, gun: gun(6, 9.0, 1, 3), weight: 2, found: &[Rites], mass: 2600 }, +]; + +/// Base chance of a critical hit. +const CRIT: f64 = 0.05; + +fn kit(title: &'static str, dmg: Dmg, damage: (i32, i32), gun: Option<Gun>, mass: u32) -> Weapon { + Weapon { + title: title.to_string(), + description: format!("your own {title}"), + dmg, + damage, + accuracy: 0.8, + crit: CRIT, + effect: 0, + loaded: gun.map_or(0, |g| g.magazine), + gun, + kit: true, + named: false, + condition: Condition::Sound, + mass, + } +} + +/// The torch you cut your way aboard with. It's also what you hit things with +/// until you find better. +pub fn cutting_torch() -> Weapon { + kit("cutting torch", Dmg::Burn, (2, 5), None, 2200) +} + +/// For pinning hull plates; it does for rust-mites, too. +pub fn rivet_gun() -> Weapon { + kit("rivet gun", Dmg::Pierce, (1, 3), gun(6, 5.0, 1, 3), 1800) +} + +/// What a weapon's condition did to it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Condition { + Worn, + Sound, + Fine, + Relic, +} + +/// A weapon found where `source` things turn up. `better` shifts the odds +/// toward finer ones: an armoury keeps its stock in better shape than a +/// galley does. +pub fn find(rng: &mut Rng, source: Source, better: bool) -> Weapon { + let kind = KINDS + .choose_weighted(rng, |k| k.weight * if k.found.contains(&source) { 5 } else { 1 }) + .expect("there are weapon kinds"); + let roll = rng.random_range(0..100) + if better { 25 } else { 0 }; + let condition = match roll { + ..25 => Condition::Worn, + 25..65 => Condition::Sound, + 65..94 => Condition::Fine, + _ => Condition::Relic, + }; + forge(rng, kind, condition) +} + +/// A weapon for an armourer's rack where `culture` lives. Each people +/// favours its own sort: Qaari blades and spear-guns, Cant shock and burn +/// tools, High House duelling pieces in fine condition, Drift guns. A rack +/// keeps nothing too worn to sell; a `relic` is rare and dear. +pub fn stocked(rng: &mut Rng, culture: Culture, relic: bool) -> Weapon { + let favoured = |k: &Kind| match culture { + Culture::Qaari => k.dmg == Dmg::Cut, + Culture::Cant => matches!(k.dmg, Dmg::Shock | Dmg::Burn), + Culture::Houses => matches!(k.dmg, Dmg::Pierce | Dmg::Hymn), + Culture::Drift => k.gun.is_some(), + }; + let kind = KINDS + .choose_weighted(rng, |k| k.weight * if favoured(k) { 4 } else { 1 }) + .expect("there are weapon kinds"); + let fine = if culture == Culture::Houses { 20 } else { 0 }; + let condition = match rng.random_range(0..100) + fine { + _ if relic => Condition::Relic, + ..15 => Condition::Worn, + 15..70 => Condition::Sound, + _ => Condition::Fine, + }; + forge(rng, kind, condition) +} + +/// What a weapon's worth to an armourer, in scrip: more for more damage, a +/// surer hit, a longer reach and a stronger extra, and a good deal more for +/// a fine one or a relic. +pub fn value(w: &Weapon) -> u32 { + let (lo, hi) = w.damage; + let pellets = w.gun.map_or(1, |g| g.pellets); + let damage = f64::from(lo + hi) / 2.0 * f64::from(pellets); + let aim = match w.gun { + Some(g) => (0.6 + f64::from(g.range.min(12)) / 30.0) * (1.0 - g.spread.min(45.0) / 60.0), + None => w.accuracy, + }; + let n = f64::from(w.effect); + let extra = match w.dmg { + Dmg::Cut | Dmg::Burn => n * 0.8, + Dmg::Pierce => n, + Dmg::Blunt => n / 25.0, + Dmg::Shock | Dmg::Hymn => n / 20.0, + }; + let power = (damage * aim + extra) * (1.0 + 1.5 * w.crit); + let condition = match w.condition { + Condition::Worn => 0.8, + Condition::Sound => 1.0, + Condition::Fine => 1.25, + Condition::Relic => 3.0, + }; + (5.0 * power.powf(1.5) * condition).round().max(1.0) as u32 +} + +fn forge(rng: &mut Rng, kind: &Kind, condition: Condition) -> Weapon { + let noun = pick(kind.nouns, rng); + let mut w = Weapon { + title: String::new(), + description: String::new(), + dmg: kind.dmg, + damage: kind.damage, + accuracy: kind.accuracy, + crit: CRIT, + effect: kind.effect, + gun: kind.gun, + loaded: 0, + kit: false, + named: false, + condition, + mass: kind.mass, + }; + // No two quite alike. + w.damage.1 = (w.damage.1 + rng.random_range(-1..=1)).max(w.damage.0); + if !w.is_gun() { + w.accuracy += rng.random_range(-0.05..0.05); + } + if let Some(g) = &mut w.gun { + g.spread *= rng.random_range(0.85..1.15); + g.spread = (g.spread * 2.0).round() / 2.0; + } + + let mut words = Vec::new(); + match condition { + Condition::Worn => { + scale(&mut w, 0.75); + w.accuracy -= 0.05; + words.push(pick(WORN, rng)); + } + Condition::Sound => {} + Condition::Fine => words.extend(affixes(rng, &mut w, 1)), + Condition::Relic => { + scale(&mut w, 1.25); + w.accuracy += 0.05; + words.extend(affixes(rng, &mut w, 2)); + } + } + w.accuracy = w.accuracy.clamp(0.4, 0.98); + w.crit = w.crit.min(0.5); + w.reload(); + + let mut what = words.join(" "); + if !what.is_empty() { + what.push(' '); + } + what.push_str(noun); + w.description = names::a(&what); + if condition == Condition::Relic { + w.title = relic_name(rng); + w.named = true; + } else { + w.title = what; + } + w +} + +/// Scales the damage, keeping it at least 1–1. +fn scale(w: &mut Weapon, k: f64) { + let s = |x: i32| ((f64::from(x) * k).round() as i32).max(1); + w.damage = (s(w.damage.0), s(w.damage.1).max(s(w.damage.0))); +} + +/// Up to `n` different affixes that suit the weapon, applied; returns their +/// words. +fn affixes(rng: &mut Rng, w: &mut Weapon, n: usize) -> Vec<&'static str> { + let mut words = Vec::new(); + for _ in 0..n { + let fits: Vec<&Affix> = + AFFIXES.iter().filter(|a| (a.fits)(w) && !words.contains(&a.word)).collect(); + let Some(affix) = fits.choose(rng) else { break }; + (affix.apply)(w); + words.push(affix.word); + } + words +} + +struct Affix { + word: &'static str, + fits: fn(&Weapon) -> bool, + apply: fn(&mut Weapon), +} + +fn melee(w: &Weapon) -> bool { + !w.is_gun() +} + +fn gun_mut(w: &mut Weapon) -> &mut Gun { + w.gun.as_mut().expect("affix only fits guns") +} + +#[rustfmt::skip] +const AFFIXES: &[Affix] = &[ + Affix { word: "serrated", fits: |w| w.dmg == Dmg::Cut, apply: |w| w.effect += 1 }, + Affix { word: "keen", fits: |w| melee(w) && matches!(w.dmg, Dmg::Cut | Dmg::Pierce), apply: |w| w.crit += 0.15 }, + Affix { word: "heavy", fits: melee, apply: |w| { w.damage.0 += 1; w.damage.1 += 2; w.accuracy -= 0.1 } }, + Affix { word: "balanced", fits: melee, apply: |w| w.accuracy += 0.1 }, + Affix { word: "weighted", fits: |w| w.dmg == Dmg::Blunt, apply: |w| w.effect += 20 }, + Affix { word: "searing", fits: |w| w.dmg == Dmg::Burn, apply: |w| { w.effect += 1; w.damage.1 += 1 } }, + Affix { word: "overcharged", fits: |w| w.dmg == Dmg::Shock, apply: |w| { w.effect += 20; w.damage.1 += 1 } }, + Affix { word: "hallowed", fits: |w| w.dmg == Dmg::Hymn, apply: |w| { w.effect += 20; w.damage.0 += 1; w.damage.1 += 1 } }, + Affix { word: "long-barrelled", fits: |w| w.gun.is_some_and(|g| g.pellets == 1), apply: |w| { let g = gun_mut(w); g.range += 3; g.spread *= 0.6 } }, + Affix { word: "rifled", fits: |w| w.gun.is_some_and(|g| g.spread > 2.0), apply: |w| gun_mut(w).spread *= 0.7 }, + Affix { word: "drum-fed", fits: |w| w.gun.is_some(), apply: |w| { let g = gun_mut(w); g.magazine += g.magazine.div_ceil(2) + 1 } }, + Affix { word: "sawn-off", fits: |w| w.gun.is_some_and(|g| g.pellets > 1), apply: |w| { w.damage.0 += 1; w.damage.1 += 1; let g = gun_mut(w); g.spread *= 1.3; g.range -= 1 } }, + Affix { word: "tungsten-cored", fits: |w| w.dmg == Dmg::Pierce && w.gun.is_some(), apply: |w| w.effect += 1 }, + Affix { word: "high-calibre", fits: |w| w.gun.is_some_and(|g| g.pellets == 1), apply: |w| { w.damage.0 += 1; w.damage.1 += 2; let g = gun_mut(w); g.magazine = g.magazine.saturating_sub(1).max(1) } }, +]; + +/// How a worn weapon looks. +const WORN: &[&str] = &[ + "pitted", "battered", "rust-flecked", "taped-up", "bent", "scorched", "chipped", "patched", + "grimy", "cracked", +]; + +pub(crate) fn relic_name(rng: &mut Rng) -> String { + let culture = Culture::ALL[rng.random_range(..Culture::ALL.len())]; + match rng.random_range(0..5) { + 0 | 1 => format!("{}'s {}", culture.word(rng), pick(VIRTUES, rng)), + 2 | 3 => format!("the {}", pick(TITLES, rng)), + _ => format!("the {} of {}", pick(VIRTUES, rng), culture.place(rng)), + } +} + +const VIRTUES: &[&str] = &[ + "Mercy", "Answer", "Penance", "Tithe", "Benediction", "Reply", "Grief", "Promise", "Wake", + "Lullaby", "Verdict", "Vigil", "Absolution", "Welcome", "Errand", "Patience", "Due", +]; + +const TITLES: &[&str] = &[ + "Quiet Argument", "Last Rites", "Patient Reply", "Red Ledger", "Cold Comfort", "Small Mercy", + "Iron Vow", "Long Silence", "Bright Answer", "Hungry Saint", "Second Opinion", "Final Offer", + "Kind Word", "Open Door", "Ninth Psalm", "Short Sermon", "Closing Hymn", "Widow's Dowry", + "Unpaid Debt", "Late Guest", +]; + +/// How far a shot strays, in degrees: most of the time near the middle, +/// never past `spread` either side. +pub fn stray(rng: &mut Rng, spread: f64) -> f64 { + spread * (rng.random_range(0.0..1.0) - rng.random_range(0.0..1.0)) +} + +/// The stray at fraction `u` of the way through all strays, smallest first: +/// `stray` is equally likely to land in any of `n` evenly spaced slices of +/// these. For working out the odds of a shot without rolling dice. +pub fn stray_quantile(u: f64, spread: f64) -> f64 { + if u < 0.5 { + spread * (-1.0 + (2.0 * u).sqrt()) + } else { + spread * (1.0 - (2.0 * (1.0 - u)).sqrt()) + } +} + +/// The cells a shot from `from` crosses, aimed at the middle of `aim` but +/// straying `deviation` degrees, out to `range` cells. Doesn't include +/// `from`, and knows nothing of walls; the caller stops it where it hits. +pub fn flight(from: Pos, aim: Pos, deviation: f64, range: i32) -> Vec<Pos> { + let (dx, dy) = (f64::from(aim.x - from.x), f64::from(aim.y - from.y)); + if dx == 0.0 && dy == 0.0 { + return Vec::new(); + } + let angle = dy.atan2(dx) + deviation.to_radians(); + let (cos, sin) = (angle.cos(), angle.sin()); + let (x0, y0) = (f64::from(from.x) + 0.5, f64::from(from.y) + 0.5); + // Into every cell whose edge is within `range` and a half of the middle + // of `from`, a twentieth of a cell at a time. + let steps = (f64::from(range) + 0.5) * 20.0; + let mut cells: Vec<Pos> = Vec::new(); + for i in 1..steps as i32 { + let t = f64::from(i) / 20.0; + let p = Pos::new((x0 + cos * t).floor() as i32, (y0 + sin * t).floor() as i32); + if p != from && cells.last() != Some(&p) { + cells.push(p); + } + } + cells +} + +/// A few lines describing `n` weapons found in a run of wrecks, for +/// `orrery weapons`. +pub fn catalogue(rng: &mut Rng, n: usize) -> String { + let mut out = String::new(); + let sources = [Tools, Armoury, Crew, Rites]; + for i in 0..n { + let w = find(rng, sources[i % sources.len()], false); + let _ = match w.named { + true => writeln!(out, "{} ({})", w.name(), w.description), + false => writeln!(out, "{}", w.name()), + }; + for line in w.stats() { + let _ = writeln!(out, " {line}"); + } + let _ = writeln!(out, " {:.1} kg", f64::from(w.mass) / 1000.0); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::seed; + + #[test] + fn found_weapons_are_sound() { + let mut rng = seed::rng(4); + let mut titles = std::collections::HashSet::new(); + for i in 0..2000 { + let source = [Tools, Armoury, Crew, Rites][i % 4]; + let w = find(&mut rng, source, i % 3 == 0); + assert!(w.damage.0 >= 1 && w.damage.0 <= w.damage.1, "{w:?}"); + assert!((0.4..=0.98).contains(&w.accuracy), "{w:?}"); + assert!(!w.title.is_empty() && !w.description.is_empty()); + if let Some(g) = w.gun { + assert!(g.range >= 3 && g.magazine >= 1 && g.pellets >= 1 && g.spread > 0.0); + assert_eq!(w.loaded, g.magazine, "found loaded"); + } + assert!(w.mass > 0); + // A save from before weight gets the same weight back. + let mut saved = w.clone(); + saved.mass = 0; + saved.mend(); + assert_eq!(saved.mass, w.mass, "{}", w.description); + titles.insert(w.title.clone()); + } + assert!(titles.len() > 300, "only {} different weapons", titles.len()); + } + + #[test] + fn every_kind_of_damage_turns_up() { + let mut rng = seed::rng(5); + let found: Vec<Dmg> = + (0..400).map(|_| find(&mut rng, Source::Armoury, false).dmg).collect(); + for dmg in Dmg::ALL { + assert!(found.contains(&dmg), "never found a {} weapon", dmg.name()); + } + } + + #[test] + fn shots_fly_straight_when_they_dont_stray() { + let from = Pos::new(2, 2); + let path = flight(from, Pos::new(6, 2), 0.0, 4); + assert_eq!(path, (3..=6).map(|x| Pos::new(x, 2)).collect::<Vec<_>>()); + let diagonal = flight(from, Pos::new(5, 5), 0.0, 4); + assert_eq!(diagonal[..3], [Pos::new(3, 3), Pos::new(4, 4), Pos::new(5, 5)]); + assert!(flight(from, from, 0.0, 5).is_empty()); + } + + #[test] + fn strays_stay_within_the_spread() { + let mut rng = seed::rng(6); + for _ in 0..1000 { + assert!(stray(&mut rng, 8.0).abs() <= 8.0); + } + assert_eq!(stray_quantile(0.5, 8.0), 0.0); + assert!(stray_quantile(0.0, 8.0) <= -7.99 && stray_quantile(1.0, 8.0) >= 7.99); + assert!(stray_quantile(0.25, 8.0) < stray_quantile(0.3, 8.0)); + } +} diff --git a/src/dungeon/crawl.rs b/src/dungeon/crawl.rs index c5196b9..72d8a35 100644 --- a/src/dungeon/crawl.rs +++ b/src/dungeon/crawl.rs @@ -1,31 +1,110 @@ //! The rules of a crawl: moving, fighting, picking things up, and what the //! derelict's inhabitants do each turn. Nothing here draws; the scene turns //! keys into `Action`s and draws whatever state results. +//! +//! You fight with what you've readied: walk into something to hit it with +//! your blade or club, or fire your gun at anything in range. Every monster +//! takes each kind of damage its own way (weak to some, shrugging off +//! others), and a monster's armour comes off every hit but a piercing one. +//! Yours guards against blows by kind, and wears as it does (see +//! `armour`). Cuts bleed, fires burn, blows knock back and shocks stun; see +//! `arms::Dmg`. + +use std::collections::BTreeSet; use rand::RngExt; +use serde::{Deserialize, Serialize}; +use super::arms::{self, Dmg, Gun, Weapon}; use super::fov::{self, Fov}; use super::ship; use super::{Map, Pos, Tile}; -use crate::names::Artifact; +use crate::items::{self, Gear, Item, Slot, Spot, Stack}; use crate::seed::{self, Rng}; /// How far your lamp reaches, in cells. pub const SIGHT_RADIUS: i32 = 8; const PLAYER_HP: i32 = 20; -/// Your cutting torch. -const PLAYER_DAMAGE: (i32, i32) = (2, 5); -const PLAYER_HIT: f64 = 0.8; -const MONSTER_HIT: f64 = 0.7; -const MEDPATCH_HEAL: i32 = 8; +pub const MEDPATCH_HEAL: i32 = 8; const LOG_LENGTH: usize = 50; +/// Bleeding doesn't get worse than this. +const MAX_BLEEDING: u32 = 6; +/// How many evenly spread strays `odds` tries. +const ODDS_SAMPLES: u32 = 64; +/// Gunfire wakes anything within this many steps of you. +const GUNFIRE_CARRIES: u32 = 7; + +/// How something takes a kind of damage. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Affinity { + /// Half as much again. + Weak, + Normal, + /// Half. + Resists, + /// None at all. + Immune, +} + +impl Affinity { + pub fn scale(self, damage: i32) -> i32 { + match self { + Affinity::Weak => (damage * 3 + 1) / 2, + Affinity::Normal => damage, + Affinity::Resists => (damage + 1) / 2, + Affinity::Immune => 0, + } + } + + /// As in "the rust-mite is weak to blunt". + pub fn phrase(self) -> &'static str { + match self { + Affinity::Weak => "is weak to", + Affinity::Normal => "takes no more or less from", + Affinity::Resists => "resists", + Affinity::Immune => "is immune to", + } + } +} + +pub struct Attack { + pub dmg: Dmg, + pub damage: (i32, i32), + /// The chance a blow lands. Shots ignore it; they stray instead. + pub hit: f64, + /// Bleeding it leaves you with. + pub bleed: u32, + /// Turns it sets you alight for. + pub ignite: u32, + /// The chance it knocks you back a cell. + pub knock: f64, + /// Shoots from where it is instead of closing in. + pub gun: Option<Gun>, +} + +const fn bite(dmg: Dmg, damage: (i32, i32), hit: f64, bleed: u32) -> Attack { + Attack { dmg, damage, hit, bleed, ignite: 0, knock: 0.0, gun: None } +} pub struct MonsterKind { pub name: &'static str, pub glyph: char, pub rgb: (u8, u8, u8), pub hp: i32, - pub damage: (i32, i32), + /// Taken off every hit except a piercing one, though a hit always does + /// at least 1. + pub armour: i32, + /// Whether cuts make it bleed. + pub blood: bool, + pub weak: &'static [Dmg], + pub resists: &'static [Dmg], + pub immune: &'static [Dmg], + pub attack: Attack, + /// Steps it takes a turn: none for things bolted to the deck. + pub speed: u32, + /// Lumbers: steps only every other turn, though it hits whenever you're + /// beside it. + pub slow: bool, /// As in "the rust-mite bites you". pub verb: &'static str, /// How common it is, relative to the others. @@ -34,12 +113,73 @@ pub struct MonsterKind { pub unique: bool, } +impl MonsterKind { + pub fn affinity(&self, dmg: Dmg) -> Affinity { + if self.immune.contains(&dmg) { + Affinity::Immune + } else if self.resists.contains(&dmg) { + Affinity::Resists + } else if self.weak.contains(&dmg) { + Affinity::Weak + } else { + Affinity::Normal + } + } +} + #[rustfmt::skip] pub const MONSTERS: &[MonsterKind] = &[ - MonsterKind { name: "rust-mite", glyph: 'm', rgb: (196, 112, 60), hp: 3, damage: (1, 2), verb: "bites", weight: 5, unique: false }, - MonsterKind { name: "maintenance drone", glyph: 'd', rgb: (150, 175, 200), hp: 6, damage: (1, 3), verb: "zaps", weight: 3, unique: false }, - MonsterKind { name: "hollow crewman", glyph: 'h', rgb: (160, 200, 150), hp: 9, damage: (2, 4), verb: "claws at", weight: 2, unique: false }, - MonsterKind { name: "choir-wraith", glyph: 'W', rgb: (200, 150, 255), hp: 14, damage: (3, 6), verb: "sings into", weight: 1, unique: true }, + MonsterKind { + name: "rust-mite", glyph: 'm', rgb: (196, 112, 60), hp: 3, armour: 1, blood: false, + weak: &[Dmg::Blunt, Dmg::Burn], resists: &[Dmg::Cut], immune: &[], + attack: bite(Dmg::Pierce, (1, 2), 0.7, 0), speed: 1, slow: false, verb: "bites", weight: 5, unique: false, + }, + MonsterKind { + name: "maintenance drone", glyph: 'd', rgb: (150, 175, 200), hp: 6, armour: 1, blood: false, + weak: &[Dmg::Shock, Dmg::Blunt], resists: &[Dmg::Cut], immune: &[], + attack: bite(Dmg::Shock, (1, 3), 0.7, 0), speed: 1, slow: false, verb: "zaps", weight: 3, unique: false, + }, + MonsterKind { + name: "hollow crewman", glyph: 'h', rgb: (160, 200, 150), hp: 9, armour: 0, blood: true, + weak: &[Dmg::Cut], resists: &[Dmg::Pierce], immune: &[], + attack: bite(Dmg::Cut, (2, 4), 0.7, 1), speed: 1, slow: false, verb: "claws at", weight: 2, unique: false, + }, + MonsterKind { + name: "choir-wraith", glyph: 'W', rgb: (200, 150, 255), hp: 14, armour: 0, blood: false, + weak: &[Dmg::Hymn, Dmg::Shock], resists: &[Dmg::Cut, Dmg::Blunt], immune: &[Dmg::Pierce], + attack: bite(Dmg::Hymn, (3, 6), 0.7, 0), speed: 1, slow: false, verb: "sings into", weight: 1, unique: true, + }, + MonsterKind { + name: "bilge-hound", glyph: 'H', rgb: (214, 170, 96), hp: 6, armour: 0, blood: true, + weak: &[Dmg::Burn], resists: &[], immune: &[], + attack: bite(Dmg::Cut, (1, 3), 0.75, 1), speed: 2, slow: false, verb: "savages", weight: 2, unique: false, + }, + MonsterKind { + name: "bulkhead sentry", glyph: 'S', rgb: (226, 96, 72), hp: 8, armour: 2, blood: false, + weak: &[Dmg::Shock], resists: &[Dmg::Cut], immune: &[], + attack: Attack { + dmg: Dmg::Pierce, damage: (1, 2), hit: 1.0, bleed: 0, ignite: 0, knock: 0.0, + gun: Some(Gun { range: 7, spread: 6.0, pellets: 1, magazine: 3 }), + }, + speed: 0, slow: false, verb: "shoots", weight: 1, unique: false, + }, + MonsterKind { + name: "welder drone", glyph: 'w', rgb: (255, 150, 60), hp: 7, armour: 1, blood: false, + weak: &[Dmg::Shock], resists: &[Dmg::Cut], immune: &[Dmg::Burn], + attack: Attack { + dmg: Dmg::Burn, damage: (1, 2), hit: 1.0, bleed: 0, ignite: 2, knock: 0.0, + gun: Some(Gun { range: 4, spread: 10.0, pellets: 1, magazine: 3 }), + }, + speed: 1, slow: false, verb: "torches", weight: 2, unique: false, + }, + MonsterKind { + name: "cargo loader", glyph: 'L', rgb: (222, 184, 70), hp: 12, armour: 2, blood: false, + weak: &[Dmg::Shock], resists: &[Dmg::Cut], immune: &[], + attack: Attack { + dmg: Dmg::Blunt, damage: (2, 5), hit: 0.65, bleed: 0, ignite: 0, knock: 0.5, gun: None, + }, + speed: 1, slow: true, verb: "slams", weight: 2, unique: false, + }, ]; pub struct Monster { @@ -48,17 +188,44 @@ pub struct Monster { pub hp: i32, /// Dormant until it comes into view; then it hunts you. pub awake: bool, + /// What it'll bleed next turn, one less each turn after. + pub bleeding: u32, + /// Turns it has left alight. + pub burning: u32, + /// Turns it'll lose. + pub stunned: u32, + /// Rounds left before it has to reload, if it has a gun. + pub loaded: u32, } -pub enum Item { - Artifact(Artifact), - Salvage { name: &'static str, value: u32 }, - Medpatch, +impl Monster { + pub fn new(kind: &'static MonsterKind, pos: Pos) -> Monster { + let loaded = kind.attack.gun.map_or(0, |g| g.magazine); + Monster { + kind, + pos, + hp: kind.hp, + awake: false, + bleeding: 0, + burning: 0, + stunned: 0, + loaded, + } + } } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Loot { pub pos: Pos, - pub item: Item, + pub stack: Stack, + /// Put down on purpose: walking over it doesn't pick it up again. + pub left: bool, +} + +impl Loot { + pub fn new(pos: Pos, item: Item) -> Loot { + Loot { pos, stack: Stack::one(item), left: false } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -75,9 +242,52 @@ pub enum Action { Move(i32, i32), Wait, UseMedpatch, + /// Fire your gun at a cell. + Fire(Pos), + Reload, + /// Ready the weapon at this index in your pack: a gun to fire, or + /// anything else to hit things with. + Ready(usize), + /// Put on the armour at this index in your pack. + Wear(usize), + TakeOff(Slot), + /// Eat one of the food at this index in your pack. + Eat(usize), + /// Put this many of what's at a spot on you down where you stand. + /// Takes no time. + Drop(Spot, u32), + /// Pick up this many of the `n`th thing where you stand (see `here`). + Take(usize, u32), + /// Pick up everything here there's room for, even what you put down. + PickUp, +} + +/// One round's flight, for drawing. +pub struct Trace { + /// Every cell it crossed, in order, ending where it stopped. + pub cells: Vec<Pos>, + pub dmg: Dmg, + /// Whether it ended in something it hit, rather than a wall or the air. + pub hit: bool, + /// How far behind the turn's first shot it sets off, in cells of + /// flight: shots fired in answer wait for the ones before to land. + pub delay: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Target { + You, + Monster(usize), +} + +struct Shot { + cells: Vec<Pos>, + struck: Vec<Target>, } pub struct Crawl { + /// What the ship and its dice are made from. + pub seed: u64, /// "the Vexant". pub name: String, /// "Cant hauler". @@ -88,27 +298,131 @@ pub struct Crawl { pub max_hp: i32, pub monsters: Vec<Monster>, pub loot: Vec<Loot>, - /// What you're carrying. It's only yours once you're back through the airlock. - pub artifacts: Vec<Artifact>, - pub scrip: u32, - pub medpatches: u32, + /// What you're carrying. What you found is only yours once you're back + /// through the airlock. + pub gear: Gear, + /// What you'll bleed next turn, one less each turn after. + pub bleeding: u32, + /// Whatever opened the wound, in case it's the death of you. + bled_by: &'static str, + /// Turns you'll go on burning, 1–2 a turn. + pub burning: u32, + /// Whatever set you alight. + burned_by: &'static str, + /// Knocked into a bulkhead: you lose your next turn. + reeling: bool, + /// How each kind of monster takes each kind of damage, as far as you've + /// found out by hitting them. + pub known: BTreeSet<(&'static str, Dmg)>, + /// The kinds of what's died since whoever's keeping count last took + /// them. + pub slain: Vec<&'static str>, + /// This turn's shots, yours and theirs, in the order they were fired. + pub volley: Vec<Trace>, pub fov: Fov, /// Cells you've seen at some point, drawn from memory when out of sight. seen: Vec<bool>, /// Messages, oldest first. pub log: Vec<String>, + /// How many messages there have been, however many the log still holds. + pub said: u32, pub turns: u32, pub outcome: Option<Outcome>, + /// Whether you've been told how to ready what you pick up. + shown_pack: bool, /// Dice for fights. Separate from the ship's seed, so the same ship and /// the same moves always play out the same way. rng: Rng, } +/// A crawl as it stands, for a save: everything that's changed since the +/// ship was made from its seed. `Crawl::resume` makes the ship again and +/// puts it all back. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SavedCrawl { + pub seed: u64, + /// The wreck's name, to show without making the ship. + pub name: String, + player: Pos, + hp: i32, + max_hp: i32, + monsters: Vec<SavedMonster>, + loot: Vec<Loot>, + gear: Gear, + #[serde(default)] + bleeding: u32, + /// The kind of monster that opened your wound. + #[serde(default)] + bled_by: String, + #[serde(default)] + burning: u32, + /// The kind of monster that set you alight. + #[serde(default)] + burned_by: String, + /// The doors standing open. + #[serde(default)] + opened: Vec<Pos>, + /// Every cell you've seen, in hex, four cells to a digit. + #[serde(default)] + seen: String, + #[serde(default)] + log: Vec<String>, + #[serde(default)] + turns: u32, + #[serde(default)] + shown_pack: bool, +} + +/// A monster, with its kind by name. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +struct SavedMonster { + kind: String, + pos: Pos, + hp: i32, + awake: bool, + bleeding: u32, + burning: u32, + stunned: u32, + loaded: u32, +} + +/// Cells as hex, four to a digit, the first in the lowest bit. +fn to_hex(cells: &[bool]) -> String { + cells + .chunks(4) + .map(|four| { + let n = four.iter().enumerate().fold(0, |n, (i, &on)| n | (u32::from(on) << i)); + char::from_digit(n, 16).unwrap_or('0') + }) + .collect() +} + +/// `len` cells back from hex; any short of that are unseen. +fn from_hex(text: &str, len: usize) -> Vec<bool> { + let mut cells: Vec<bool> = text + .chars() + .flat_map(|c| { + let n = c.to_digit(16).unwrap_or(0); + (0..4).map(move |i| n >> i & 1 == 1) + }) + .collect(); + cells.resize(len, false); + cells +} + +/// The name and description ("Cant hauler") of the derelict that +/// `Crawl::new(seed)` would board, without starting a crawl. +pub fn describe(seed: u64) -> (String, String) { + let ship = ship::generate(seed::derive(seed, "ship", 0)); + (ship.name, ship.description) +} + impl Crawl { pub fn new(seed: u64) -> Crawl { let ship = ship::generate(seed::derive(seed, "ship", 0)); let cells = (ship.map.width * ship.map.height) as usize; let mut crawl = Crawl { + seed, fov: fov::compute(&ship.map, ship.start, SIGHT_RADIUS), name: ship.name, description: ship.description, @@ -118,13 +432,21 @@ impl Crawl { max_hp: PLAYER_HP, monsters: ship.monsters, loot: ship.loot, - artifacts: Vec::new(), - scrip: 0, - medpatches: 0, + gear: Gear::default(), + bleeding: 0, + bled_by: "", + burning: 0, + burned_by: "", + reeling: false, + known: BTreeSet::new(), + slain: Vec::new(), + volley: Vec::new(), seen: vec![false; cells], log: Vec::new(), + said: 0, turns: 0, outcome: None, + shown_pack: false, rng: seed::rng(seed::derive(seed, "dice", 0)), }; crawl.say(format!("You board {}, a derelict {}.", crawl.name, crawl.description)); @@ -132,44 +454,193 @@ impl Crawl { crawl } + /// Everything about this crawl a save needs to bring it back. + pub fn saved(&self) -> SavedCrawl { + let open = |p: &Pos| self.map.tile(*p) == Tile::Door { open: true }; + SavedCrawl { + seed: self.seed, + name: self.name.clone(), + player: self.player, + hp: self.hp, + max_hp: self.max_hp, + monsters: self + .monsters + .iter() + .map(|m| SavedMonster { + kind: m.kind.name.to_string(), + pos: m.pos, + hp: m.hp, + awake: m.awake, + bleeding: m.bleeding, + burning: m.burning, + stunned: m.stunned, + loaded: m.loaded, + }) + .collect(), + loot: self.loot.clone(), + gear: self.gear.clone(), + bleeding: self.bleeding, + bled_by: self.bled_by.to_string(), + burning: self.burning, + burned_by: self.burned_by.to_string(), + opened: self.map.positions().filter(open).collect(), + seen: to_hex(&self.seen), + log: self.log.clone(), + turns: self.turns, + shown_pack: self.shown_pack, + } + } + + /// The crawl a save kept, made again: the same ship, with everything + /// that had changed aboard it put back. `None` if it no longer fits the + /// ship its seed makes now. The dice roll afresh from here, and what + /// you know of monsters is the scene's to fill in. + pub fn resume(saved: &SavedCrawl) -> Option<Crawl> { + let mut crawl = Crawl::new(saved.seed); + crawl.log.clear(); + for &p in &saved.opened { + if crawl.map.contains(p) && matches!(crawl.map.tile(p), Tile::Door { .. }) { + crawl.map.set(p, Tile::Door { open: true }); + } + } + let fits = |p: Pos| crawl.map.contains(p) && crawl.map.tile(p).walkable(); + if !fits(saved.player) { + return None; + } + let kind = |name: &str| MONSTERS.iter().find(|k| k.name == name); + let monsters = saved + .monsters + .iter() + .filter(|m| fits(m.pos) && m.hp > 0) + .filter_map(|m| { + Some(Monster { + kind: kind(&m.kind)?, + pos: m.pos, + hp: m.hp, + awake: m.awake, + bleeding: m.bleeding, + burning: m.burning, + stunned: m.stunned, + loaded: m.loaded, + }) + }) + .collect(); + let loot = + saved.loot.iter().filter(|l| fits(l.pos) && l.stack.count > 0).cloned().collect(); + crawl.monsters = monsters; + crawl.loot = loot; + crawl.player = saved.player; + crawl.max_hp = saved.max_hp.max(1); + crawl.hp = saved.hp.clamp(1, crawl.max_hp); + crawl.gear = saved.gear.clone(); + crawl.gear.mend(); + crawl.bled_by = kind(&saved.bled_by).map_or("", |k| k.name); + crawl.bleeding = if crawl.bled_by.is_empty() { 0 } else { saved.bleeding }; + crawl.burned_by = kind(&saved.burned_by).map_or("", |k| k.name); + crawl.burning = if crawl.burned_by.is_empty() { 0 } else { saved.burning }; + crawl.seen = from_hex(&saved.seen, crawl.seen.len()); + crawl.log = saved.log.iter().rev().take(LOG_LENGTH).rev().cloned().collect(); + crawl.turns = saved.turns; + crawl.shown_pack = saved.shown_pack; + crawl.rng = seed::rng(seed::derive(saved.seed, "dice", u64::from(saved.turns) + 1)); + crawl.fov = fov::compute(&crawl.map, crawl.player, SIGHT_RADIUS); + for p in crawl.map.positions() { + if crawl.fov.contains(p) { + let i = crawl.map.index(p); + crawl.seen[i] = true; + } + } + Some(crawl) + } + pub fn seen(&self, p: Pos) -> bool { self.map.contains(p) && self.seen[self.map.index(p)] } pub fn monster_at(&self, p: Pos) -> Option<&Monster> { - self.monsters.iter().find(|m| m.pos == p) + self.monsters.iter().find(|m| m.pos == p && m.hp > 0) + } + + fn alive_at(&self, p: Pos) -> Option<usize> { + self.monsters.iter().position(|m| m.pos == p && m.hp > 0) } pub fn loot_at(&self, p: Pos) -> Option<&Loot> { self.loot.iter().find(|l| l.pos == p) } + /// What's lying where you stand, as indices into `loot`. + pub fn here(&self) -> Vec<usize> { + (0..self.loot.len()).filter(|&i| self.loot[i].pos == self.player).collect() + } + /// The name of where you're standing. pub fn location(&self) -> &'static str { self.map.room_at(self.player).map_or("a dark passage", |r| r.name) } + /// What you know of how `kind` takes `dmg`, if you've hit one with it. + pub fn knows(&self, kind: &MonsterKind, dmg: Dmg) -> Option<Affinity> { + self.known.contains(&(kind.name, dmg)).then(|| kind.affinity(dmg)) + } + /// Takes your turn, then everything else's. Does nothing once the crawl /// is over, or if the action wouldn't take any time (walking into a wall). pub fn act(&mut self, action: Action) { if self.outcome.is_some() { return; } + self.volley.clear(); let took_time = match action { Action::Wait => true, Action::Move(dx, dy) => self.step(dx, dy), Action::UseMedpatch => self.use_medpatch(), + Action::Fire(aim) => self.fire(aim), + Action::Reload => self.reload(), + Action::Ready(i) => self.outfit(|g| g.ready(i)), + Action::Wear(i) => self.outfit(|g| g.wear(i)), + Action::TakeOff(slot) => self.outfit(|g| g.take_off(slot)), + Action::Eat(i) => self.outfit(|g| g.eat(i)), + Action::Drop(spot, count) => { + self.put_down(spot, count); + false + } + Action::Take(n, count) => match self.here().get(n) { + Some(&i) => self.take(i, count), + None => false, + }, + Action::PickUp => self.pick_up(true), }; + self.bury(); if !took_time || self.outcome.is_some() { return; } self.turns += 1; + self.fester(); + self.bury(); + // Where you are now is what things can see; nothing new wakes until + // they've all had their turn. + self.fov = fov::compute(&self.map, self.player, SIGHT_RADIUS); self.monsters_act(); + self.bury(); + // Knocked into a bulkhead, you lose a turn while they have theirs. + if std::mem::take(&mut self.reeling) && self.outcome.is_none() { + self.say("You reel, and lose a turn.".into()); + self.turns += 1; + self.fester(); + self.bury(); + self.fov = fov::compute(&self.map, self.player, SIGHT_RADIUS); + self.monsters_act(); + self.bury(); + // Not twice running. + self.reeling = false; + } self.look(); } - fn say(&mut self, message: String) { + pub fn say(&mut self, message: String) { self.log.push(message); + self.said += 1; if self.log.len() > LOG_LENGTH { self.log.remove(0); } @@ -197,10 +668,17 @@ impl Crawl { } } + /// Clears away the dead. Everything that kills says so as it does. + fn bury(&mut self) { + let dead = self.monsters.iter().filter(|m| m.hp <= 0).map(|m| m.kind.name); + self.slain.extend(dead); + self.monsters.retain(|m| m.hp > 0); + } + fn step(&mut self, dx: i32, dy: i32) -> bool { let target = self.player.offset(dx, dy); - if let Some(i) = self.monsters.iter().position(|m| m.pos == target) { - self.attack(i); + if let Some(i) = self.alive_at(target) { + self.strike(i); return true; } match self.map.tile(target) { @@ -216,113 +694,663 @@ impl Crawl { } tile if tile.walkable() => { self.player = target; - self.pick_up(); + self.pick_up(false); true } _ => false, } } - fn pick_up(&mut self) { - while let Some(i) = self.loot.iter().position(|l| l.pos == self.player) { - match self.loot.remove(i).item { - Item::Artifact(artifact) => { - self.say(format!("You find {}.", artifact.unidentified)); - self.artifacts.push(artifact); - } - Item::Salvage { name, value } => { - self.scrip += value; - self.say(format!("You salvage {name} ({value} ¤).")); - } - Item::Medpatch => { - self.medpatches += 1; - self.say("You pocket a medpatch.".into()); - } + /// Picks up what's here that there's room for: everything, if you + /// asked, or else whatever you didn't put down yourself. Says whether + /// you took anything. + fn pick_up(&mut self, asked: bool) -> bool { + let here = self.here(); + if asked && here.is_empty() { + self.say("There's nothing here to pick up.".into()); + } + let mut took = false; + // From the last, so taking one doesn't move the rest. + for &i in here.iter().rev() { + let loot = &self.loot[i]; + if loot.left && !asked { + let name = loot.stack.name(); + self.say(format!("You left {name} here (g picks it up).")); + } else if asked || self.gear.fits(&loot.stack.item) >= loot.stack.count { + took |= self.take(i, loot.stack.count); + } else { + let (name, kg) = (loot.stack.name(), items::kg(loot.stack.mass())); + self.say(format!("There's {name} here ({kg} kg): too much to carry (g, i).")); + } + } + took + } + + /// Picks up as many as there's room for, up to `count`, of the loot at + /// `i`. Says whether you took any. + fn take(&mut self, i: usize, count: u32) -> bool { + let loot = &self.loot[i]; + let count = count.min(loot.stack.count).min(self.gear.fits(&loot.stack.item)); + if count == 0 { + let name = loot.stack.name(); + let room = items::kg(self.gear.room()); + self.say(format!("You've no room for {name}: only {room} kg to spare.")); + return false; + } + let more = count < loot.stack.count; + let stack = if more { + self.loot[i].stack.count -= count; + Stack { item: self.loot[i].stack.item.clone(), count } + } else { + self.loot.remove(i).stack + }; + let name = stack.name(); + self.say(match &stack.item { + Item::Artifact(a) => format!("You find {}.", a.unidentified), + Item::Salvage(s) => { + format!("You salvage {name} ({} kg, worth about {} ¤).", items::kg(s.mass), s.value) } + Item::Medpatch => format!("You pocket {name}."), + Item::Weapon(w) => format!("You take {name} ({}).", w.damage_text()), + Item::Armor(a) => format!("You take {name} ({}).", a.guards_text()), + Item::Food(_) => format!("You take {name}."), + }); + if more { + self.say("You've no room for the rest.".into()); + } + let how = match stack.item { + Item::Weapon(_) => Some("ready it"), + Item::Armor(_) => Some("put it on"), + _ => None, + }; + self.gear.add(stack); + if let Some(how) = how + && !self.shown_pack + { + self.shown_pack = true; + self.say(format!("Your pack (i) is where you {how}.")); } + true } fn use_medpatch(&mut self) -> bool { - if self.medpatches == 0 { + if self.gear.medpatches() == 0 { self.say("You have no medpatches.".into()); return false; } - if self.hp == self.max_hp { + if self.hp == self.max_hp && self.bleeding == 0 && self.burning == 0 { self.say("You're not hurt.".into()); return false; } - self.medpatches -= 1; + self.gear.take_medpatch(); let healed = MEDPATCH_HEAL.min(self.max_hp - self.hp); self.hp += healed; - self.say(format!("The medpatch stings, then numbs (+{healed}).")); + let stanched = if self.bleeding > 0 { " The bleeding stops." } else { "" }; + let smothered = if self.burning > 0 { " Its foam smothers the flames." } else { "" }; + self.bleeding = 0; + self.burning = 0; + self.say(format!("The medpatch stings, then numbs (+{healed}).{stanched}{smothered}")); + true + } + + /// Changes what you have ready or wear, or eats something: a turn's + /// work if it works, and nothing if it doesn't. + fn outfit(&mut self, change: impl FnOnce(&mut Gear) -> Result<String, String>) -> bool { + match change(&mut self.gear) { + Ok(said) => { + self.say(said); + true + } + Err(said) => { + self.say(said); + false + } + } + } + + fn put_down(&mut self, spot: Spot, count: u32) { + match self.gear.remove(spot, count) { + Ok(stack) => { + self.say(format!("You put down {}.", stack.name())); + self.loot.push(Loot { pos: self.player, stack, left: true }); + } + Err(said) => self.say(said), + } + } + + fn reload(&mut self) -> bool { + let w = &mut self.gear.gun; + let Some(gun) = w.gun else { return false }; + let yours = w.yours(); + if w.loaded == gun.magazine { + self.say(format!("{} is loaded.", capitalise(&yours))); + return false; + } + w.reload(); + self.say(format!("You reload {yours}.")); true } - fn attack(&mut self, i: usize) { - let name = self.monsters[i].kind.name; + /// Hits monster `i` with your readied blade or club. + fn strike(&mut self, i: usize) { + let weapon = self.gear.melee.clone(); self.monsters[i].awake = true; - if !self.rng.random_bool(PLAYER_HIT) { - self.say(format!("Your torch misses the {name}.")); + if !self.rng.random_bool(weapon.accuracy) { + let name = self.monsters[i].kind.name; + self.say(format!("{} misses the {name}.", capitalise(&weapon.yours()))); return; } - let damage = self.rng.random_range(PLAYER_DAMAGE.0..=PLAYER_DAMAGE.1); - self.monsters[i].hp -= damage; - if self.monsters[i].hp <= 0 { - self.monsters.remove(i); - self.say(format!("You burn the {name} down to slag.")); + self.wound(i, &weapon, 1, self.player); + } + + /// Fires your gun at `aim`. With the magazine empty, reloads instead. + fn fire(&mut self, aim: Pos) -> bool { + let weapon = self.gear.gun.clone(); + let Some(gun) = weapon.gun else { return false }; + if aim == self.player || !self.map.contains(aim) { + return false; + } + if weapon.loaded == 0 { + return self.reload(); + } + self.gear.gun.loaded -= 1; + let passes = if weapon.dmg == Dmg::Pierce { weapon.effect } else { 0 }; + let mut hits = vec![0; self.monsters.len()]; + for _ in 0..gun.pellets { + let stray = arms::stray(&mut self.rng, gun.spread); + let shot = self.shot(self.player, aim, stray, gun.range, passes); + for &t in &shot.struck { + if let Target::Monster(i) = t { + hits[i] += 1; + } + } + let hit = !shot.struck.is_empty(); + self.volley.push(Trace { cells: shot.cells, dmg: weapon.dmg, hit, delay: 0 }); + } + if hits.iter().all(|&n| n == 0) { + self.say(format!("{} misses.", capitalise(&weapon.yours()))); + } + self.echo(); + for (i, n) in hits.into_iter().enumerate() { + if n > 0 && self.monsters[i].hp > 0 { + self.wound(i, &weapon, n, self.player); + } + } + true + } + + /// Gunfire carries through the hull: whatever's asleep within earshot + /// wakes, and comes looking. + fn echo(&mut self) { + let dist = self.map.distances_from(self.player); + let mut stirred = false; + for m in &mut self.monsters { + let near = dist[self.map.index(m.pos)].is_some_and(|d| d <= GUNFIRE_CARRIES); + if !m.awake && near { + m.awake = true; + stirred |= !self.fov.contains(m.pos); + } + } + if stirred { + self.say("The shot rings through the hull. Something stirs.".into()); + } + } + + /// Where a round from `from` goes, aimed at `aim` but straying + /// `stray` degrees: the cells it crosses and what it hits, in order. It + /// stops at a wall, or at the first thing it hits after passing + /// through `passes` others. + fn shot(&self, from: Pos, aim: Pos, stray: f64, range: i32, mut passes: u32) -> Shot { + let mut shot = Shot { cells: Vec::new(), struck: Vec::new() }; + for p in arms::flight(from, aim, stray, range) { + shot.cells.push(p); + if self.map.tile(p).blocks_sight() { + break; + } + let target = if p == self.player { + Some(Target::You) + } else { + self.alive_at(p).map(Target::Monster) + }; + if let Some(t) = target { + shot.struck.push(t); + if passes == 0 { + break; + } + passes -= 1; + } + } + shot + } + + /// The chance that one round from your gun, aimed at `aim`, hits what's + /// there, given what's in the way. `None` if nothing's there. + pub fn odds(&self, aim: Pos) -> Option<f64> { + let weapon = &self.gear.gun; + let gun = weapon.gun?; + let i = self.alive_at(aim)?; + let passes = if weapon.dmg == Dmg::Pierce { weapon.effect } else { 0 }; + let hits = (0..ODDS_SAMPLES) + .filter(|&k| { + let u = (f64::from(k) + 0.5) / f64::from(ODDS_SAMPLES); + let stray = arms::stray_quantile(u, gun.spread); + self.shot(self.player, aim, stray, gun.range, passes) + .struck + .contains(&Target::Monster(i)) + }) + .count(); + Some(hits as f64 / f64::from(ODDS_SAMPLES)) + } + + /// The cells a dead-straight shot at `aim` would cross before a wall + /// stops it or it runs out of range. + pub fn line_of_fire(&self, aim: Pos) -> Vec<Pos> { + let range = self.gear.gun.gun.map_or(0, |g| g.range); + self.shot(self.player, aim, 0.0, range, u32::MAX).cells + } + + /// Whether `aim` is close enough for your gun to reach. + pub fn in_range(&self, aim: Pos) -> bool { + self.gear.gun.gun.is_some_and(|g| within(self.player, aim, g.range)) + } + + /// `hits` blows or rounds from `weapon` land on monster `i`, from the + /// direction of `from`. Rolls the damage, does whatever else the weapon + /// does, and says so. The dead are left for `bury`. + fn wound(&mut self, i: usize, weapon: &Weapon, hits: u32, from: Pos) { + let kind = self.monsters[i].kind; + let name = kind.name; + let affinity = kind.affinity(weapon.dmg); + self.monsters[i].awake = true; + let (mut total, mut crits) = (0, 0); + for _ in 0..hits { + let mut d = self.rng.random_range(weapon.damage.0..=weapon.damage.1); + if self.rng.random_bool(weapon.crit) { + d *= 2; + crits += 1; + } + if affinity != Affinity::Immune { + let armour = if weapon.dmg == Dmg::Pierce { 0 } else { kind.armour }; + total += (affinity.scale(d) - armour).max(1); + } + } + + let subject = capitalise(&weapon.yours()); + let verb = if weapon.is_gun() { "hits" } else { weapon.dmg.verb() }; + let times = match hits { + 1 => String::new(), + 2 => " twice".into(), + n => format!(" {n} times"), + }; + if affinity == Affinity::Immune { + self.say(format!("{subject} {verb} the {name}{times}, to no effect.")); } else { - self.say(format!("You burn the {name} ({damage}).")); + let mut notes = vec![total.to_string()]; + if crits > 0 { + notes.push("critical".into()); + } + match affinity { + Affinity::Weak => notes.push("weak".into()), + Affinity::Resists => notes.push("resisted".into()), + _ => {} + } + self.say(format!("{subject} {verb} the {name}{times} ({}).", notes.join(", "))); + } + if self.known.insert((name, weapon.dmg)) && affinity != Affinity::Normal { + self.say(format!("The {name} {} {}.", affinity.phrase(), weapon.dmg.name())); + } + if total == 0 { + return; + } + self.monsters[i].hp -= total; + if self.monsters[i].hp <= 0 { + self.say(killed(weapon.dmg, name)); + return; + } + + let chance = f64::from(weapon.effect.min(100)) / 100.0; + match weapon.dmg { + Dmg::Cut if weapon.effect > 0 && kind.blood => { + let m = &mut self.monsters[i]; + let fresh = m.bleeding == 0; + m.bleeding = (m.bleeding + weapon.effect * hits).min(MAX_BLEEDING); + if fresh { + self.say(format!("The {name} is bleeding.")); + } + } + Dmg::Burn if weapon.effect > 0 => { + let m = &mut self.monsters[i]; + let fresh = m.burning == 0; + m.burning = m.burning.max(weapon.effect); + if fresh { + self.say(format!("The {name} catches fire.")); + } + } + Dmg::Shock if self.rng.random_bool(chance) => { + self.monsters[i].stunned = 1; + self.say(format!("The {name} seizes up.")); + } + Dmg::Blunt if self.rng.random_bool(chance) => self.knock_back(i, from), + Dmg::Hymn if self.hp < self.max_hp && self.rng.random_bool(chance) => { + let eased = self.rng.random_range(1..=2).min(self.max_hp - self.hp); + self.hp += eased; + self.say(format!("The hymn steadies you (+{eased}).")); + } + _ => {} } } - /// Each awake monster attacks if it's next to you, or else takes one - /// step along the shortest path toward you, opening doors on the way. + /// Knocks monster `i` a cell away from `from`. Into a wall, it staggers. + fn knock_back(&mut self, i: usize, from: Pos) { + let m = &self.monsters[i]; + let (name, at) = (m.kind.name, m.pos); + let to = at.offset((at.x - from.x).signum(), (at.y - from.y).signum()); + let tile = self.map.tile(to); + if tile.walkable() && to != self.player && self.alive_at(to).is_none() { + self.monsters[i].pos = to; + self.say(format!("The {name} is knocked back.")); + } else if tile.blocks_sight() { + let slam = self.rng.random_range(1..=2); + let m = &mut self.monsters[i]; + m.hp -= slam; + m.stunned = 1; + let dead = m.hp <= 0; + self.say(format!("The {name} slams into the bulkhead ({slam}).")); + if dead { + self.say(killed(Dmg::Blunt, name)); + } + } + } + + /// Each awake monster has its turn: shoots if it has a gun and you in + /// its sights, hits you if it's next to you, or else heads toward you + /// along the shortest path, opening doors on the way. fn monsters_act(&mut self) { let dist = self.map.distances_from(self.player); for i in 0..self.monsters.len() { if self.outcome.is_some() { return; } - let monster = &self.monsters[i]; - if !monster.awake { + let m = &self.monsters[i]; + if !m.awake || m.hp <= 0 { + continue; + } + if m.stunned > 0 { + self.monsters[i].stunned -= 1; continue; } - if monster.pos.steps_to(self.player) == 1 { - self.monster_attacks(i); + let (kind, pos) = (m.kind, m.pos); + if let Some(gun) = kind.attack.gun + && within(pos, self.player, gun.range) + && self.fov.contains(pos) + { + self.monster_fires(i, gun); continue; } - let here = dist[self.map.index(monster.pos)].unwrap_or(u32::MAX); - let next = monster - .pos - .neighbours() - .filter(|&n| self.map.contains(n) && self.monster_at(n).is_none()) - .filter_map(|n| dist[self.map.index(n)].map(|d| (d, n))) - .filter(|&(d, _)| d < here) - .min_by_key(|&(d, n)| (d, n.y, n.x)); - if let Some((_, n)) = next { - if self.map.tile(n) == (Tile::Door { open: false }) { - self.map.set(n, Tile::Door { open: true }); - } else { - self.monsters[i].pos = n; + for _ in 0..kind.speed { + if self.monsters[i].pos.steps_to(self.player) == 1 { + self.monster_attacks(i); + break; + } + if kind.slow && self.turns.is_multiple_of(2) { + break; + } + if !self.monster_steps(i, &dist) { + break; } } } } + /// Takes one step toward you, or opens a door in the way. Says whether + /// it went anywhere. + fn monster_steps(&mut self, i: usize, dist: &[Option<u32>]) -> bool { + let pos = self.monsters[i].pos; + let here = dist[self.map.index(pos)].unwrap_or(u32::MAX); + let next = pos + .neighbours() + .filter(|&n| self.map.contains(n) && self.alive_at(n).is_none()) + .filter_map(|n| dist[self.map.index(n)].map(|d| (d, n))) + .filter(|&(d, _)| d < here) + .min_by_key(|&(d, n)| (d, n.y, n.x)); + let Some((_, n)) = next else { return false }; + if self.map.tile(n) == (Tile::Door { open: false }) { + self.map.set(n, Tile::Door { open: true }); + } else { + self.monsters[i].pos = n; + } + true + } + fn monster_attacks(&mut self, i: usize) { - let kind = self.monsters[i].kind; - if !self.rng.random_bool(MONSTER_HIT) { + let (kind, from) = (self.monsters[i].kind, self.monsters[i].pos); + let attack = &kind.attack; + if !self.rng.random_bool(attack.hit) { self.say(format!("The {} misses you.", kind.name)); return; } - let damage = self.rng.random_range(kind.damage.0..=kind.damage.1); + let damage = self.rng.random_range(attack.damage.0..=attack.damage.1); + let (damage, shown, worn) = self.blunted(damage, attack.dmg); + self.say(format!("The {} {} you ({shown}).", kind.name, kind.verb)); + self.hurt(damage, kind.name); + self.aftermath(kind, from); + for line in worn { + self.say(line); + } + } + + /// What a blow from `kind`, struck from `from`, leaves you with besides + /// the hurt: a wound that bleeds unless your armour keeps it closed, + /// flames if it burns, a shove if it's heavy. + fn aftermath(&mut self, kind: &'static MonsterKind, from: Pos) { + let attack = &kind.attack; + if self.outcome.is_some() { + return; + } + if attack.bleed > 0 { + // Part of the same blow: it wears no more for it. + if let Some(a) = self.gear.worn.values().find(|a| a.stanching()) { + self.say(format!("{} keeps the wound closed.", capitalise(&a.yours()))); + } else { + if self.bleeding == 0 { + self.say("You're bleeding.".into()); + } + self.bleeding = (self.bleeding + attack.bleed).min(MAX_BLEEDING); + self.bled_by = kind.name; + } + } + if attack.ignite > 0 { + if self.burning == 0 { + self.say("You catch alight.".into()); + } + self.burning = self.burning.max(attack.ignite); + self.burned_by = kind.name; + } + if attack.knock > 0.0 && self.rng.random_bool(attack.knock) { + self.knocked(from); + } + } + + /// Knocks you a cell away from `from`. Into a bulkhead, or anything else + /// in the way, you reel and lose your next turn. + fn knocked(&mut self, from: Pos) { + let at = self.player; + let to = at.offset((at.x - from.x).signum(), (at.y - from.y).signum()); + let tile = self.map.tile(to); + match self.alive_at(to) { + None if tile.walkable() => { + self.player = to; + self.say("You're knocked back.".into()); + } + Some(j) => { + let name = self.monsters[j].kind.name; + self.reeling = true; + self.say(format!("You're knocked into the {name}.")); + } + None => { + self.reeling = true; + self.say("You're knocked into the bulkhead.".into()); + } + } + } + + fn monster_fires(&mut self, i: usize, gun: Gun) { + let (kind, from) = (self.monsters[i].kind, self.monsters[i].pos); + if self.monsters[i].loaded == 0 { + self.monsters[i].loaded = gun.magazine; + self.say(format!("The {} reloads.", kind.name)); + return; + } + self.monsters[i].loaded -= 1; + let stray = arms::stray(&mut self.rng, gun.spread); + let shot = self.shot(from, self.player, stray, gun.range, 0); + let delay = self.volley.iter().map(|t| t.delay + t.cells.len() as u32 + 2).max(); + let attack = &kind.attack; + let hit = !shot.struck.is_empty(); + let struck = shot.struck.first().copied(); + self.volley.push(Trace { + cells: shot.cells, + dmg: attack.dmg, + hit, + delay: delay.unwrap_or(0), + }); + let damage = self.rng.random_range(attack.damage.0..=attack.damage.1); + match struck { + Some(Target::You) => { + let (damage, shown, worn) = self.blunted(damage, attack.dmg); + self.say(format!("The {} {} you ({shown}).", kind.name, kind.verb)); + self.hurt(damage, kind.name); + self.aftermath(kind, from); + for line in worn { + self.say(line); + } + } + Some(Target::Monster(j)) => { + let other = self.monsters[j].kind; + let affinity = other.affinity(attack.dmg); + let damage = if affinity == Affinity::Immune { 0 } else { affinity.scale(damage) }; + let m = &mut self.monsters[j]; + m.hp -= damage; + m.awake = true; + let dead = m.hp <= 0; + self.say(format!("The {}'s shot hits the {} ({damage}).", kind.name, other.name)); + if dead { + self.say(format!("The {} goes down.", other.name)); + } + } + None => self.say(format!("The {}'s shot goes wide.", kind.name)), + } + } + + /// What a blow of `damage` does to you through what you're wearing: + /// each piece takes off what it guards against `dmg`, though a blow + /// always does at least 1, and each piece that softened it wears a + /// little. Returns what's left, how to show it ("2", or "pierce 2 - 1 = + /// 1"), and anything to say about armour wearing thin or out. + fn blunted(&mut self, damage: i32, dmg: Dmg) -> (i32, String, Vec<String>) { + let mut left = damage; + let mut news = Vec::new(); + for a in self.gear.worn.values_mut() { + let off = a.guard(dmg).min(left - 1); + if off > 0 { + left -= off; + news.extend(a.wear_down()); + } + } + let shown = if left < damage { + format!("{} {damage} - {} = {left}", dmg.name(), damage - left) + } else { + damage.to_string() + }; + (left, shown, news) + } + + fn hurt(&mut self, damage: i32, by: &'static str) { self.hp = (self.hp - damage).max(0); - self.say(format!("The {} {} you ({damage}).", kind.name, kind.verb)); - if self.hp == 0 { - self.outcome = Some(Outcome::Died { killer: kind.name }); + if self.hp == 0 && self.outcome.is_none() { + self.outcome = Some(Outcome::Died { killer: by }); self.say(format!("You die aboard {}.", self.name)); } } + + /// After your move each turn, wounds bleed and fires burn, a little + /// less each time. Yours too: a wound you take on their move bleeds on + /// your next, unless a medpatch stops it first, and flames burn until + /// they go out or a medpatch smothers them, your burn guards taking + /// their share. + fn fester(&mut self) { + for i in 0..self.monsters.len() { + let m = &mut self.monsters[i]; + if m.hp <= 0 || (m.bleeding == 0 && m.burning == 0) { + continue; + } + let (mut bled, mut burned) = (0, 0); + if m.bleeding > 0 { + bled = m.bleeding as i32; + m.bleeding -= 1; + } + if m.burning > 0 { + burned = m.kind.affinity(Dmg::Burn).scale(self.rng.random_range(1..=2)); + m.burning -= 1; + } + m.hp -= bled + burned; + let (name, dead, pos) = (m.kind.name, m.hp <= 0, m.pos); + if dead { + let how = if burned > 0 { "burns down to slag" } else { "bleeds out" }; + self.say(format!("The {name} {how}.")); + } else if self.fov.contains(pos) { + let mut what = Vec::new(); + if bled > 0 { + what.push(format!("bleeds ({bled})")); + } + if burned > 0 { + what.push(format!("burns ({burned})")); + } + self.say(format!("The {name} {}.", what.join(" and "))); + } + } + if self.bleeding > 0 && self.outcome.is_none() { + let bled = self.bleeding as i32; + self.bleeding -= 1; + let clots = if self.bleeding == 0 { " The wound clots." } else { "" }; + self.say(format!("You bleed ({bled}).{clots}")); + self.hurt(bled, self.bled_by); + } + if self.burning > 0 && self.outcome.is_none() { + self.burning -= 1; + let heat = self.rng.random_range(1..=2); + let (burned, shown, worn) = self.blunted(heat, Dmg::Burn); + let out = if self.burning == 0 { " The flames go out." } else { "" }; + self.say(format!("You burn ({shown}).{out}")); + self.hurt(burned, self.burned_by); + for line in worn { + self.say(line); + } + } + } +} + +/// Whether `b` is within `range` cells of `a`, measured the way sight and +/// shots are. +fn within(a: Pos, b: Pos, range: i32) -> bool { + let (dx, dy) = (b.x - a.x, b.y - a.y); + dx * dx + dy * dy <= range * range + range +} + +fn killed(dmg: Dmg, name: &str) -> String { + match dmg { + Dmg::Cut => format!("You cut the {name} down."), + Dmg::Blunt => format!("You smash the {name} to pieces."), + Dmg::Pierce => format!("The {name} drops, holed through."), + Dmg::Burn => format!("You burn the {name} down to slag."), + Dmg::Shock => format!("The {name} jerks and goes still."), + Dmg::Hymn => format!("The {name} falls silent."), + } +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) } #[cfg(test)] @@ -344,7 +1372,71 @@ mod tests { Action::UseMedpatch, ]; for _ in 0..turns { - crawl.act(ACTIONS[rng.random_range(..ACTIONS.len())]); + let action = match rng.random_range(0..20) { + 0 => Action::Reload, + 1 => Action::PickUp, + 2 => Action::Ready(rng.random_range(0..8)), + 3 => { + let spot = match rng.random_range(0..4) { + 0 => Spot::Melee, + 1 => Spot::Gun, + 2 => Spot::Worn(Slot::Head), + _ => Spot::Pack(rng.random_range(0..8)), + }; + Action::Drop(spot, rng.random_range(1..=3)) + } + 4 | 5 => { + let p = crawl.player; + Action::Fire(p.offset(rng.random_range(-9..=9), rng.random_range(-9..=9))) + } + _ => ACTIONS[rng.random_range(..ACTIONS.len())], + }; + crawl.act(action); + } + } + + /// A crawl in an empty room 30 wide and 9 high, standing at (2, 5) + /// with nothing else aboard. + fn arena() -> Crawl { + let mut crawl = Crawl::new(1); + let mut map = Map::new(32, 11, Tile::Hull); + for p in map.positions().collect::<Vec<_>>() { + if (1..31).contains(&p.x) && (1..10).contains(&p.y) { + map.set(p, Tile::Floor); + } + } + crawl.seen = vec![false; (map.width * map.height) as usize]; + crawl.map = map; + crawl.player = Pos::new(2, 5); + crawl.monsters.clear(); + crawl.loot.clear(); + crawl.look(); + crawl + } + + /// Puts an awake `name` at `pos`. + fn spawn(crawl: &mut Crawl, name: &str, pos: Pos) -> usize { + let kind = MONSTERS.iter().find(|k| k.name == name).unwrap(); + let mut m = Monster::new(kind, pos); + m.awake = true; + crawl.monsters.push(m); + crawl.monsters.len() - 1 + } + + /// A weapon that always lands, doing exactly `damage`. + fn sure(dmg: Dmg, damage: i32, effect: u32) -> Weapon { + let mut w = arms::cutting_torch(); + w.kit = false; + w.title = "test weapon".into(); + (w.dmg, w.damage, w.accuracy, w.crit, w.effect) = (dmg, (damage, damage), 1.0, 0.0, effect); + w + } + + fn wield(crawl: &mut Crawl, w: Weapon) { + if w.is_gun() { + crawl.gear.gun = w; + } else { + crawl.gear.melee = w; } } @@ -356,9 +1448,13 @@ mod tests { assert!(crawl.hp >= 0 && crawl.hp <= crawl.max_hp); assert!(crawl.map.tile(crawl.player).walkable(), "seed {seed}: player in a wall"); for m in &crawl.monsters { + assert!(m.hp > 0, "seed {seed}: a dead {} is still about", m.kind.name); assert!(crawl.map.tile(m.pos).walkable(), "seed {seed}: {} in a wall", m.kind.name); assert_ne!(m.pos, crawl.player); } + let g = &crawl.gear; + assert!(!g.melee.is_gun() && g.gun.is_gun(), "seed {seed}: the wrong things ready"); + assert!(g.carried() <= g.capacity, "seed {seed}: carrying too much"); if crawl.outcome.is_none() { assert!(crawl.fov.contains(crawl.player)); } @@ -390,7 +1486,9 @@ mod tests { let mut crawl = Crawl::new(5); let next_to_player = crawl.player.neighbours().find(|&n| crawl.map.tile(n).walkable()); let pos = next_to_player.unwrap(); - crawl.monsters = vec![Monster { kind: &MONSTERS[0], pos, hp: 3, awake: true }]; + let mut mite = Monster::new(&MONSTERS[0], pos); + mite.awake = true; + crawl.monsters = vec![mite]; let (dx, dy) = (pos.x - crawl.player.x, pos.y - crawl.player.y); for _ in 0..50 { crawl.act(Action::Move(dx, dy)); @@ -413,4 +1511,430 @@ mod tests { crawl.act(Action::Move(wall.x - crawl.player.x, wall.y - crawl.player.y)); assert_eq!(crawl.turns, 0); } + + #[test] + fn guns_kill_at_range_and_need_reloading() { + let mut crawl = arena(); + spawn(&mut crawl, "hollow crewman", Pos::new(7, 5)); + let magazine = crawl.gear.gun.gun.unwrap().magazine; + for _ in 0..magazine { + let aim = crawl.monsters.first().map_or(Pos::new(7, 5), |m| m.pos); + crawl.act(Action::Fire(aim)); + } + assert_eq!(crawl.gear.gun.loaded, 0); + assert!(!crawl.volley.is_empty() || crawl.monsters.is_empty()); + let turns = crawl.turns; + crawl.act(Action::Fire(Pos::new(7, 5))); + assert_eq!((crawl.turns, crawl.gear.gun.loaded), (turns + 1, magazine), "reloads"); + for _ in 0..40 { + let Some(aim) = crawl.monsters.first().map(|m| m.pos) else { break }; + crawl.act(Action::Fire(aim)); + } + assert!(crawl.monsters.is_empty(), "the crewman survived 40 rivets"); + assert!(crawl.known.contains(&("hollow crewman", Dmg::Pierce))); + assert!(crawl.log.iter().any(|m| m == "The hollow crewman resists pierce.")); + } + + #[test] + fn some_things_shrug_some_damage_off() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "choir-wraith", Pos::new(5, 5)); + crawl.monsters[i].stunned = 1000; + for _ in 0..4 { + crawl.act(Action::Fire(Pos::new(5, 5))); + } + assert_eq!(crawl.monsters[0].hp, 14, "rivets go through a wraith"); + assert!(crawl.log.iter().any(|m| m == "The choir-wraith is immune to pierce.")); + assert_eq!(crawl.knows(crawl.monsters[0].kind, Dmg::Pierce), Some(Affinity::Immune)); + assert_eq!(crawl.knows(crawl.monsters[0].kind, Dmg::Hymn), None); + } + + #[test] + fn piercing_rounds_go_through_and_ignore_armour() { + let mut crawl = arena(); + for x in [5, 6] { + let i = spawn(&mut crawl, "bulkhead sentry", Pos::new(x, 5)); + crawl.monsters[i].stunned = 1000; + } + let mut rail = sure(Dmg::Pierce, 3, 1); + rail.gun = Some(Gun { range: 10, spread: 0.0, pellets: 1, magazine: 9 }); + rail.loaded = 9; + wield(&mut crawl, rail); + crawl.act(Action::Fire(Pos::new(5, 5))); + let hp: Vec<i32> = crawl.monsters.iter().map(|m| m.hp).collect(); + assert_eq!(hp, [5, 5], "3 through armour 2, and on into the next"); + } + + #[test] + fn armour_takes_its_share() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "bulkhead sentry", Pos::new(3, 5)); + crawl.monsters[i].stunned = 1000; + wield(&mut crawl, sure(Dmg::Blunt, 4, 0)); + crawl.act(Action::Move(1, 0)); + assert_eq!(crawl.monsters[0].hp, 6, "4 less armour 2"); + } + + #[test] + fn cuts_bleed_and_bleeding_runs_its_course() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "hollow crewman", Pos::new(3, 5)); + crawl.monsters[i].stunned = 1000; + wield(&mut crawl, sure(Dmg::Cut, 1, 3)); + crawl.act(Action::Move(1, 0)); + // 1 weak is 2, then 3 bled at the end of the turn. + assert_eq!((crawl.monsters[0].hp, crawl.monsters[0].bleeding), (4, 2)); + crawl.act(Action::Wait); + crawl.act(Action::Wait); + crawl.act(Action::Wait); + assert_eq!((crawl.monsters[0].hp, crawl.monsters[0].bleeding), (1, 0)); + } + + #[test] + fn machines_dont_bleed() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "maintenance drone", Pos::new(3, 5)); + crawl.monsters[i].stunned = 1000; + wield(&mut crawl, sure(Dmg::Cut, 2, 3)); + crawl.act(Action::Move(1, 0)); + assert_eq!(crawl.monsters[0].bleeding, 0); + } + + #[test] + fn blows_knock_things_back_and_into_walls() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "maintenance drone", Pos::new(3, 5)); + crawl.monsters[i].stunned = 1000; + wield(&mut crawl, sure(Dmg::Blunt, 1, 100)); + crawl.act(Action::Move(1, 0)); + assert_eq!(crawl.monsters[0].pos, Pos::new(4, 5)); + + crawl.player = Pos::new(29, 5); + crawl.monsters[0].pos = Pos::new(30, 5); + let hp = crawl.monsters[0].hp; + crawl.act(Action::Move(1, 0)); + assert_eq!(crawl.monsters[0].pos, Pos::new(30, 5), "nowhere to go"); + assert!(crawl.monsters[0].hp < hp - 1, "it hit the wall too"); + assert!(crawl.log.iter().any(|m| m.contains("slams into the bulkhead"))); + } + + #[test] + fn shocks_stun() { + let mut crawl = arena(); + spawn(&mut crawl, "bilge-hound", Pos::new(3, 5)); + crawl.hp = 100; + crawl.max_hp = 100; + wield(&mut crawl, sure(Dmg::Shock, 1, 100)); + crawl.act(Action::Move(1, 0)); + assert!(!crawl.log.iter().any(|m| m.contains("hound savages") || m.contains("misses you"))); + assert!(crawl.log.iter().any(|m| m.contains("seizes up"))); + } + + #[test] + fn fire_burns_on() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "bilge-hound", Pos::new(3, 5)); + crawl.monsters[i].stunned = 1000; + wield(&mut crawl, sure(Dmg::Burn, 1, 2)); + crawl.act(Action::Move(1, 0)); + // 1 weak is 2, then 1–2 weak is 2–3 for the burning. + let hp = crawl.monsters[0].hp; + assert!((1..=2).contains(&hp), "hp {hp}"); + assert_eq!(crawl.monsters[0].burning, 1); + } + + #[test] + fn claws_make_you_bleed_and_medpatches_stop_it() { + let mut crawl = arena(); + spawn(&mut crawl, "hollow crewman", Pos::new(3, 5)); + crawl.hp = 1000; + crawl.max_hp = 1000; + for _ in 0..20 { + crawl.act(Action::Wait); + if crawl.bleeding > 0 { + break; + } + } + assert!(crawl.bleeding > 0, "twenty turns of claws and not a scratch"); + crawl.monsters.clear(); + crawl.gear.add(Stack::one(Item::Medpatch)); + crawl.act(Action::UseMedpatch); + assert_eq!(crawl.bleeding, 0); + } + + #[test] + fn sentries_shoot_from_where_they_stand() { + let mut crawl = arena(); + spawn(&mut crawl, "bulkhead sentry", Pos::new(8, 5)); + crawl.hp = 1000; + crawl.max_hp = 1000; + for _ in 0..10 { + crawl.act(Action::Wait); + } + assert_eq!(crawl.monsters[0].pos, Pos::new(8, 5)); + assert!(crawl.hp < 1000, "ten turns under fire and not a scratch"); + assert!(crawl.volley.iter().any(|t| t.cells.contains(&crawl.player))); + } + + #[test] + fn hounds_run_two_steps_a_turn() { + let mut crawl = arena(); + spawn(&mut crawl, "bilge-hound", Pos::new(12, 5)); + crawl.act(Action::Wait); + assert_eq!(crawl.monsters[0].pos.steps_to(crawl.player), 8); + } + + #[test] + fn the_odds_fall_off_with_distance() { + let mut pistol = sure(Dmg::Pierce, 1, 0); + pistol.gun = Some(Gun { range: 20, spread: 10.0, pellets: 1, magazine: 6 }); + let odds_at = |x: i32| { + let mut crawl = arena(); + wield(&mut crawl, pistol.clone()); + spawn(&mut crawl, "rust-mite", Pos::new(x, 5)); + assert!(crawl.odds(Pos::new(x, 6)).is_none(), "nothing there"); + crawl.odds(Pos::new(x, 5)).unwrap() + }; + let odds = [3, 6, 12, 20].map(odds_at); + assert_eq!(odds[0], 1.0, "point blank"); + assert!(odds.windows(2).all(|w| w[0] > w[1]), "{odds:?}"); + + // Something in the way takes the hit. + let mut crawl = arena(); + spawn(&mut crawl, "rust-mite", Pos::new(4, 5)); + spawn(&mut crawl, "rust-mite", Pos::new(6, 5)); + assert!(crawl.odds(Pos::new(6, 5)).unwrap() < 0.5); + } + + #[test] + fn you_carry_only_what_you_have_room_for() { + let mut crawl = arena(); + let mut rng = seed::rng(1); + for _ in 0..12 { + let mut w = arms::find(&mut rng, arms::Source::Armoury, false); + w.mass = 4000; + crawl.loot.push(Loot::new(Pos::new(3, 5), Item::Weapon(w))); + } + crawl.act(Action::Move(1, 0)); + let room = crawl.gear.capacity - Gear::default().carried(); + let fit = (room / 4000) as usize; + assert_eq!(crawl.loot.len(), 12 - fit); + assert!(crawl.gear.carried() <= crawl.gear.capacity); + assert!(crawl.log.iter().any(|m| m.contains("too much to carry"))); + crawl.act(Action::Take(0, 1)); + assert_eq!(crawl.loot.len(), 12 - fit, "still no room"); + assert!(crawl.log.last().unwrap().contains("no room")); + } + + #[test] + fn stacks_come_and_go_by_the_count() { + let mut crawl = arena(); + crawl.gear.add(Stack { item: Item::Medpatch, count: 5 }); + crawl.act(Action::Drop(Spot::Pack(0), 2)); + assert_eq!(crawl.gear.medpatches(), 3); + assert_eq!(crawl.loot[0].stack.count, 2); + crawl.act(Action::Take(0, 1)); + assert_eq!(crawl.gear.medpatches(), 4); + assert_eq!(crawl.loot[0].stack.count, 1); + assert_eq!(crawl.turns, 1, "dropping is free; picking up takes a turn"); + } + + #[test] + fn what_you_put_down_stays_down() { + let mut crawl = arena(); + wield(&mut crawl, sure(Dmg::Cut, 3, 0)); + crawl.gear.add(Stack::one(Item::Weapon(arms::cutting_torch()))); + crawl.act(Action::Drop(Spot::Melee, 1)); + assert_eq!(crawl.turns, 0, "dropping takes no time"); + assert_eq!(crawl.gear.melee.title, "cutting torch", "back to the torch"); + assert!(crawl.gear.pack.is_empty()); + crawl.act(Action::Move(1, 0)); + crawl.act(Action::Move(-1, 0)); + assert!(crawl.gear.pack.is_empty(), "walking over it leaves it"); + crawl.act(Action::PickUp); + assert_eq!(crawl.gear.pack.len(), 1); + + crawl.act(Action::Drop(Spot::Gun, 1)); + assert!(crawl.gear.gun.kit, "with nothing else to shoot with, you keep it"); + assert!(crawl.log.last().unwrap().contains("nothing to shoot with")); + } + + #[test] + fn readying_takes_a_turn_and_picks_the_right_hand() { + let mut crawl = arena(); + crawl.gear.add(Stack::one(Item::Weapon(sure(Dmg::Cut, 3, 0)))); + let mut gun = sure(Dmg::Pierce, 2, 0); + gun.gun = Some(Gun { range: 6, spread: 4.0, pellets: 1, magazine: 3 }); + crawl.gear.add(Stack::one(Item::Weapon(gun))); + crawl.act(Action::Ready(0)); + assert_eq!(crawl.gear.melee.title, "test weapon"); + // The torch went where the blade was; the gun is now second. + crawl.act(Action::Ready(0)); + assert!(crawl.gear.gun.is_gun() && !crawl.gear.gun.kit); + assert_eq!(crawl.turns, 2); + crawl.act(Action::Ready(9)); + assert_eq!(crawl.turns, 2, "nothing there"); + } + + /// A piece of armour on your body guarding `guards`, with `durability` + /// of 10. + fn plate(guards: &[(Dmg, i32)], durability: u32, stanches: bool) -> items::Armor { + items::Armor { + title: "test plate".into(), + description: String::new(), + slot: Slot::Body, + guards: guards.iter().copied().collect(), + stanches, + durability, + max_durability: 10, + condition: arms::Condition::Sound, + named: false, + pair: false, + mass: 1, + } + } + + /// What the armour you wear on your body has left. + fn left(crawl: &Crawl) -> u32 { + crawl.gear.worn[&Slot::Body].durability + } + + #[test] + fn armour_takes_its_share_of_blows_of_its_kind_and_wears() { + let mut crawl = arena(); + crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Shock, 100)], 10, false)); + spawn(&mut crawl, "maintenance drone", Pos::new(3, 5)); + for _ in 0..10 { + crawl.act(Action::Wait); + } + let blows = crawl.log.iter().filter(|l| l.contains("zaps you")).count() as i32; + let softened = crawl.log.iter().filter(|l| l.contains("zaps you (shock")).count() as u32; + assert!(blows > 0 && crawl.hp == crawl.max_hp - blows, "a point a blow: {:?}", crawl.log); + assert!(softened > 0, "{:?}", crawl.log); + assert_eq!(left(&crawl), 10 - softened, "a point of wear for each blow it softened"); + + // Against another kind of blow it does nothing, and doesn't wear. + let mut crawl = arena(); + crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Shock, 100)], 10, false)); + spawn(&mut crawl, "bilge-hound", Pos::new(3, 5)); + for _ in 0..4 { + crawl.act(Action::Wait); + } + assert!(crawl.log.iter().any(|l| l.contains("savages you ("))); + assert!(!crawl.log.iter().any(|l| l.contains("savages you (cut"))); + assert_eq!(left(&crawl), 10); + } + + #[test] + fn worn_out_armour_guards_nothing_but_stays_on() { + let mut crawl = arena(); + crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Shock, 100)], 1, false)); + spawn(&mut crawl, "maintenance drone", Pos::new(3, 5)); + for _ in 0..20 { + crawl.act(Action::Wait); + if left(&crawl) == 0 { + break; + } + } + assert!(crawl.log.iter().any(|l| l.contains("worn out")), "{:?}", crawl.log); + assert!(crawl.gear.worn.contains_key(&Slot::Body)); + assert_eq!(crawl.gear.guard(Dmg::Shock), 0); + } + + #[test] + fn welders_set_you_alight_from_a_few_cells_off() { + let mut crawl = arena(); + spawn(&mut crawl, "welder drone", Pos::new(5, 5)); + for _ in 0..20 { + crawl.act(Action::Wait); + if crawl.burning > 0 { + break; + } + } + assert_eq!(crawl.burning, 2, "{:?}", crawl.log); + assert!(crawl.log.iter().any(|l| l.starts_with("The welder drone torches you ("))); + assert!(crawl.log.iter().any(|l| l == "You catch alight.")); + assert_eq!(crawl.monsters[0].pos, Pos::new(5, 5), "it sprays from where it is"); + + // It burns on after, until it goes out or a medpatch smothers it. + crawl.monsters.clear(); + let hp = crawl.hp; + crawl.act(Action::Wait); + assert!(crawl.hp < hp && crawl.log.last().unwrap().starts_with("You burn (")); + assert_eq!(crawl.burning, 1); + crawl.gear.add(Stack::one(Item::Medpatch)); + crawl.act(Action::UseMedpatch); + assert_eq!(crawl.burning, 0); + assert!(crawl.log.iter().any(|l| l.contains("smothers the flames"))); + } + + #[test] + fn burn_guards_take_their_share_of_the_flames() { + let mut crawl = arena(); + crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Burn, 100)], 10, false)); + crawl.burning = 6; + crawl.burned_by = "welder drone"; + let hp = crawl.hp; + for _ in 0..6 { + crawl.act(Action::Wait); + } + assert_eq!(crawl.hp, hp - 6, "1 a turn: {:?}", crawl.log); + assert!(crawl.log.last().unwrap().ends_with("The flames go out.")); + let softened = crawl.log.iter().filter(|l| l.starts_with("You burn (burn 2")).count(); + assert_eq!(left(&crawl), 10 - softened as u32, "worn by every tick it softened"); + } + + #[test] + fn loaders_lumber_and_knock_you_about() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "cargo loader", Pos::new(8, 5)); + for _ in 0..4 { + crawl.act(Action::Wait); + } + assert_eq!(crawl.monsters[i].pos.x, 6, "a step every other turn"); + + // Beside you, it hits every turn, and knocks you back to the wall. + crawl.monsters[i].pos = Pos::new(3, 5); + (crawl.hp, crawl.max_hp) = (1000, 1000); + let (mut knocked, mut reeled) = (false, false); + for _ in 0..40 { + let turns = crawl.turns; + crawl.act(Action::Wait); + knocked |= crawl.log.iter().any(|l| l == "You're knocked back."); + if crawl.log.iter().any(|l| l.contains("lose a turn")) { + reeled = true; + assert!(crawl.log.iter().any(|l| l == "You're knocked into the bulkhead.")); + assert_eq!(crawl.turns, turns + 2, "you lose a turn while it has another"); + break; + } + } + assert!(knocked && reeled, "{:?}", crawl.log); + assert_eq!(crawl.player, Pos::new(1, 5), "up against the hull"); + assert!(crawl.log.iter().any(|l| l.starts_with("The cargo loader slams you ("))); + } + + #[test] + fn being_alight_is_saved() { + let mut crawl = Crawl::new(5); + (crawl.burning, crawl.burned_by) = (2, "welder drone"); + let back = Crawl::resume(&crawl.saved()).unwrap(); + assert_eq!((back.burning, back.burned_by), (2, "welder drone")); + let mut saved = crawl.saved(); + saved.burned_by = "something long gone".into(); + assert_eq!(Crawl::resume(&saved).unwrap().burning, 0); + } + + #[test] + fn armour_that_stanches_keeps_cuts_from_bleeding() { + let mut crawl = arena(); + crawl.gear.worn.insert(Slot::Body, plate(&[], 10, true)); + spawn(&mut crawl, "bilge-hound", Pos::new(3, 5)); + for _ in 0..6 { + crawl.act(Action::Wait); + } + assert!(crawl.hp < crawl.max_hp, "the bites still land"); + assert!(crawl.log.iter().any(|l| l.contains("keeps the wound closed"))); + assert_eq!(crawl.bleeding, 0); + assert_eq!(left(&crawl), 10, "it softened no blow, so it didn't wear"); + } } diff --git a/src/dungeon/mod.rs b/src/dungeon/mod.rs index d3227a5..baeb0fe 100644 --- a/src/dungeon/mod.rs +++ b/src/dungeon/mod.rs @@ -1,11 +1,15 @@ //! Turn-based dungeons: the map, field of view, derelict-ship generation, //! and the rules of a crawl. Drawing lives in `scenes::derelict`. +pub mod armour; +pub mod arms; pub mod crawl; pub mod fov; pub mod ship; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Pos { pub x: i32, pub y: i32, @@ -111,6 +115,14 @@ impl Map { self.tiles[i] = tile; } + /// Which room `p` is in, to tell when you've gone into another. + pub fn room_index(&self, p: Pos) -> Option<u16> { + if !self.contains(p) { + return None; + } + self.room_of[self.index(p)] + } + pub fn room_at(&self, p: Pos) -> Option<&Room> { if !self.contains(p) { return None; diff --git a/src/dungeon/ship.rs b/src/dungeon/ship.rs index dc9b0b8..d845376 100644 --- a/src/dungeon/ship.rs +++ b/src/dungeon/ship.rs @@ -25,8 +25,11 @@ use rand::RngExt; use rand::seq::IndexedRandom; -use super::crawl::{Item, Loot, MONSTERS, Monster}; +use super::armour; +use super::arms::{self, Source}; +use super::crawl::{Loot, MONSTERS, Monster}; use super::{Map, Pos, Tile}; +use crate::items::{Item, Salvage}; use crate::names::{self, Culture}; use crate::seed::{self, Rng}; @@ -52,42 +55,65 @@ struct RoomKind { salvage: (u32, u32), /// Chance of a medpatch. medpatch: f64, + /// Chance of a weapon; at 1, there are one or two, and in better shape. + arms: f64, + /// What sort of weapon it'd be, or armour. + source: Source, + /// Chance of a piece of armour; at 1, one or two, in better shape. + armour: f64, } -const ENGINE_ROOM: RoomKind = - RoomKind { name: "the engine room", weight: 0, artifact: 0.05, salvage: (2, 3), medpatch: 0.1 }; -const BRIDGE: RoomKind = - RoomKind { name: "the bridge", weight: 0, artifact: 0.6, salvage: (0, 2), medpatch: 0.1 }; +const ENGINE_ROOM: RoomKind = RoomKind { + name: "the engine room", + weight: 0, + artifact: 0.05, + salvage: (2, 3), + medpatch: 0.1, + arms: 0.3, + source: Source::Tools, + armour: 0.15, +}; +const BRIDGE: RoomKind = RoomKind { + name: "the bridge", + weight: 0, + artifact: 0.6, + salvage: (0, 2), + medpatch: 0.1, + arms: 0.3, + source: Source::Armoury, + armour: 0.2, +}; #[rustfmt::skip] const ROOMS: &[RoomKind] = &[ - RoomKind { name: "a cargo hold", weight: 4, artifact: 0.05, salvage: (2, 4), medpatch: 0.05 }, - RoomKind { name: "the crew berths", weight: 3, artifact: 0.1, salvage: (0, 1), medpatch: 0.25 }, - RoomKind { name: "the galley", weight: 2, artifact: 0.02, salvage: (0, 2), medpatch: 0.1 }, - RoomKind { name: "the med-bay", weight: 1, artifact: 0.05, salvage: (0, 1), medpatch: 1.0 }, - RoomKind { name: "a machine shop", weight: 2, artifact: 0.05, salvage: (1, 3), medpatch: 0.0 }, - RoomKind { name: "the armoury", weight: 1, artifact: 0.15, salvage: (1, 2), medpatch: 0.3 }, - RoomKind { name: "a cryo-vault", weight: 1, artifact: 0.3, salvage: (0, 1), medpatch: 0.2 }, - RoomKind { name: "the chapel", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0 }, - RoomKind { name: "the reliquary", weight: 1, artifact: 0.9, salvage: (0, 0), medpatch: 0.0 }, - RoomKind { name: "the archive", weight: 1, artifact: 0.4, salvage: (0, 1), medpatch: 0.0 }, - RoomKind { name: "the navigator's cell", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0 }, - RoomKind { name: "an observation blister", weight: 1, artifact: 0.1, salvage: (0, 1), medpatch: 0.0 }, + RoomKind { name: "a cargo hold", weight: 4, artifact: 0.05, salvage: (2, 4), medpatch: 0.05, arms: 0.2, source: Source::Tools, armour: 0.1 }, + RoomKind { name: "the crew berths", weight: 3, artifact: 0.1, salvage: (0, 1), medpatch: 0.25, arms: 0.3, source: Source::Crew, armour: 0.4 }, + RoomKind { name: "the galley", weight: 2, artifact: 0.02, salvage: (0, 2), medpatch: 0.1, arms: 0.2, source: Source::Crew, armour: 0.05 }, + RoomKind { name: "the med-bay", weight: 1, artifact: 0.05, salvage: (0, 1), medpatch: 1.0, arms: 0.1, source: Source::Tools, armour: 0.05 }, + RoomKind { name: "a machine shop", weight: 2, artifact: 0.05, salvage: (1, 3), medpatch: 0.0, arms: 0.5, source: Source::Tools, armour: 0.2 }, + RoomKind { name: "the armoury", weight: 1, artifact: 0.15, salvage: (1, 2), medpatch: 0.3, arms: 1.0, source: Source::Armoury, armour: 1.0 }, + RoomKind { name: "a cryo-vault", weight: 1, artifact: 0.3, salvage: (0, 1), medpatch: 0.2, arms: 0.15, source: Source::Crew, armour: 0.15 }, + RoomKind { name: "the chapel", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0, arms: 0.4, source: Source::Rites, armour: 0.35 }, + RoomKind { name: "the reliquary", weight: 1, artifact: 0.9, salvage: (0, 0), medpatch: 0.0, arms: 0.5, source: Source::Rites, armour: 0.3 }, + RoomKind { name: "the archive", weight: 1, artifact: 0.4, salvage: (0, 1), medpatch: 0.0, arms: 0.1, source: Source::Rites, armour: 0.05 }, + RoomKind { name: "the navigator's cell", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0, arms: 0.2, source: Source::Crew, armour: 0.15 }, + RoomKind { name: "an observation blister", weight: 1, artifact: 0.1, salvage: (0, 1), medpatch: 0.0, arms: 0.15, source: Source::Armoury, armour: 0.1 }, ]; -/// Salvage and its base value; each find varies by ±30%. -const SALVAGE: &[(&str, u32)] = &[ - ("a coil of superconductor", 30), - ("a cracked reactor baffle", 45), - ("a crate of ration bricks", 10), - ("an intact navigation core", 90), - ("a spool of wiring", 15), - ("a stack of hull plates", 25), - ("a sealed canister of coolant", 20), - ("a working servo", 35), - ("a box of votive candles", 8), - ("a cantor's data-slate", 40), - ("a half-charged fuel cell", 50), +/// Salvage: what it's called, its base value, and what it weighs in grams. +/// Each find's value varies by ±30%. +const SALVAGE: &[(&str, u32, u32)] = &[ + ("coil of superconductor", 30, 6000), + ("cracked reactor baffle", 45, 9000), + ("crate of ration bricks", 10, 8000), + ("intact navigation core", 90, 5000), + ("spool of wiring", 15, 3000), + ("stack of hull plates", 25, 12000), + ("sealed canister of coolant", 20, 7000), + ("working servo", 35, 4000), + ("box of votive candles", 8, 2000), + ("cantor's data-slate", 40, 500), + ("half-charged fuel cell", 50, 6000), ]; /// A room as generated, before its contents are placed. @@ -146,13 +172,14 @@ pub fn generate(seed: u64) -> Derelict { let kind = plan.kind; if rng.random_bool(kind.artifact) { let pos = *cells.choose(&mut rng).unwrap(); - loot.push(Loot { pos, item: Item::Artifact(names::artifact(&mut rng)) }); + loot.push(Loot::new(pos, Item::Artifact(names::artifact(&mut rng)))); } for _ in 0..rng.random_range(kind.salvage.0..=kind.salvage.1) { let pos = *cells.choose(&mut rng).unwrap(); - let &(name, base) = SALVAGE.choose(&mut rng).unwrap(); + let &(title, base, mass) = SALVAGE.choose(&mut rng).unwrap(); let value = (f64::from(base) * rng.random_range(0.7..1.3)).round() as u32; - loot.push(Loot { pos, item: Item::Salvage { name, value } }); + let salvage = Salvage { title: title.into(), value, mass }; + loot.push(Loot::new(pos, Item::Salvage(salvage))); } let medpatches = if kind.medpatch >= 1.0 { rng.random_range(1..=2) @@ -160,18 +187,18 @@ pub fn generate(seed: u64) -> Derelict { u32::from(rng.random_bool(kind.medpatch)) }; for _ in 0..medpatches { - loot.push(Loot { pos: *cells.choose(&mut rng).unwrap(), item: Item::Medpatch }); + loot.push(Loot::new(*cells.choose(&mut rng).unwrap(), Item::Medpatch)); } } // Every derelict is worth boarding: if no room rolled an artifact, put // one as far from the airlock as the ship goes. - if !loot.iter().any(|l| matches!(l.item, Item::Artifact(_))) { + if !loot.iter().any(|l| matches!(l.stack.item, Item::Artifact(_))) { let farthest = map .positions() .filter(|&p| free(&map, p)) .max_by_key(|&p| (dist[map.index(p)], p.x, p.y)) .expect("a derelict always has floor"); - loot.push(Loot { pos: farthest, item: Item::Artifact(names::artifact(&mut rng)) }); + loot.push(Loot::new(farthest, Item::Artifact(names::artifact(&mut rng)))); } let lairs: Vec<Pos> = map @@ -188,7 +215,51 @@ pub fn generate(seed: u64) -> Derelict { if kind.unique && monsters.iter().any(|m| m.kind.name == kind.name) { continue; } - monsters.push(Monster { kind, pos, hp: kind.hp, awake: false }); + monsters.push(Monster::new(kind, pos)); + } + + // Weapons roll dice of their own, so arming the derelicts left every + // ship's rooms, loot and lairs where they were. + let mut rng = seed::rng(seed::derive(seed, "arms", 0)); + let mut armed = false; + for plan in &plans { + let cells: Vec<Pos> = plan.cells.iter().copied().filter(|&p| free(&map, p)).collect(); + if cells.is_empty() { + continue; + } + let kind = plan.kind; + let stocked = kind.arms >= 1.0; + let n = + if stocked { rng.random_range(1..=2) } else { u32::from(rng.random_bool(kind.arms)) }; + for _ in 0..n { + let pos = *cells.choose(&mut rng).unwrap(); + let weapon = arms::find(&mut rng, kind.source, stocked); + loot.push(Loot::new(pos, Item::Weapon(weapon))); + armed = true; + } + } + // And there's always something to fight with aboard. + if !armed && let Some(plan) = plans.iter().find(|p| p.cells.iter().any(|&c| free(&map, c))) { + let cells: Vec<Pos> = plan.cells.iter().copied().filter(|&p| free(&map, p)).collect(); + let pos = *cells.choose(&mut rng).unwrap(); + loot.push(Loot::new(pos, Item::Weapon(arms::find(&mut rng, plan.kind.source, false)))); + } + + // Armour too: armouries and berths mostly, warded pieces in chapels. + let mut rng = seed::rng(seed::derive(seed, "armour", 0)); + for plan in &plans { + let cells: Vec<Pos> = plan.cells.iter().copied().filter(|&p| free(&map, p)).collect(); + if cells.is_empty() { + continue; + } + let kind = plan.kind; + let stocked = kind.armour >= 1.0; + let n = + if stocked { rng.random_range(1..=2) } else { u32::from(rng.random_bool(kind.armour)) }; + for _ in 0..n { + let pos = *cells.choose(&mut rng).unwrap(); + loot.push(Loot::new(pos, Item::Armor(armour::find(&mut rng, kind.source, stocked)))); + } } Derelict { name, description, map, start, monsters, loot } @@ -436,13 +507,27 @@ mod tests { assert!(map.tile(l.pos).walkable(), "seed {seed}: loot in a wall"); assert_ne!(l.pos, d.start); } - assert!(d.loot.iter().any(|l| matches!(l.item, Item::Artifact(_)))); + assert!(d.loot.iter().any(|l| matches!(l.stack.item, Item::Artifact(_)))); + assert!( + d.loot.iter().any(|l| matches!(l.stack.item, Item::Weapon(_))), + "seed {seed}: unarmed" + ); assert!(map.rooms.len() >= 4, "seed {seed}: only {} rooms", map.rooms.len()); assert!(d.monsters.iter().filter(|m| m.kind.unique).count() <= 1); } } #[test] + fn armour_turns_up_in_most_wrecks() { + let armoured = (0..200) + .filter(|&seed| { + generate(seed).loot.iter().any(|l| matches!(l.stack.item, Item::Armor(_))) + }) + .count(); + assert!((80..195).contains(&armoured), "armour aboard {armoured} of 200 wrecks"); + } + + #[test] fn same_seed_same_ship() { let (a, b) = (generate(99), generate(99)); assert_eq!(a.name, b.name); diff --git a/src/items.rs b/src/items.rs new file mode 100644 index 0000000..6c38ecd --- /dev/null +++ b/src/items.rs @@ -0,0 +1,728 @@ +//! Things you can carry: weapons, armour, food, medpatches, artifacts and +//! salvage. What each weighs, how they stack, where they go on you, and the +//! piles they lie in: your pack, the ship's lockers, the floor. +//! +//! Weights are in grams. Everything on you counts, worn, ready and packed, +//! up to `Gear::capacity`; shipyards sell bigger packs. Your own cutting +//! torch and rivet gun are always on you: they can't be dropped, stored or +//! lost. +//! +//! Armour goes on your head, body, hands and feet, each piece guarding +//! against its own kinds of blow (see `armour`). Food is ready for its +//! first items: eating works, but nothing is hungry yet. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +pub use crate::dungeon::armour::Armor; +use crate::dungeon::arms::{self, Weapon}; +use crate::names::{self, Artifact}; +use crate::seed; + +/// What you can carry to begin with, in grams. +pub const BASE_CAPACITY: u32 = 25_000; +/// What the ship's lockers hold, in grams. +pub const LOCKER_CAPACITY: u32 = 400_000; +const MEDPATCH_MASS: u32 = 150; + +/// What a bigger pack is called, how much more it lets you carry and what a +/// shipyard asks for it, in the order you can buy them. +pub const PACKS: [(&str, u32, u32); 2] = [("frame pack", 10_000, 300), ("hauler rig", 10_000, 650)]; + +/// The next bigger pack you could buy, if there is one. +pub fn next_pack(capacity: u32) -> Option<(&'static str, u32, u32)> { + let mut have = BASE_CAPACITY; + for pack in PACKS { + if capacity < have + pack.1 { + return Some(pack); + } + have += pack.1; + } + None +} + +/// The most you can carry with every pack there is. +pub fn max_capacity() -> u32 { + BASE_CAPACITY + PACKS.iter().map(|p| p.1).sum::<u32>() +} + +/// Grams as kilograms, to a decimal place: "2.1". +pub fn kg(grams: u32) -> String { + format!("{:.1}", f64::from(grams) / 1000.0) +} + +/// What sort of thing something is, in the order the inventory lists them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Category { + Weapon, + Armor, + Food, + Medical, + Artifact, + Salvage, +} + +impl Category { + pub const ALL: [Category; 6] = [ + Category::Weapon, + Category::Armor, + Category::Food, + Category::Medical, + Category::Artifact, + Category::Salvage, + ]; + + pub fn heading(self) -> &'static str { + match self { + Category::Weapon => "WEAPONS", + Category::Armor => "ARMOR", + Category::Food => "FOOD", + Category::Medical => "MEDICAL", + Category::Artifact => "ARTIFACTS", + Category::Salvage => "SALVAGE", + } + } +} + +/// Where on you a piece of armour goes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Slot { + Head, + Body, + Hands, + Feet, +} + +impl Slot { + pub const ALL: [Slot; 4] = [Slot::Head, Slot::Body, Slot::Hands, Slot::Feet]; + + pub fn name(self) -> &'static str { + match self { + Slot::Head => "head", + Slot::Body => "body", + Slot::Hands => "hands", + Slot::Feet => "feet", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Food { + /// "ration bar", without an article. + pub title: String, + pub description: String, + /// How much it fills you. Nothing gets hungry yet. + pub nourishment: u32, + pub mass: u32, +} + +/// Something worth money and nothing else: sell it to a broker or a station. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Salvage { + /// "coil of superconductor", without an article. + pub title: String, + /// What it's worth, before what a buyer makes of it. + pub value: u32, + pub mass: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Item { + Weapon(Weapon), + Armor(Armor), + Food(Food), + Medpatch, + Artifact(Artifact), + Salvage(Salvage), +} + +impl Item { + pub fn category(&self) -> Category { + match self { + Item::Weapon(_) => Category::Weapon, + Item::Armor(_) => Category::Armor, + Item::Food(_) => Category::Food, + Item::Medpatch => Category::Medical, + Item::Artifact(_) => Category::Artifact, + Item::Salvage(_) => Category::Salvage, + } + } + + /// What one weighs, in grams. + pub fn mass(&self) -> u32 { + match self { + Item::Weapon(w) => w.mass, + Item::Armor(a) => a.mass, + Item::Food(f) => f.mass, + Item::Medpatch => MEDPATCH_MASS, + Item::Artifact(a) => artifact_mass(a), + Item::Salvage(s) => s.mass, + } + } + + /// What it's called in a list, without an article where it has one: + /// "serrated cleaver", "humming ochre lattice", "Khaseem's Mercy". + pub fn label(&self) -> String { + match self { + Item::Weapon(w) => w.title.clone(), + Item::Armor(a) => a.title.clone(), + Item::Food(f) => f.title.clone(), + Item::Medpatch => "medpatch".into(), + Item::Artifact(a) => { + let name = a.name(); + name.strip_prefix("a ").or(name.strip_prefix("an ")).unwrap_or(name).to_string() + } + Item::Salvage(s) => s.title.clone(), + } + } + + /// One of it, in a sentence: "a serrated cleaver", "your cutting + /// torch", "the Lacquered Orrery of Teshemond". + pub fn name(&self) -> String { + match self { + Item::Weapon(w) => w.name(), + Item::Armor(a) => a.name(), + Item::Artifact(a) => a.name().to_string(), + _ => names::a(&self.label()), + } + } + + /// `n` of it, in a sentence: "a medpatch", "3 medpatches". + pub fn counted(&self, n: u32) -> String { + if n == 1 { self.name() } else { format!("{n} {}", plural(&self.label())) } + } + + /// Whether more than one of it pile up as one. + pub fn stacks(&self) -> bool { + matches!(self, Item::Food(_) | Item::Medpatch | Item::Salvage(_)) + } + + /// Your own torch or rivet gun: never dropped, stored or lost. + pub fn is_kit(&self) -> bool { + matches!(self, Item::Weapon(w) if w.kit) + } +} + +/// What an artifact weighs, fixed by its true name: from a carved token to +/// a reliquary you need both arms for. +fn artifact_mass(a: &Artifact) -> u32 { + let k = seed::derive(0, &a.true_name, 1) % 1000; + 300 + (k as u32 * k as u32) / 220 +} + +/// "medpatches", "coils of superconductor". +pub fn plural(label: &str) -> String { + if let Some((head, tail)) = label.split_once(" of ") { + return format!("{} of {tail}", plural(head)); + } + let consonant_y = label.ends_with('y') + && !label.ends_with("ay") + && !label.ends_with("ey") + && !label.ends_with("oy") + && !label.ends_with("uy"); + if consonant_y { + format!("{}ies", &label[..label.len() - 1]) + } else if ["s", "x", "z", "ch", "sh"].iter().any(|end| label.ends_with(end)) { + format!("{label}es") + } else { + format!("{label}s") + } +} + +/// Some number of one thing. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Stack { + pub item: Item, + pub count: u32, +} + +impl Stack { + pub fn one(item: Item) -> Stack { + Stack { item, count: 1 } + } + + pub fn mass(&self) -> u32 { + self.item.mass().saturating_mul(self.count) + } + + /// "3 medpatches", "a serrated cleaver". + pub fn name(&self) -> String { + self.item.counted(self.count) + } +} + +/// Adds `stack` to a pile, onto a stack of the same thing if there is one. +pub fn stow(pile: &mut Vec<Stack>, stack: Stack) { + if stack.count == 0 { + return; + } + if stack.item.stacks() + && let Some(same) = pile.iter_mut().find(|s| s.item == stack.item) + { + same.count += stack.count; + return; + } + pile.push(stack); +} + +/// Takes up to `count` of the stack at `i` out of a pile. +pub fn take(pile: &mut Vec<Stack>, i: usize, count: u32) -> Stack { + let count = count.min(pile[i].count); + if count == pile[i].count { + pile.remove(i) + } else { + pile[i].count -= count; + Stack { item: pile[i].item.clone(), count } + } +} + +pub fn pile_mass(pile: &[Stack]) -> u32 { + pile.iter().map(Stack::mass).sum() +} + +/// How many of `item` fit in `room` grams. +pub fn how_many_fit(item: &Item, room: u32) -> u32 { + room.checked_div(item.mass()).unwrap_or(u32::MAX) +} + +/// Where something is on you. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Spot { + Worn(Slot), + /// The weapon you hit things with. + Melee, + /// The gun you fire. + Gun, + /// A stack in your pack, by index. + Pack(usize), +} + +/// Everything you carry: what you wear, your two ready weapons, and your +/// pack. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Gear { + pub melee: Weapon, + pub gun: Weapon, + #[serde(default)] + pub worn: BTreeMap<Slot, Armor>, + #[serde(default)] + pub pack: Vec<Stack>, + /// How much you can carry, all told, in grams. + pub capacity: u32, +} + +impl Default for Gear { + fn default() -> Gear { + Gear { + melee: arms::cutting_torch(), + gun: arms::rivet_gun(), + worn: BTreeMap::new(), + pack: Vec::new(), + capacity: BASE_CAPACITY, + } + } +} + +impl Gear { + /// What everything on you weighs, in grams. + pub fn carried(&self) -> u32 { + let worn: u32 = self.worn.values().map(|a| a.mass).sum(); + self.melee.mass + self.gun.mass + worn + pile_mass(&self.pack) + } + + /// How many more grams you could carry. + pub fn room(&self) -> u32 { + self.capacity.saturating_sub(self.carried()) + } + + /// How many of `item` you could take on. + pub fn fits(&self, item: &Item) -> u32 { + how_many_fit(item, self.room()) + } + + /// What's at `spot`, as a stack, for showing. + pub fn get(&self, spot: Spot) -> Option<Stack> { + match spot { + Spot::Worn(slot) => self.worn.get(&slot).map(|a| Stack::one(Item::Armor(a.clone()))), + Spot::Melee => Some(Stack::one(Item::Weapon(self.melee.clone()))), + Spot::Gun => Some(Stack::one(Item::Weapon(self.gun.clone()))), + Spot::Pack(i) => self.pack.get(i).cloned(), + } + } + + /// Puts `stack` in your pack, whatever it weighs. + pub fn add(&mut self, stack: Stack) { + stow(&mut self.pack, stack); + } + + /// Every weapon on you, ready or packed. + #[cfg(test)] + pub fn weapons(&self) -> impl Iterator<Item = &Weapon> { + let packed = self.pack.iter().filter_map(|s| match &s.item { + Item::Weapon(w) => Some(w), + _ => None, + }); + [&self.melee, &self.gun].into_iter().chain(packed) + } + + pub fn medpatches(&self) -> u32 { + self.pack.iter().filter(|s| s.item == Item::Medpatch).map(|s| s.count).sum() + } + + /// Takes a medpatch out of your pack to use, if you have one. + pub fn take_medpatch(&mut self) -> bool { + let Some(i) = self.pack.iter().position(|s| s.item == Item::Medpatch) else { + return false; + }; + take(&mut self.pack, i, 1); + true + } + + /// What everything you wear takes off a blow of `dmg`, as it is now. + #[cfg(test)] + pub fn guard(&self, dmg: arms::Dmg) -> i32 { + self.worn.values().map(|a| a.guard(dmg)).sum() + } + + /// Every piece of armour on you, worn or packed. + pub fn armour_mut(&mut self) -> impl Iterator<Item = &mut Armor> { + let packed = self.pack.iter_mut().filter_map(|s| match &mut s.item { + Item::Armor(a) => Some(a), + _ => None, + }); + self.worn.values_mut().chain(packed) + } + + /// Readies the weapon at `i` in your pack: a gun to fire, anything + /// else to hit things with. What was ready goes in its place. + pub fn ready(&mut self, i: usize) -> Result<String, String> { + let Some(Stack { item: Item::Weapon(w), .. }) = self.pack.get(i) else { + return Err("That's not something to fight with.".into()); + }; + let gun = w.is_gun(); + let Item::Weapon(w) = take(&mut self.pack, i, 1).item else { unreachable!() }; + let yours = w.yours(); + let hand = if gun { &mut self.gun } else { &mut self.melee }; + let old = std::mem::replace(hand, w); + self.add(Stack::one(Item::Weapon(old))); + Ok(format!("You ready {yours}.")) + } + + /// Puts on the armour at `i` in your pack, taking off whatever was there. + pub fn wear(&mut self, i: usize) -> Result<String, String> { + let Some(Stack { item: Item::Armor(_), .. }) = self.pack.get(i) else { + return Err("You can't wear that.".into()); + }; + let Item::Armor(a) = take(&mut self.pack, i, 1).item else { unreachable!() }; + let what = format!("You put on {}.", a.name()); + if let Some(old) = self.worn.insert(a.slot, a) { + self.add(Stack::one(Item::Armor(old))); + } + Ok(what) + } + + /// Takes off what you wear on `slot`, into your pack. + pub fn take_off(&mut self, slot: Slot) -> Result<String, String> { + let Some(a) = self.worn.remove(&slot) else { + return Err(format!("You've nothing on your {}.", slot.name())); + }; + let what = format!("You take off {}.", a.yours()); + self.add(Stack::one(Item::Armor(a))); + Ok(what) + } + + /// Eats one of the food at `i` in your pack. + pub fn eat(&mut self, i: usize) -> Result<String, String> { + let Some(Stack { item: Item::Food(f), .. }) = self.pack.get(i) else { + return Err("You can't eat that.".into()); + }; + let what = format!("You eat {}.", names::a(&f.title)); + take(&mut self.pack, i, 1); + Ok(what) + } + + /// Where in your pack the weapon is that would take the place of the + /// gun (or the one to hit with) if you put that down: your own, if it's + /// there, or else the first. + pub fn spare(&self, gun: bool) -> Option<usize> { + let fits = |s: &Stack, own: bool| matches!(&s.item, Item::Weapon(w) if w.is_gun() == gun && (w.kit || !own)); + let own = self.pack.iter().position(|s| fits(s, true)); + own.or_else(|| self.pack.iter().position(|s| fits(s, false))) + } + + /// Takes up to `count` of what's at `spot` off you, to drop or store. + /// Put down a weapon you had ready and another from your pack comes out + /// in its place; with nothing else to fight with, you keep it. + pub fn remove(&mut self, spot: Spot, count: u32) -> Result<Stack, String> { + match spot { + Spot::Worn(slot) => match self.worn.remove(&slot) { + Some(a) => Ok(Stack::one(Item::Armor(a))), + None => Err(format!("You've nothing on your {}.", slot.name())), + }, + Spot::Melee | Spot::Gun => { + let gun = spot == Spot::Gun; + let Some(i) = self.spare(gun) else { + let what = if gun { "shoot with" } else { "hit things with" }; + return Err(format!( + "You'd have nothing to {what}. Ready something else first." + )); + }; + let Item::Weapon(spare) = take(&mut self.pack, i, 1).item else { unreachable!() }; + let hand = if gun { &mut self.gun } else { &mut self.melee }; + Ok(Stack::one(Item::Weapon(std::mem::replace(hand, spare)))) + } + Spot::Pack(i) => match self.pack.get(i) { + None => Err("There's nothing there.".into()), + Some(_) => Ok(take(&mut self.pack, i, count.max(1))), + }, + } + } + + pub fn reload_all(&mut self) { + self.melee.reload(); + self.gun.reload(); + for s in &mut self.pack { + if let Item::Weapon(w) = &mut s.item { + w.reload(); + } + } + } + + /// What's left when everything you carried stays aboard a wreck: your + /// own torch and rivet gun, if you had them on you. A hand left with + /// neither gets yours from `elsewhere` (the lockers), or failing that a + /// spare from the ship's stores: you never go about with nothing to + /// fight with. + pub fn stripped(&self, mut elsewhere: impl FnMut(bool) -> Option<Weapon>) -> Gear { + let packed = self.pack.iter().filter_map(|s| match &s.item { + Item::Weapon(w) => Some(w.clone()), + _ => None, + }); + let mut own: Vec<Weapon> = [self.melee.clone(), self.gun.clone()] + .into_iter() + .chain(packed) + .filter(|w| w.kit) + .collect(); + let mut hand = |gun: bool| match own.iter().position(|w| w.is_gun() == gun) { + Some(i) => own.remove(i), + None => elsewhere(gun) + .unwrap_or_else(|| if gun { arms::rivet_gun() } else { arms::cutting_torch() }), + }; + let (melee, gun) = (hand(false), hand(true)); + let mut gear = Gear { melee, gun, capacity: self.capacity, ..Gear::default() }; + for w in own { + gear.add(Stack::one(Item::Weapon(w))); + } + gear + } + + /// Puts right whatever a save file got wrong: the right sort of weapon + /// ready in each hand, sensible numbers, nothing empty, no more packs + /// than there are. + pub fn mend(&mut self) { + self.capacity = self.capacity.clamp(BASE_CAPACITY, max_capacity()); + self.pack.retain(|s| s.count > 0); + if self.melee.is_gun() && !self.gun.is_gun() { + std::mem::swap(&mut self.melee, &mut self.gun); + } + for gun in [false, true] { + let hand = if gun { &self.gun } else { &self.melee }; + if hand.is_gun() == gun { + continue; + } + let right = match self.spare(gun) { + Some(i) => match take(&mut self.pack, i, 1).item { + Item::Weapon(w) => w, + _ => unreachable!(), + }, + None if gun => arms::rivet_gun(), + None => arms::cutting_torch(), + }; + let hand = if gun { &mut self.gun } else { &mut self.melee }; + let wrong = std::mem::replace(hand, right); + self.add(Stack::one(Item::Weapon(wrong))); + } + for s in &mut self.pack { + if !s.item.stacks() { + s.count = 1; + } + if let Item::Weapon(w) = &mut s.item { + w.mend(); + } + } + self.melee.mend(); + self.gun.mend(); + for (slot, a) in &mut self.worn { + a.slot = *slot; + } + for a in self.armour_mut() { + a.mend(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dungeon::arms::{Dmg, Source}; + + fn found(gun: bool) -> Weapon { + let mut rng = seed::rng(3); + (0..) + .map(|_| arms::find(&mut rng, Source::Armoury, false)) + .find(|w| w.is_gun() == gun) + .unwrap() + } + + fn helmet() -> Armor { + Armor { + title: "patched vac-helmet".into(), + description: "a patched vac-helmet".into(), + slot: Slot::Head, + guards: [(Dmg::Pierce, 1), (Dmg::Blunt, 1)].into_iter().collect(), + stanches: false, + durability: 20, + max_durability: 20, + condition: arms::Condition::Worn, + named: false, + pair: false, + mass: 1800, + } + } + + #[test] + fn like_things_stack_and_unlike_ones_dont() { + let mut pile = Vec::new(); + stow(&mut pile, Stack::one(Item::Medpatch)); + stow(&mut pile, Stack { item: Item::Medpatch, count: 2 }); + stow(&mut pile, Stack::one(Item::Weapon(found(false)))); + stow(&mut pile, Stack::one(Item::Weapon(found(false)))); + assert_eq!(pile.len(), 3); + assert_eq!(pile[0].count, 3); + assert_eq!(take(&mut pile, 0, 2).count, 2); + assert_eq!(pile[0].count, 1); + assert_eq!(take(&mut pile, 0, 5).count, 1, "no more than there is"); + assert_eq!(pile.len(), 2); + } + + #[test] + fn names_count_and_pluralise() { + assert_eq!(Item::Medpatch.counted(1), "a medpatch"); + assert_eq!(Item::Medpatch.counted(3), "3 medpatches"); + assert_eq!(plural("coil of superconductor"), "coils of superconductor"); + assert_eq!(plural("box of votive candles"), "boxes of votive candles"); + assert_eq!(plural("battery"), "batteries"); + } + + #[test] + fn readying_swaps_with_what_was_ready() { + let mut gear = Gear::default(); + let blade = found(false); + let carried = gear.carried(); + gear.add(Stack::one(Item::Weapon(blade.clone()))); + assert_eq!(gear.carried(), carried + blade.mass); + gear.ready(0).unwrap(); + assert_eq!(gear.melee, blade); + assert!(matches!(&gear.pack[0].item, Item::Weapon(w) if w.kit && !w.is_gun())); + // Your own torch can go too, once it's not in your hand. + let torch = gear.remove(Spot::Pack(0), 1).unwrap(); + assert!(gear.remove(Spot::Melee, 1).is_err(), "not with nothing else to hit with"); + assert!(gear.remove(Spot::Gun, 1).is_err(), "nor to shoot with"); + // Put the blade down and what's in your pack comes out in its place. + gear.add(torch); + let dropped = gear.remove(Spot::Melee, 1).unwrap(); + assert_eq!(dropped.item, Item::Weapon(blade)); + assert!(gear.melee.kit && gear.pack.is_empty()); + } + + #[test] + fn stripped_you_keep_your_own_and_never_go_unarmed() { + let mut gear = Gear::default(); + gear.add(Stack::one(Item::Weapon(found(false)))); + gear.add(Stack { item: Item::Medpatch, count: 3 }); + let kept = gear.stripped(|_| None); + assert_eq!(kept, Gear::default(), "your own, and nothing else"); + // With your torch left in the lockers, it comes out of them. + gear.ready(0).unwrap(); + let torch = gear.remove(Spot::Pack(gear.spare(false).unwrap()), 1).unwrap(); + let mut lockers = vec![torch]; + let kept = gear.stripped(|gun| match (gun, lockers.pop()) { + (false, Some(Stack { item: Item::Weapon(w), .. })) => Some(w), + _ => None, + }); + assert!(kept.melee.kit && kept.gun.kit && kept.pack.is_empty()); + assert!(lockers.is_empty()); + } + + #[test] + fn armour_goes_on_and_comes_off() { + let mut gear = Gear::default(); + gear.add(Stack::one(Item::Armor(helmet()))); + assert_eq!(gear.wear(0).unwrap(), "You put on a patched vac-helmet."); + assert_eq!((gear.guard(Dmg::Pierce), gear.guard(Dmg::Cut)), (1, 0)); + assert!(gear.pack.is_empty()); + let mut better = helmet(); + better.guards.insert(Dmg::Pierce, 2); + gear.add(Stack::one(Item::Armor(better))); + gear.wear(0).unwrap(); + assert_eq!(gear.guard(Dmg::Pierce), 2, "the better helmet went on"); + assert_eq!(gear.pack.len(), 1, "and the old one came off"); + assert_eq!(gear.take_off(Slot::Head).unwrap(), "You take off your patched vac-helmet."); + assert_eq!((gear.guard(Dmg::Pierce), gear.pack.len()), (0, 2)); + assert!(gear.take_off(Slot::Feet).is_err()); + } + + #[test] + fn food_is_eaten_one_at_a_time() { + let mut gear = Gear::default(); + let bar = Food { + title: "ration bar".into(), + description: "dense and faintly sweet".into(), + nourishment: 1, + mass: 200, + }; + gear.add(Stack { item: Item::Food(bar), count: 2 }); + assert_eq!(gear.eat(0).unwrap(), "You eat a ration bar."); + assert_eq!(gear.pack[0].count, 1); + gear.eat(0).unwrap(); + assert!(gear.pack.is_empty()); + assert!(gear.eat(0).is_err()); + } + + #[test] + fn you_carry_what_you_have_room_for() { + let gear = Gear::default(); + assert!(gear.carried() < gear.capacity); + assert_eq!(gear.fits(&Item::Medpatch), gear.room() / MEDPATCH_MASS); + assert_eq!(next_pack(BASE_CAPACITY).unwrap().0, "frame pack"); + assert_eq!(next_pack(BASE_CAPACITY + PACKS[0].1).unwrap().0, "hauler rig"); + assert_eq!(next_pack(max_capacity()), None); + } + + #[test] + fn mending_puts_your_own_kit_back() { + let mut gear = Gear::default(); + let (blade, gun) = (found(false), found(true)); + gear.melee = gun.clone(); + gear.gun = blade.clone(); + gear.pack.clear(); + gear.capacity = 0; + gear.mend(); + assert!(!gear.melee.is_gun() && gear.gun.is_gun()); + assert_eq!(gear.weapons().count(), 2, "swapped, with nothing made up"); + assert!(gear.weapons().any(|w| *w == blade) && gear.weapons().any(|w| *w == gun)); + assert_eq!(gear.capacity, BASE_CAPACITY); + } + + #[test] + fn artifacts_weigh_something_sensible() { + let mut rng = seed::rng(5); + for _ in 0..200 { + let mass = Item::Artifact(names::artifact(&mut rng)).mass(); + assert!((300..=5000).contains(&mass), "{mass} g"); + } + } +} diff --git a/src/main.rs b/src/main.rs index e2b74d3..e46a1e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,27 @@ //! Orrery: an ASCII sci-fi-fantasy game. //! -//! `orrery` plays; `orrery names` and `orrery ship` print what the -//! generators make, without the TUI. +//! `orrery` plays; `orrery names`, `ship`, `galaxy`, `system` and `town` print +//! what the generators make, without the TUI. mod app; mod cli; mod dungeon; +mod items; mod names; +mod save; mod scenes; mod seed; +mod space; mod term; +mod town; +mod trade; use std::process::ExitCode; fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); match cli::parse(&args) { - Ok(cli::Command::Play { seed }) => match app::run(seed.unwrap_or_else(seed::from_clock)) { + Ok(cli::Command::Play { seed }) => match app::run(seed) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("orrery: {e}"); @@ -31,6 +36,26 @@ fn main() -> ExitCode { cli::print_ship(seed, which); ExitCode::SUCCESS } + Ok(cli::Command::Weapons { seed, count }) => { + cli::print_weapons(seed, count); + ExitCode::SUCCESS + } + Ok(cli::Command::Armour { seed, count }) => { + cli::print_armour(seed, count); + ExitCode::SUCCESS + } + Ok(cli::Command::Galaxy { seed }) => { + cli::print_galaxy(seed); + ExitCode::SUCCESS + } + Ok(cli::Command::System { seed, which }) => { + cli::print_system(seed, which); + ExitCode::SUCCESS + } + Ok(cli::Command::Town { seed, which, inside }) => { + cli::print_town(seed, which, inside); + ExitCode::SUCCESS + } Ok(cli::Command::Help) => { print!("{}", cli::USAGE); ExitCode::SUCCESS diff --git a/src/names/artifact.rs b/src/names/artifact.rs index af24b3d..336eef4 100644 --- a/src/names/artifact.rs +++ b/src/names/artifact.rs @@ -2,22 +2,31 @@ //! called once a scholar has told you what it is. use rand::RngExt; +use serde::{Deserialize, Serialize}; use super::{Culture, pick}; use crate::seed::Rng; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Artifact { /// What you see when you find it: "a humming ochre lattice". pub unidentified: String, /// Its true name: "the Lacquered Orrery of Teshemond". pub true_name: String, + /// Whether a scholar has told you its true name. + pub identified: bool, +} + +impl Artifact { + /// What you call it: its true name once you know it. + pub fn name(&self) -> &str { + if self.identified { &self.true_name } else { &self.unidentified } + } } pub fn artifact(rng: &mut Rng) -> Artifact { let look = format!("{} {} {}", pick(QUALITIES, rng), pick(COLOURS, rng), pick(SHAPES, rng)); - let article = if look.starts_with(['a', 'e', 'i', 'o', 'u']) { "an" } else { "a" }; - let unidentified = format!("{article} {look}"); + let unidentified = super::a(&look); let object = pick(OBJECTS, rng); let culture = Culture::ALL[rng.random_range(..Culture::ALL.len())]; @@ -28,7 +37,7 @@ pub fn artifact(rng: &mut Rng) -> Artifact { 3 => format!("the {} {object}", pick(EPITHETS, rng)), _ => format!("the {object} {}", pick(CLAUSES, rng)), }; - Artifact { unidentified, true_name } + Artifact { unidentified, true_name, identified: false } } const QUALITIES: &[&str] = &[ diff --git a/src/names/mod.rs b/src/names/mod.rs index 719455f..9b5d9a6 100644 --- a/src/names/mod.rs +++ b/src/names/mod.rs @@ -21,10 +21,12 @@ use std::fmt::Write as _; use rand::RngExt; use rand::seq::IndexedRandom; +use serde::{Deserialize, Serialize}; use crate::seed::Rng; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Culture { Qaari, Cant, @@ -198,7 +200,7 @@ impl Culture { self.expand(pick(self.phonology().places, rng), rng) } - fn expand(self, template: &str, rng: &mut Rng) -> String { + pub(crate) fn expand(self, template: &str, rng: &mut Rng) -> String { let mut out = String::new(); let mut rest = template; while let Some(open) = rest.find('{') { @@ -217,6 +219,13 @@ impl Culture { } } +/// The phrase with "a" or "an" in front, by its first letter: "an ice +/// world", "a desert world". +pub fn a(phrase: &str) -> String { + let article = if phrase.starts_with(['a', 'e', 'i', 'o', 'u']) { "an" } else { "a" }; + format!("{article} {phrase}") +} + pub(crate) fn pick(list: &'static [&'static str], rng: &mut Rng) -> &'static str { list.choose(rng).expect("name lists are never empty") } diff --git a/src/save.rs b/src/save.rs new file mode 100644 index 0000000..23c7b62 --- /dev/null +++ b/src/save.rs @@ -0,0 +1,790 @@ +//! Characters, and saving them. +//! +//! A save is a universe seed plus everything you've changed in it: who you +//! are, what's in your hold, where your ship is, which wrecks you've +//! stripped, what you've learned about what lives aboard them. The universe +//! itself isn't saved; it's regenerated from the seed, which is one more +//! reason seeds must stay stable (see `seed`). +//! +//! Each character is one JSON file in the saves folder, +//! `$XDG_DATA_HOME/orrery/saves` or `~/.local/share/orrery/saves`. The game +//! writes it whenever the scene changes and when you quit. + +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::app::{Ctx, Hold}; +use crate::dungeon::Pos; +use crate::dungeon::arms::{self, Dmg, Weapon}; +use crate::dungeon::crawl::{Crawl, MONSTERS, SavedCrawl}; +use crate::items::{self, Armor, Gear, Item, Stack}; +use crate::names::{Artifact, Culture}; +use crate::scenes::inventory::Sort; +use crate::space::locale::ORES; +use crate::trade::{MAX_FUEL, MEDPATCH_LIMIT, Rack}; + +/// The save format. Bump it when a change means older Orreries can't read +/// what this one writes; this one reads any save at or below it. 2: the +/// inventory, which an older Orrery would read as having nothing on you. +pub const VERSION: u32 = 2; + +/// Who you are. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Character { + /// "Khaseem ul-Tirun". + pub name: String, + /// Where you're from. For now it only flavours the names you're offered. + pub origin: Culture, + /// Your ship, without "the": "Ithane". + pub ship: String, +} + +impl Character { + /// "the Ithane". + pub fn ship_name(&self) -> String { + format!("the {}", self.ship) + } + + /// "a Qaari salvager". + pub fn calling(&self) -> String { + format!("{} salvager", crate::names::a(self.origin.adjective())) + } +} + +/// What you've done, for the character sheet. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Record { + /// Wrecks you've gone aboard. + pub boarded: u32, + /// Of those, how many you got back from with what you found. + pub escaped: u32, + /// Times you were brought down and dragged back by your crew. + pub downed: u32, + /// Times you left by the emergency hatch. + pub abandoned: u32, + /// What you've killed, by kind. + pub slain: BTreeMap<String, u32>, +} + +impl Record { + pub fn slain_total(&self) -> u32 { + self.slain.values().sum() + } +} + +/// A save file's contents. Everything is in plain owned types, so a file +/// says what it means without the code that wrote it. +#[derive(Serialize, Deserialize)] +struct Save { + version: u32, + /// When it was written, in seconds since 1970. + saved: u64, + character: Character, + seed: u64, + day: f64, + /// The day you set out for where you are, so the wrecks you came for + /// are still there. + #[serde(default)] + departed: Option<f64>, + /// Which system you're in. Saves from before there were others are in + /// the system their universe started in. + #[serde(default)] + star: Option<usize>, + /// The systems you've been to. + #[serde(default)] + visited: Vec<usize>, + location: usize, + /// The name of where you are, for the list of saves. + #[serde(default)] + place: String, + /// Whether your ship is set down there, rather than in flight. + #[serde(default)] + landed: bool, + #[serde(default)] + boarded: u64, + #[serde(default)] + lenses: u32, + #[serde(default)] + stripped: Vec<u64>, + hold: SavedHold, + #[serde(default)] + bestiary: Vec<(String, Dmg)>, + #[serde(default)] + record: Record, + /// The wreck you were aboard, if you were in one. + #[serde(default)] + crawl: Option<SavedCrawl>, + /// Armourers' racks as you left them this fortnight. + #[serde(default)] + racks: Vec<Rack>, + /// How you had your inventory sorted. + #[serde(default, deserialize_with = "lenient")] + inventory_sort: Sort, +} + +/// A sort from a save, or the usual one if it names a sort there's no +/// longer. +fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Sort, D::Error> { + let value = serde_json::Value::deserialize(d)?; + Ok(Sort::deserialize(value).unwrap_or_default()) +} + +#[derive(Serialize, Deserialize)] +struct SavedHold { + #[serde(default)] + scrip: u32, + /// Tons of each ore, by name. + #[serde(default)] + ore: BTreeMap<String, u32>, + ore_capacity: u32, + /// What's on you. Saves from before the inventory have `artifacts`, + /// `medpatches` and `arms` instead. + #[serde(default)] + gear: Option<Gear>, + #[serde(default)] + lockers: Vec<Stack>, + #[serde(default)] + deck: Vec<(Pos, Stack)>, + #[serde(default = "full_tank")] + fuel: u32, + #[serde(default = "full_tank")] + fuel_capacity: u32, + #[serde(default)] + debt: u32, + // From before the inventory: read, never written. + #[serde(default, skip_serializing)] + artifacts: Vec<Artifact>, + #[serde(default, skip_serializing)] + medpatches: u32, + #[serde(default, skip_serializing)] + arms: Option<OldArms>, +} + +/// How weapons were saved before the inventory: all of them in one list, +/// with which were ready. +#[derive(Deserialize)] +struct OldArms { + weapons: Vec<Weapon>, + melee: usize, + ranged: usize, +} + +/// What a save from before the inventory had on you and aboard: the +/// weapons and medpatches you took into every wreck go on you, and the +/// artifacts from the shelf go in the lockers. +fn from_before(h: &mut SavedHold) -> (Gear, Vec<Stack>) { + let mut gear = Gear::default(); + if let Some(OldArms { weapons, melee, ranged }) = h.arms.take() { + let (mut blade, mut gun) = (None, None); + for (i, w) in weapons.into_iter().enumerate() { + if i == melee && !w.is_gun() && blade.is_none() { + blade = Some(w); + } else if i == ranged && w.is_gun() && gun.is_none() { + gun = Some(w); + } else { + gear.add(Stack::one(Item::Weapon(w))); + } + } + gear.melee = blade.unwrap_or_else(arms::cutting_torch); + gear.gun = gun.unwrap_or_else(arms::rivet_gun); + } + gear.add(Stack { item: Item::Medpatch, count: h.medpatches.min(MEDPATCH_LIMIT) }); + let lockers = h.artifacts.drain(..).map(|a| Stack::one(Item::Artifact(a))).collect(); + (gear, lockers) +} + +/// Puts right a pile a save file got wrong: nothing empty, no copies of +/// what can't be copied, none of your own kit (which is always on you), +/// every weapon's numbers sensible. +fn mend_pile(pile: &mut Vec<Stack>) { + pile.retain(|s| s.count > 0); + pile.iter_mut().for_each(mend_stack); +} + +fn mend_stack(s: &mut Stack) { + if !s.item.stacks() { + s.count = 1; + } + match &mut s.item { + Item::Weapon(w) => w.mend(), + Item::Armor(a) => a.mend(), + _ => {} + } +} + +/// If you're carrying more than you can, the heaviest things in your pack +/// go to the lockers until you aren't. +fn unburden(gear: &mut Gear, lockers: &mut Vec<Stack>) { + while gear.carried() > gear.capacity { + let heaviest = (0..gear.pack.len()).max_by_key(|&i| gear.pack[i].mass()); + let Some(i) = heaviest else { break }; + let count = gear.pack[i].count; + items::stow(lockers, items::take(&mut gear.pack, i, count)); + } +} + +fn full_tank() -> u32 { + Hold::BASE_FUEL +} + +/// What the list of saves shows about one, without loading it. +pub struct Summary { + pub character: Character, + pub day: f64, + pub place: String, + pub scrip: u32, + pub record: Record, + /// Seconds since 1970. + pub saved: u64, +} + +/// A file in the saves folder: what's in it, or why it can't be read. +pub struct Slot { + pub path: PathBuf, + pub summary: Result<Summary, String>, +} + +/// Where saves go: `$XDG_DATA_HOME/orrery/saves`, or +/// `~/.local/share/orrery/saves`. +pub fn default_dir() -> Option<PathBuf> { + let data = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?; + Some(data.join("orrery").join("saves")) +} + +/// A new file for `character` in universe `seed`: their name, made safe for +/// a file name, and the universe, so two characters of the same name don't +/// share one. +pub fn new_slot(dir: &Path, character: &Character, seed: u64) -> PathBuf { + let mut slug = String::new(); + for c in character.name.chars().flat_map(char::to_lowercase) { + if c.is_ascii_alphanumeric() { + slug.push(c); + } else if !slug.is_empty() && !slug.ends_with('-') { + slug.push('-'); + } + } + let slug: String = slug.trim_end_matches('-').chars().take(24).collect(); + let slug = if slug.is_empty() { "character".to_string() } else { slug }; + dir.join(format!("{slug}-{seed:016x}.json")) +} + +/// Every save in `dir`, most recently played first; the ones that can't be +/// read come last, with why. +pub fn list(dir: &Path) -> Vec<Slot> { + let Ok(entries) = fs::read_dir(dir) else { return Vec::new() }; + let mut slots: Vec<Slot> = entries + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "json")) + .map(|path| { + let summary = read(&path).map(|s| Summary { + day: s.day, + place: s.place, + scrip: s.hold.scrip, + record: s.record, + saved: s.saved, + character: s.character, + }); + Slot { path, summary } + }) + .collect(); + slots.sort_by_key(|s| (s.summary.as_ref().map_or(1, |_| 0), std::cmp::Reverse(saved(s)))); + slots +} + +fn saved(slot: &Slot) -> u64 { + slot.summary.as_ref().map_or(0, |s| s.saved) +} + +/// Writes the game in `ctx` to its slot. Writes a new file and moves it +/// over the old, so a crash halfway through leaves the old save whole. +pub fn write(ctx: &Ctx) -> io::Result<()> { + let Some(path) = &ctx.slot else { return Ok(()) }; + write_save(path, &capture(ctx)) +} + +fn write_save(path: &Path, save: &Save) -> io::Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let json = serde_json::to_string_pretty(save).map_err(io::Error::other)?; + let fresh = path.with_extension("json.new"); + fs::write(&fresh, json + "\n")?; + fs::rename(&fresh, path) +} + +/// Loads the save at `path` into `ctx`, which becomes that character's game +/// from then on. If the save can't be read, says why and leaves `ctx` as it +/// was. +pub fn load(path: &Path, ctx: &mut Ctx) -> Result<(), String> { + let save = read(path)?; + restore(save, ctx); + ctx.slot = Some(path.to_path_buf()); + Ok(()) +} + +pub fn delete(path: &Path) -> io::Result<()> { + fs::remove_file(path) +} + +fn read(path: &Path) -> Result<Save, String> { + let text = fs::read_to_string(path).map_err(|e| e.to_string())?; + let save: Save = serde_json::from_str(&text).map_err(|e| e.to_string())?; + if save.version > VERSION { + return Err(format!("saved by a newer Orrery (save format {})", save.version)); + } + Ok(save) +} + +/// Seconds since 1970. +pub fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) +} + +fn capture(ctx: &Ctx) -> Save { + let h = &ctx.hold; + let mut stripped: Vec<u64> = ctx.stripped.iter().copied().collect(); + stripped.sort_unstable(); + Save { + version: VERSION, + saved: now(), + character: ctx.character.clone(), + seed: ctx.seed, + day: ctx.day, + departed: ctx.departed, + star: Some(ctx.star), + visited: ctx.visited.iter().copied().collect(), + location: ctx.location, + place: match &ctx.crawl { + Some(c) => format!("aboard {} in {}", c.name, ctx.system.name), + None => format!("{} in {}", ctx.system.places[ctx.location].name, ctx.system.name), + }, + landed: ctx.landed, + boarded: ctx.boarded, + lenses: ctx.lenses, + stripped, + hold: SavedHold { + scrip: h.scrip, + ore: h.ore.iter().map(|(&k, &v)| (k.to_string(), v)).collect(), + ore_capacity: h.ore_capacity, + gear: Some(h.gear.clone()), + lockers: h.lockers.clone(), + deck: h.deck.clone(), + fuel: h.fuel, + fuel_capacity: h.fuel_capacity, + debt: h.debt, + artifacts: Vec::new(), + medpatches: 0, + arms: None, + }, + bestiary: ctx.bestiary.iter().map(|&(m, d)| (m.to_string(), d)).collect(), + record: ctx.record.clone(), + crawl: ctx.crawl.clone(), + racks: ctx.racks.clone(), + inventory_sort: ctx.inventory_sort, + } +} + +/// Starts `ctx` afresh in the save's universe, then puts back everything +/// the save changed. Anything that doesn't fit this version's universe (an +/// ore or a monster that no longer exists, a place past the last one) is +/// dropped or put somewhere sensible rather than refused. +fn restore(save: Save, ctx: &mut Ctx) { + ctx.begin(save.seed, save.character); + let star = save.star.filter(|&s| s < ctx.galaxy.sites.len()).unwrap_or(ctx.galaxy.home); + if star != ctx.star { + ctx.enter_system(star); + ctx.location = ctx.system.home_place(); + } + ctx.visited = save.visited.into_iter().filter(|&s| s < ctx.galaxy.sites.len()).collect(); + ctx.visited.insert(star); + let placed = save.location < ctx.system.places.len(); + if placed { + ctx.location = save.location; + ctx.landed = save.landed; + } + if save.day.is_finite() && save.day >= 0.0 { + ctx.day = save.day; + // Only if you're still where you set out for. + ctx.departed = save.departed.filter(|d| placed && (0.0..=ctx.day).contains(d)); + } + ctx.boarded = save.boarded; + ctx.lenses = save.lenses; + ctx.stripped = save.stripped.into_iter().collect(); + let mut h = save.hold; + let (mut gear, mut lockers) = match h.gear.take() { + Some(gear) => (gear, std::mem::take(&mut h.lockers)), + None => from_before(&mut h), + }; + gear.mend(); + mend_pile(&mut lockers); + unburden(&mut gear, &mut lockers); + let mut deck = h.deck; + deck.retain(|(_, s)| s.count > 0); + deck.iter_mut().for_each(|(_, s)| mend_stack(s)); + ctx.hold = Hold { + scrip: h.scrip, + ore: h + .ore + .into_iter() + .filter_map(|(name, tons)| ORES.iter().find(|o| o.name == name).map(|o| (o.name, tons))) + .collect(), + ore_capacity: h.ore_capacity.max(Hold::BASE_ORE_CAPACITY), + gear, + lockers, + deck, + fuel_capacity: h.fuel_capacity.clamp(Hold::BASE_FUEL, MAX_FUEL), + fuel: 0, + debt: h.debt, + }; + ctx.hold.fuel = h.fuel.min(ctx.hold.fuel_capacity); + ctx.bestiary = save + .bestiary + .into_iter() + .filter_map(|(name, dmg)| MONSTERS.iter().find(|k| k.name == name).map(|k| (k.name, dmg))) + .collect(); + ctx.record = save.record; + // A wreck that no longer fits the ship its seed makes is lost, as if you + // got out before it came apart: you're back as you were before boarding. + ctx.crawl = save.crawl.filter(|c| Crawl::resume(c).is_some()); + // Only this fortnight's racks, as `trade::rack` keeps: one saved when + // racks were restocked every week has a week's number, not a + // fortnight's, and could pass for another fortnight's later on. + ctx.inventory_sort = save.inventory_sort; + ctx.racks = save.racks; + ctx.racks.retain(|r| r.fortnight == crate::trade::fortnight(ctx.day)); + for r in &mut ctx.racks { + r.weapons.iter_mut().for_each(Weapon::mend); + r.armour.iter_mut().for_each(Armor::mend); + } +} + +/// For tests: a folder of their own under the system's temp folder, empty. +#[cfg(test)] +pub fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("orrery-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dungeon::arms::{self, Source}; + use crate::names; + use crate::seed; + use crate::term::KeyboardCaps; + + fn ctx(seed: u64) -> Ctx { + Ctx::new(seed, KeyboardCaps { key_release: false }) + } + + fn character(name: &str) -> Character { + Character { name: name.into(), origin: Culture::Drift, ship: "Long Patience".into() } + } + + /// A game well under way. + fn played(dir: &Path) -> Ctx { + let mut ctx = ctx(1312); + ctx.begin(1312, character("Ysolde Marr")); + ctx.slot = Some(new_slot(dir, &ctx.character, ctx.seed)); + ctx.day = 41.5; + let next = ctx.galaxy.sites[ctx.star].lanes[0]; + ctx.enter_system(next); + ctx.travel(ctx.system.places.len() - 1, 2.5); + ctx.landed = false; + ctx.boarded = 3; + ctx.lenses = 1; + ctx.stripped.extend([7, 99]); + let mut rng = seed::rng(3); + let mut artifact = names::artifact(&mut rng); + artifact.identified = true; + ctx.hold.lockers.push(Stack::one(Item::Artifact(artifact))); + ctx.hold.scrip = 1234; + ctx.hold.ore.insert("nickel", 12); + ctx.hold.ore_capacity = 60; + ctx.hold.gear.add(Stack { item: Item::Medpatch, count: 4 }); + ctx.hold.fuel_capacity = 6; + ctx.hold.fuel = 3; + ctx.hold.debt = 75; + let blade = + (0..).map(|_| arms::find(&mut rng, Source::Armoury, true)).find(|w| !w.is_gun()); + ctx.hold.gear.add(Stack::one(Item::Weapon(blade.unwrap()))); + ctx.hold.gear.ready(1).unwrap(); + ctx.hold.gear.capacity += 10_000; + let salvage = items::Salvage { title: "working servo".into(), value: 35, mass: 4000 }; + ctx.hold.deck.push((Pos::new(20, 9), Stack { item: Item::Salvage(salvage), count: 2 })); + ctx.bestiary.insert(("choir-wraith", Dmg::Pierce)); + ctx.record.boarded = 5; + ctx.record.slain.insert("rust-mite".into(), 9); + ctx + } + + #[test] + fn an_armourers_rack_is_as_you_left_it() { + let dir = scratch("rack"); + let mut ctx = played(&dir); + let place = ctx.system.home_place(); + let culture = ctx.system.culture; + let rack = crate::trade::rack(&mut ctx.racks, &ctx.system, place, culture, ctx.day); + let sold = rack.weapons.remove(0); + rack.weapons.push(sold); + let mut worn = rack.armour.remove(0); + worn.durability /= 2; + rack.armour.push(worn); + let titles = + |c: &Ctx| c.racks[0].weapons.iter().map(|w| w.title.clone()).collect::<Vec<_>>(); + write(&ctx).unwrap(); + let mut back = self::ctx(1); + load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); + assert_eq!(titles(&back), titles(&ctx)); + assert_eq!(back.racks[0].armour, ctx.racks[0].armour); + assert_eq!(back.racks[0].fortnight, ctx.racks[0].fortnight); + } + + #[test] + fn your_inventory_sort_is_kept_and_an_unknown_one_forgotten() { + use crate::scenes::inventory::SortBy; + let dir = scratch("sort"); + let mut ctx = played(&dir); + ctx.inventory_sort = Sort { by: SortBy::Density, reversed: true }; + write(&ctx).unwrap(); + let mut back = self::ctx(1); + load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); + assert_eq!(back.inventory_sort, ctx.inventory_sort); + + let mut json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(ctx.slot.as_ref().unwrap()).unwrap()).unwrap(); + json["inventory_sort"] = serde_json::json!({ "by": "vibes", "reversed": true }); + fs::write(ctx.slot.as_ref().unwrap(), json.to_string()).unwrap(); + load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); + assert_eq!(back.inventory_sort, Sort::default()); + } + + #[test] + fn a_wreck_you_quit_in_is_there_when_you_come_back() { + let dir = scratch("wreck"); + let mut ctx = played(&dir); + let mut wreck = crate::dungeon::crawl::Crawl::new(5); + wreck.gear = ctx.hold.gear.clone(); + ctx.crawl = Some(wreck.saved()); + write(&ctx).unwrap(); + let summary = &list(&dir)[0].summary; + assert!(summary.as_ref().unwrap().place.starts_with("aboard "), "the list says so"); + let mut back = self::ctx(1); + load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); + // The same wreck, as you left it (floats aside, which JSON rounds). + let resumed = |c: &Ctx| Crawl::resume(c.crawl.as_ref().unwrap()).unwrap(); + let (was, is) = (resumed(&ctx), resumed(&back)); + assert_eq!( + (is.seed, is.player, is.hp, is.turns), + (was.seed, was.player, was.hp, was.turns) + ); + assert_eq!((is.loot.len(), is.monsters.len()), (was.loot.len(), was.monsters.len())); + assert_eq!(is.gear.pack, was.gear.pack); + let scenes = crate::scenes::flight::resume(&back); + assert_eq!(scenes.len(), 2, "flying, with the wreck on top"); + + // One that no longer fits its ship is lost, and you're as you were + // before you boarded. + let mut json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(ctx.slot.as_ref().unwrap()).unwrap()).unwrap(); + json["crawl"]["player"] = serde_json::json!({ "x": -5, "y": -5 }); + fs::write(ctx.slot.as_ref().unwrap(), json.to_string()).unwrap(); + load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); + assert_eq!(back.crawl, None); + assert_eq!(back.hold.gear, ctx.hold.gear); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn a_save_brings_back_everything() { + let dir = scratch("round-trip"); + let ctx = played(&dir); + write(&ctx).unwrap(); + let mut back = self::ctx(1); + load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); + assert_eq!(back.character, ctx.character); + assert_eq!((back.seed, back.day, back.location), (ctx.seed, ctx.day, ctx.location)); + assert_eq!(back.departed, Some(41.5), "the day you set out for here"); + assert_eq!(back.system.name, ctx.system.name, "the same universe, and system"); + assert_eq!(back.star, ctx.star); + assert_eq!(back.visited, ctx.visited); + assert_eq!(back.visited.len(), 2); + assert_eq!((back.hold.fuel, back.hold.fuel_capacity, back.hold.debt), (3, 6, 75)); + assert_eq!((back.landed, back.boarded, back.lenses), (false, 3, 1)); + assert_eq!(back.stripped, ctx.stripped); + assert_eq!( + (back.hold.scrip, back.hold.ore_capacity, back.hold.medpatches()), + (1234, 60, 4) + ); + assert_eq!(back.hold.ore, ctx.hold.ore); + assert_eq!(back.hold.gear, ctx.hold.gear); + assert_eq!(back.hold.lockers, ctx.hold.lockers); + assert_eq!(back.hold.deck, ctx.hold.deck); + assert_eq!(back.bestiary, ctx.bestiary); + assert_eq!(back.record, ctx.record); + assert_eq!(back.slot, ctx.slot); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn what_no_longer_exists_is_dropped() { + let dir = scratch("stale"); + let ctx = played(&dir); + let mut save = capture(&ctx); + save.hold.ore.insert("unobtainium".into(), 5); + save.bestiary.push(("snark".into(), Dmg::Cut)); + save.location = 10_000; + let gear = save.hold.gear.as_mut().unwrap(); + std::mem::swap(&mut gear.melee, &mut gear.gun); + gear.pack.retain(|s| !s.item.is_kit()); + gear.pack.push(Stack { item: Item::Medpatch, count: 0 }); + save.hold.lockers.push(Stack::one(Item::Weapon(arms::rivet_gun()))); + let path = dir.join("stale.json"); + write_save(&path, &save).unwrap(); + let mut back = self::ctx(1); + load(&path, &mut back).unwrap(); + assert_eq!(back.hold.ore.len(), 1); + assert_eq!(back.bestiary.len(), 1); + assert_eq!(back.location, back.system.home_place()); + let gear = &back.hold.gear; + assert!(!gear.melee.is_gun() && gear.gun.is_gun()); + assert!(gear.pack.iter().all(|s| s.count > 0)); + let stored = Stack::one(Item::Weapon(arms::rivet_gun())); + assert!(back.hold.lockers.contains(&stored), "your own can be stowed"); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn saves_from_before_the_galaxy_stay_where_they_were() { + let dir = scratch("before-galaxy"); + let mut ctx = played(&dir); + // Back in the system the universe began in, wherever the + // character's people have their seat. + ctx.visited.clear(); + ctx.enter_system(ctx.galaxy.home); + ctx.location = 2; + let mut save = capture(&ctx); + save.star = None; + save.visited.clear(); + let json = serde_json::to_string(&save).unwrap(); + let mut json: serde_json::Value = serde_json::from_str(&json).unwrap(); + for field in ["star", "visited"] { + json.as_object_mut().unwrap().remove(field); + } + for field in ["fuel", "fuel_capacity", "debt"] { + json["hold"].as_object_mut().unwrap().remove(field); + } + let path = dir.join("old.json"); + fs::write(&path, json.to_string()).unwrap(); + let mut back = self::ctx(1); + load(&path, &mut back).unwrap(); + assert_eq!(back.star, back.galaxy.home); + assert_eq!(back.location, 2); + assert_eq!(back.system.name, crate::space::home_system(1312).name); + assert_eq!(back.visited.len(), 1); + assert_eq!((back.hold.fuel, back.hold.fuel_capacity, back.hold.debt), (4, 4, 0)); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn saves_from_before_the_inventory_keep_everything() { + let dir = scratch("before-inventory"); + let ctx = played(&dir); + let mut json = serde_json::to_value(capture(&ctx)).unwrap(); + let hold = json["hold"].as_object_mut().unwrap(); + for field in ["gear", "lockers", "deck"] { + hold.remove(field); + } + let mut rng = seed::rng(8); + let artifact = names::artifact(&mut rng); + let cleaver = + (0..).map(|_| arms::find(&mut rng, Source::Crew, false)).find(|w| !w.is_gun()).unwrap(); + let mut old = serde_json::to_value(&cleaver).unwrap(); + old.as_object_mut().unwrap().remove("mass"); + let torch = serde_json::to_value(arms::cutting_torch()).unwrap(); + let rivets = serde_json::to_value(arms::rivet_gun()).unwrap(); + hold.insert("artifacts".into(), serde_json::json!([artifact])); + hold.insert("medpatches".into(), 3.into()); + hold.insert( + "arms".into(), + serde_json::json!({ "weapons": [torch, rivets, old], "melee": 2, "ranged": 1 }), + ); + json["version"] = 1.into(); + let path = dir.join("v1.json"); + fs::write(&path, json.to_string()).unwrap(); + let mut back = self::ctx(1); + load(&path, &mut back).unwrap(); + let gear = &back.hold.gear; + assert_eq!(gear.melee, cleaver, "ready as it was, and weighed"); + assert!(gear.gun.kit && gear.gun.is_gun()); + assert_eq!(gear.weapons().count(), 3, "the torch is in the pack"); + assert_eq!(gear.medpatches(), 3); + assert!(back.hold.lockers.contains(&Stack::one(Item::Artifact(artifact)))); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn unreadable_saves_are_refused_and_change_nothing() { + let dir = scratch("unreadable"); + let garbled = dir.join("garbled.json"); + fs::write(&garbled, "{ not json").unwrap(); + let newer = dir.join("newer.json"); + let mut save = capture(&played(&dir)); + save.version = VERSION + 1; + write_save(&newer, &save).unwrap(); + let mut ctx = self::ctx(5); + ctx.hold.scrip = 77; + assert!(load(&garbled, &mut ctx).is_err()); + assert!(load(&newer, &mut ctx).unwrap_err().contains("newer")); + assert_eq!((ctx.seed, ctx.hold.scrip, ctx.slot.is_none()), (5, 77, true)); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn saves_list_newest_first_and_the_broken_last() { + let dir = scratch("list"); + for (name, saved) in [("Old Hand", 100), ("New Face", 300), ("Middling", 200)] { + let mut ctx = played(&dir); + ctx.character.name = name.into(); + let mut save = capture(&ctx); + save.saved = saved; + write_save(&new_slot(&dir, &ctx.character, ctx.seed), &save).unwrap(); + } + fs::write(dir.join("broken.json"), "[]").unwrap(); + fs::write(dir.join("notes.txt"), "not a save").unwrap(); + let slots = list(&dir); + let names: Vec<String> = slots + .iter() + .map(|s| s.summary.as_ref().map_or("?".into(), |s| s.character.name.clone())) + .collect(); + assert_eq!(names, ["New Face", "Middling", "Old Hand", "?"]); + assert_eq!(slots[0].summary.as_ref().unwrap().scrip, 1234); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn slot_names_are_safe() { + let dir = Path::new("/saves"); + let slot = |name: &str| new_slot(dir, &character(name), 255); + assert_eq!(slot("Khaseem ul-Tirun"), dir.join("khaseem-ul-tirun-00000000000000ff.json")); + assert_eq!(slot("../../etc"), dir.join("etc-00000000000000ff.json")); + assert_eq!(slot("☼☼"), dir.join("character-00000000000000ff.json")); + } + + #[test] + fn saving_without_a_slot_does_nothing() { + assert!(write(&ctx(1)).is_ok()); + } +} diff --git a/src/scenes/aboard.rs b/src/scenes/aboard.rs new file mode 100644 index 0000000..49dc61d --- /dev/null +++ b/src/scenes/aboard.rs @@ -0,0 +1,1314 @@ +//! Aboard your own ship, on foot. +//! +//! Landing leaves you in the cockpit. To go ashore you walk aft through the +//! ship to the airlock in the stern, and to take off again you walk back +//! forward and sit in the pilot's seat. Out in space you can get up from +//! the controls whenever you're flying slowly: the ship holds station while +//! you walk about, and if a wreck is alongside, the airlock boards it. +//! +//! The ship is drawn by hand below, in the shape of the dart you fly: the +//! cockpit in the nose, an engine room in each swept-back wing, and the +//! airlock in the notch between them. The hold has a bin for every five +//! tons of ore you can carry, more as cargo racks are fitted, and shows +//! what's in them. The shelf in the crew quarters shows the artifacts in +//! your lockers, and the med cabinet the medpatches. +//! +//! `i` opens the inventory, with the lockers beside what you carry. What +//! you put down on the deck stays where you left it, for good. `<` walks +//! you forward to the pilot's seat and `>` aft to the airlock, stopping +//! beside them. + +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap}; + +use super::character::Sheet; +use super::derelict::{Derelict, camera}; +use super::inventory::{self, Beside, Inventory, Reply, View}; +use super::keys::{self, ACCENT, DIM}; +use super::pause::{Choice, Pause}; +use super::port::Port; +use super::sky::{mix, rgb}; +use super::town::TownScene; +use super::{Scene, Transition}; +use crate::app::{Ctx, Hold}; +use crate::dungeon::Pos; +use crate::items::{self, Item, Stack}; +use crate::space::locale::ORES; +use crate::space::system::{Landing, PlaceKind, Rgb, Wreck}; +use crate::trade; + +const PANEL_WIDTH: u16 = 32; +const LOG_HEIGHT: u16 = 6; +/// The most steps one run takes. +const RUN_LIMIT: usize = 60; +/// Tons of ore to a bin in the hold. +const BIN_TONS: u32 = 5; +/// The middle row. With the nose to the east, rows above it are the port +/// side and rows below it starboard. +const MIDSHIPS: i32 = 9; + +/// Your ship, nose to the east, one character to a cell: +/// +/// `#` hull, `x` bulkhead, `.` deck, `:` engine-room grating, `/` doorway, +/// `A` the airlock, `w` windscreen, `P` the pilot's seat, `c` crew seats, +/// `n` `N` `r` the flight, nav and comms consoles, `E` the drives, +/// `k` workbenches, `O` ore bins, `1` to `3` the bins each set of cargo +/// racks adds, `b` bunks, `B` the med-bay couch, `m` the med cabinet, +/// `*` the shelf for artifacts, `l` lockers, `C` crates, `t` the galley +/// table, `h` chairs, `s` the galley, `f` a fern. +#[rustfmt::skip] +const LAYOUT: [&str; 19] = [ + "##############", + "#:EE:::::::::#####", + "#:EE:::::k:::::::#####", + "#::::::::::::::::::::#", + "####xxx/xxxxxxxxxxxxx####################", + " #..............x.bb.bb.bb.**lxmm...BB#####", + " #.OOOO.11.22.33x.............x.......x...#####", + " #..............x.............x.......x.......#####", + " #..............xxxxxxx/xxxxxxxxxx/xxxx.......cNNN#www", + " A............../...................../..........Pnnnw", + " #..............xxxxxxx/xxxxxxxxxx/xxxx.......crrr#www", + " #..............x....hh.......x.......x.......#####", + " #.OOOO.11.22.33x...htth......x......Cx...#####", + " #..............xsss.........fxlllll.C#####", + "####xxx/xxxxxxxxxxxxx####################", + "#::::::::::::::::::::#", + "#:EE:::::k:::::::#####", + "#:EE:::::::::#####", + "##############", +]; + +const HEIGHT: i32 = LAYOUT.len() as i32; +const WIDTH: i32 = { + let mut width = 0; + let mut i = 0; + while i < LAYOUT.len() { + if LAYOUT[i].len() > width { + width = LAYOUT[i].len(); + } + i += 1; + } + width as i32 +}; + +/// A room: its name, and its top left and bottom right corners. +struct Room(&'static str, (i32, i32), (i32, i32)); + +/// The first room that holds a cell names it, so doorways go with the +/// corridor or the hold. +const ROOMS: [Room; 9] = [ + Room("the cockpit", (41, 6), (54, 12)), + Room("the corridor", (19, 8), (40, 10)), + Room("the crew quarters", (19, 5), (31, 7)), + Room("the galley", (19, 11), (31, 13)), + Room("the med-bay", (33, 5), (39, 7)), + Room("the stores", (33, 11), (39, 13)), + Room("the hold", (3, 4), (18, 14)), + Room("the port engine room", (1, 1), (20, 3)), + Room("the starboard engine room", (1, 15), (20, 17)), +]; + +/// Box-drawing glyphs for walls, by which neighbours they join: 1 north, +/// 2 east, 4 south, 8 west. The hull is drawn in double lines. +const DOUBLE: [char; 16] = + ['■', '║', '═', '╚', '║', '║', '╔', '╠', '═', '╝', '═', '╩', '╗', '╣', '╦', '╬']; +const SINGLE: [char; 16] = + ['■', '│', '─', '└', '│', '│', '┌', '├', '─', '┘', '─', '┴', '┐', '┤', '┬', '┼']; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Cell { + Void, + Hull, + Wall, + Deck, + Grating, + Door, + Airlock, + Window, + PilotSeat, + /// An ore bin: one the ship came with (set 0), or one of the `n`th set + /// of cargo racks, which is bare deck until they're fitted. + Bin(u8), + Fixture(Fixture), +} + +impl Cell { + fn walkable(self) -> bool { + matches!(self, Cell::Deck | Cell::Grating | Cell::Door) + } + + /// Whether a wall beside it joins up with it. + fn joins(self) -> bool { + matches!(self, Cell::Hull | Cell::Wall | Cell::Airlock | Cell::Window) + } +} + +/// Things you bump into to look at. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fixture { + FlightConsole, + NavConsole, + Comms, + CrewSeat, + Drive, + Workbench, + Bunk, + Couch, + MedCabinet, + Shelf, + Locker, + Crate, + Table, + Chair, + Galley, + Fern, +} + +fn parse(ch: u8) -> Option<Cell> { + use Fixture as F; + let fixture = match ch { + b' ' => return Some(Cell::Void), + b'#' => return Some(Cell::Hull), + b'x' => return Some(Cell::Wall), + b'.' => return Some(Cell::Deck), + b':' => return Some(Cell::Grating), + b'/' => return Some(Cell::Door), + b'A' => return Some(Cell::Airlock), + b'w' => return Some(Cell::Window), + b'P' => return Some(Cell::PilotSeat), + b'O' => return Some(Cell::Bin(0)), + b'1'..=b'3' => return Some(Cell::Bin(ch - b'0')), + b'n' => F::FlightConsole, + b'N' => F::NavConsole, + b'r' => F::Comms, + b'c' => F::CrewSeat, + b'E' => F::Drive, + b'k' => F::Workbench, + b'b' => F::Bunk, + b'B' => F::Couch, + b'm' => F::MedCabinet, + b'*' => F::Shelf, + b'l' => F::Locker, + b'C' => F::Crate, + b't' => F::Table, + b'h' => F::Chair, + b's' => F::Galley, + b'f' => F::Fern, + _ => return None, + }; + Some(Cell::Fixture(fixture)) +} + +/// The cell at `p` as drawn, before any cargo racks are fitted. Off the +/// map is `Void`. +fn layout(p: Pos) -> Cell { + let row = usize::try_from(p.y).ok().and_then(|y| LAYOUT.get(y)); + let ch = row.and_then(|row| usize::try_from(p.x).ok().and_then(|x| row.as_bytes().get(x))); + ch.and_then(|&ch| parse(ch)).unwrap_or(Cell::Void) +} + +/// Every cell drawn as `ch`, row by row. +fn positions(ch: u8) -> impl Iterator<Item = Pos> { + LAYOUT.iter().enumerate().flat_map(move |(y, row)| { + row.bytes() + .enumerate() + .filter(move |&(_, c)| c == ch) + .map(move |(x, _)| Pos::new(x as i32, y as i32)) + }) +} + +fn seat() -> Pos { + positions(b'P').next().expect("the ship has a pilot's seat") +} + +fn room_at(p: Pos) -> Option<&'static str> { + ROOMS + .iter() + .find(|Room(_, (x0, y0), (x1, y1))| { + (*x0..=*x1).contains(&p.x) && (*y0..=*y1).contains(&p.y) + }) + .map(|room| room.0) +} + +/// Every bin, with its set, in the order they fill: the ship's own, from +/// aft forward, then each set of racks in turn. +fn bins() -> Vec<(u8, Pos)> { + let mut bins: Vec<(u8, Pos)> = (0..HEIGHT) + .flat_map(|y| (0..WIDTH).map(move |x| Pos::new(x, y))) + .filter_map(|p| match layout(p) { + Cell::Bin(set) => Some((set, p)), + _ => None, + }) + .collect(); + bins.sort_by_key(|&(set, p)| (set, p.x, p.y)); + bins +} + +/// How many sets of cargo racks are fitted. +fn racks(hold: &Hold) -> u8 { + let extra = hold.ore_capacity.saturating_sub(Hold::BASE_ORE_CAPACITY); + u8::try_from(extra / trade::RACK_TONS).unwrap_or(u8::MAX) +} + +/// The ore in the first `count` bins: tons in each, and the colour of the +/// ore that fills most of it. +fn bin_contents(hold: &Hold, count: usize) -> Vec<(u32, Option<Rgb>)> { + let mut bins = vec![(0, None::<(Rgb, u32)>); count]; + let mut i = 0; + for (name, &tons) in &hold.ore { + let colour = ORES.iter().find(|o| o.name == *name).map_or((150, 138, 124), |o| o.rgb); + let mut left = tons; + while left > 0 && i < count { + let (filled, most) = &mut bins[i]; + let pour = left.min(BIN_TONS - *filled); + *filled += pour; + if most.is_none_or(|(_, t)| pour > t) { + *most = Some((colour, pour)); + } + left -= pour; + if *filled == BIN_TONS { + i += 1; + } + } + } + bins.into_iter().map(|(tons, most)| (tons, most.map(|(colour, _)| colour))).collect() +} + +/// Where the ship is while you walk about it. +#[derive(Clone, Debug, PartialEq)] +pub enum Berth { + /// Set down at a place: a city's landing field, a station's dock, or + /// open ground where no one lives. + Landed(usize), + /// Holding station out in space, with a wreck alongside if there's one. + Adrift(Option<Wreck>), +} + +/// Where you went through the airlock to. While you're there, its scene is +/// the one on top of this. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Outside { + City, + Port, + Wreck, +} + +pub struct Aboard { + berth: Berth, + /// Where the ship is, for the panel: "landed on Qeth", "holding station + /// in orbit around Qeth, a desert world". + whereabouts: String, + player: Pos, + log: Vec<String>, + /// How many things have been said, ever: the log itself stops growing. + said: usize, + outside: Option<Outside>, + bins: Vec<(u8, Pos)>, + shelf: Vec<Pos>, + /// Open over the ship when you're looking through what you carry, with + /// the lockers or the deck beside it. + inventory: Option<(Inventory, Beside)>, + /// The menu `esc` opens. + pause: Option<Pause>, +} + +impl Aboard { + /// Just landed or docked at `place`: you get up from the pilot's seat. + pub fn landed(ctx: &Ctx, place: usize) -> Aboard { + let name = &ctx.system.places[place].name; + let (whereabouts, arrival) = match ctx.system.landing(place) { + Landing::Capital(_) => ( + format!("landed on {name}"), + format!("You set down on {name}, on the landing field outside its capital."), + ), + Landing::Market(_) => (format!("docked at {name}"), format!("You dock at {name}.")), + _ => { + (format!("landed on {name}"), format!("You set down on {name}. No one lives here.")) + } + }; + let mut scene = Aboard::new(Berth::Landed(place), whereabouts); + scene.say(arrival); + scene.say("You unbuckle and get up. The airlock is aft, through the hold.".into()); + scene + } + + /// Back aboard, landed at `place`, in a game you've come back to. + pub fn resumed(ctx: &Ctx, place: usize) -> Aboard { + let mut scene = Aboard::landed(ctx, place); + scene.log.clear(); + let ship = ctx.character.ship_name(); + let name = &ctx.system.places[place].name; + let berth = match ctx.system.landing(place) { + Landing::Market(_) => format!("docked at {name}"), + _ => format!("landed on {name}"), + }; + scene.say(format!("You're aboard {ship}, {berth}, on day {:.0}.", ctx.day.floor())); + scene.say("The airlock is aft, through the hold: [>] walks you there.".into()); + scene + } + + /// Up from the controls out in space, where the ship is `what` ("in + /// orbit around Qeth, a desert world"), with `wreck` alongside if + /// there's one. + pub fn adrift(what: &str, wreck: Option<Wreck>) -> Aboard { + let alongside = wreck.as_ref().map(|w| capitalise(&w.name)); + let mut scene = Aboard::new(Berth::Adrift(wreck), format!("holding station {what}")); + scene.say("You set the ship to hold station, unbuckle and get up.".into()); + if let Some(name) = alongside { + scene.say(format!("{name} drifts alongside. The airlock is aft, through the hold.")); + } + scene + } + + fn new(berth: Berth, whereabouts: String) -> Aboard { + Aboard { + berth, + whereabouts, + player: seat().offset(-1, 0), + log: Vec::new(), + said: 0, + outside: None, + bins: bins(), + shelf: positions(b'*').collect(), + inventory: None, + pause: None, + } + } + + fn say(&mut self, text: String) { + self.log.push(text); + self.said += 1; + if self.log.len() > 50 { + self.log.remove(0); + } + } + + /// The cell at `p`, with racks that aren't fitted left out. + fn cell(&self, p: Pos, hold: &Hold) -> Cell { + match layout(p) { + Cell::Bin(set) if set > racks(hold) => Cell::Deck, + cell => cell, + } + } + + /// Back in through the airlock, if you were out. + fn come_back(&mut self) { + let Some(outside) = self.outside.take() else { return }; + let text = match outside { + Outside::City => "You climb the ramp and seal the airlock behind you.", + Outside::Port => "You come back aboard and seal the airlock.", + Outside::Wreck => "The airlock cycles, and you're back aboard your own ship.", + }; + self.say(text.into()); + } + + fn step(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) -> Transition { + let to = self.player.offset(dx, dy); + match self.cell(to, &ctx.hold) { + cell if cell.walkable() => { + self.player = to; + if let Some(text) = inventory::underfoot(&inventory::lying(&ctx.hold.deck, to)) { + self.say(text); + } + } + Cell::PilotSeat => return Transition::Pop, + Cell::Airlock => return self.airlock(ctx), + Cell::Window => { + let text = self.view_out(ctx); + self.say(text); + } + Cell::Bin(_) => self.say(hold_text(&ctx.hold)), + // The weapons locker opens the lockers. + Cell::Fixture(Fixture::Locker) if to.y < MIDSHIPS => { + self.say("You open the lockers.".into()); + self.inventory = Some((Inventory::new(), Beside::Lockers)); + } + Cell::Fixture(fixture) => { + let text = self.about(fixture, to, ctx); + self.say(text); + } + _ => {} + } + Transition::None + } + + /// `g`: picks up what's on the deck where you stand, or lists it if + /// there's more than one thing. + fn pick_up(&mut self, ctx: &mut Ctx) { + let here = inventory::lying(&ctx.hold.deck, self.player); + match &here[..] { + [] => self.say("There's nothing here to pick up.".into()), + [stack] => { + let command = inventory::Command::Fetch(0, stack.count); + let text = self.carry_out(command, Beside::Floor("on the deck"), ctx); + self.say(text); + } + _ => self.inventory = Some((Inventory::beside(), Beside::Floor("on the deck"))), + } + } + + /// Does what the inventory asks, with the lockers or the deck beside you. + fn carry_out(&mut self, command: inventory::Command, beside: Beside, ctx: &mut Ctx) -> String { + let h = &mut ctx.hold; + let mut floor = inventory::gather(&mut h.deck, self.player); + let lockers = (beside == Beside::Lockers).then_some(&mut h.lockers); + let text = inventory::carry_out(command, &mut h.gear, lockers, &mut floor, Some(beside)); + inventory::scatter(&mut h.deck, self.player, floor); + text + } + + /// A key while the inventory's open. + fn inventory_key(&mut self, key: KeyEvent, ctx: &mut Ctx) { + let Some((inv, beside)) = &mut self.inventory else { return }; + let beside = *beside; + let floor = inventory::lying(&ctx.hold.deck, self.player); + let view = view(ctx, beside, &floor); + match inv.key(key, &view) { + Reply::Stay => {} + Reply::Close => self.inventory = None, + Reply::Sort(sort) => ctx.inventory_sort = sort, + Reply::Do(command) => { + let text = self.carry_out(command, beside, ctx); + if let Some((inv, _)) = &mut self.inventory { + inv.note = Some(text.clone()); + } + self.say(text); + } + } + } + + /// Steps until something changes: a wall, a doorway into another room, + /// something said. + fn run(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) -> Transition { + for _ in 0..RUN_LIMIT { + let (before, room, said) = (self.player, room_at(self.player), self.said); + let transition = self.step(dx, dy, ctx); + if !matches!(transition, Transition::None) { + return transition; + } + if self.player == before || self.said != said || room_at(self.player) != room { + break; + } + } + Transition::None + } + + /// Out through the airlock: into the city, the station or the wreck + /// alongside, if there's anywhere to go. + fn airlock(&mut self, ctx: &mut Ctx) -> Transition { + match self.berth { + Berth::Landed(place) => { + if matches!(ctx.system.landing(place), Landing::Capital(_)) { + self.outside = Some(Outside::City); + Transition::Push(Box::new(TownScene::new(ctx, place))) + } else { + self.outside = Some(Outside::Port); + Transition::Push(Box::new(Port::new(ctx, place))) + } + } + Berth::Adrift(None) => { + self.say( + "There's nothing outside the airlock but vacuum. Bring the ship alongside \ + a wreck to board it." + .into(), + ); + Transition::None + } + Berth::Adrift(Some(ref wreck)) => { + let wreck = wreck.clone(); + if ctx.stripped.contains(&wreck.seed) { + let text = + format!("You've already stripped {}. Nothing's left aboard.", wreck.name); + self.say(text); + Transition::None + } else { + let seed = wreck.seed; + ctx.stripped.insert(seed); + self.outside = Some(Outside::Wreck); + Transition::Push(Box::new(Derelict::board(seed, ctx))) + } + } + } + } + + /// `<` or `>`: walks to stand beside the pilot's seat or the airlock, + /// by the shortest way, so one more step takes off or goes out. + fn walk_to(&mut self, goal: Cell, ctx: &Ctx) { + let (what, way) = match goal { + Cell::PilotSeat => ("the pilot's seat ╥", "forward"), + _ => ("the airlock Θ", "aft"), + }; + let beside = |p: Pos| { + [(0, -1), (1, 0), (0, 1), (-1, 0)] + .into_iter() + .any(|(dx, dy)| layout(p.offset(dx, dy)) == goal) + }; + if beside(self.player) { + self.say(format!("You're beside {what}.")); + return; + } + // Breadth first, the way you walk: a step in any of eight directions. + let mut seen = std::collections::HashSet::from([self.player]); + let mut queue = std::collections::VecDeque::from([self.player]); + while let Some(p) = queue.pop_front() { + if beside(p) { + self.player = p; + self.say(format!("You walk {way} to {what}.")); + if let Some(text) = inventory::underfoot(&inventory::lying(&ctx.hold.deck, p)) { + self.say(text); + } + return; + } + for dy in -1..=1 { + for dx in -1..=1 { + let next = p.offset(dx, dy); + if self.cell(next, &ctx.hold).walkable() && seen.insert(next) { + queue.push_back(next); + } + } + } + } + self.say(format!("There's no way through to {what}.")); + } + + /// Esc: which way to go to leave. + fn way_out(&self) -> String { + match self.berth { + Berth::Landed(_) => "To take off, sit in the pilot's seat ╥, forward in the cockpit \ + ([<] walks you there). To go out, it's the airlock Θ, aft ([>])." + .into(), + Berth::Adrift(_) => "To fly on, sit in the pilot's seat ╥, forward in the cockpit \ + ([<] walks you there)." + .into(), + } + } + + fn view_out(&self, ctx: &Ctx) -> String { + match self.berth { + Berth::Landed(place) => { + let name = &ctx.system.places[place].name; + match ctx.system.landing(place) { + Landing::Capital(_) => format!( + "Through the windscreen: the landing field, and beyond it the walls of \ + {name}'s capital." + ), + Landing::Market(_) => { + format!( + "Through the windscreen: the docking bay of {name}, and dockhands at work." + ) + } + _ => format!("Through the windscreen: {name}, empty as far as you can see."), + } + } + Berth::Adrift(_) => { + format!("Through the windscreen: the stars. The ship is {}.", self.whereabouts) + } + } + } + + /// What you see when you bump `fixture`, at `at`. + fn about(&self, fixture: Fixture, at: Pos, ctx: &Ctx) -> String { + let h = &ctx.hold; + let port = at.y < MIDSHIPS; + match fixture { + Fixture::FlightConsole => "The flight console. Sit in the pilot's seat to fly.".into(), + Fixture::NavConsole => { + format!("The nav console: the {} system, day {:.1}.", ctx.system.name, ctx.day) + } + Fixture::Comms => "The comms board. Nothing on the open channels but static.".into(), + Fixture::CrewSeat if port => "The co-pilot's seat, with a jacket slung over it.".into(), + Fixture::CrewSeat => "The engineer's seat, worn smooth.".into(), + Fixture::Drive => { + let side = if port { "port" } else { "starboard" }; + match self.berth { + Berth::Landed(_) => format!("The {side} drive, cold and ticking as it cools."), + Berth::Adrift(_) => { + format!("The {side} drive, idling, holding the ship on station.") + } + } + } + Fixture::Workbench => { + "A workbench, with tools clipped to the bulkhead above it.".into() + } + Fixture::Bunk => "A bunk, made up tight.".into(), + Fixture::Couch => "The med-bay couch, its straps buckled down.".into(), + Fixture::MedCabinet => match stored(h, |i| matches!(i, Item::Medpatch)).count() { + 0 => "The med cabinet is empty. Doctors in the cities sell medpatches.".into(), + 1 => "The med cabinet holds a medpatch. Pack it (i) to take it aboard a wreck." + .into(), + n => format!( + "The med cabinet holds {n} medpatches. Pack them (i) to take them aboard \ + a wreck." + ), + }, + Fixture::Shelf => { + let names: Vec<String> = + stored(h, |i| matches!(i, Item::Artifact(_))).map(|i| i.name()).collect(); + if names.is_empty() { + "An empty shelf, for the strange things you find aboard wrecks.".into() + } else { + format!("On the shelf: {}.", listed(&names)) + } + } + Fixture::Locker => "Lockers of spare parts, coolant and air filters.".into(), + Fixture::Crate => "A crate of ration bricks, lashed to the deck.".into(), + Fixture::Table => "The galley table, bolted to the deck.".into(), + Fixture::Chair => "A chair, bolted down in case the gravity goes.".into(), + Fixture::Galley => "The galley: a hotplate, a kettle and a water still.".into(), + Fixture::Fern => "A potted fern. Against the odds, it's thriving.".into(), + } + } +} + +/// One of each thing in the lockers that's `kind`, counting stacks out. +fn stored(h: &Hold, kind: impl Fn(&Item) -> bool) -> impl Iterator<Item = &Item> { + h.lockers + .iter() + .filter(move |s| kind(&s.item)) + .flat_map(|s| std::iter::repeat_n(&s.item, s.count as usize)) +} + +/// What the inventory's shown: what you carry, beside the lockers or the +/// deck where you stand (`floor`). +fn view<'a>(ctx: &'a Ctx, beside: Beside, floor: &'a [Stack]) -> View<'a> { + let pile = match beside { + Beside::Lockers => &ctx.hold.lockers[..], + Beside::Floor(_) | Beside::Rack => floor, + }; + View { + gear: &ctx.hold.gear, + beside: (beside == Beside::Lockers || !pile.is_empty()).then_some((beside, pile)), + known: &ctx.bestiary, + hurt: false, + shop: None, + sort: ctx.inventory_sort, + } +} + +/// What the ore bins hold. +fn hold_text(h: &Hold) -> String { + let tons = h.ore_tons(); + if tons == 0 { + return format!( + "The ore bins are empty. They take {} t; the mining laser fills them.", + h.ore_capacity + ); + } + let kinds: Vec<String> = + h.ore.iter().filter(|&(_, &t)| t > 0).map(|(name, t)| format!("{t} t of {name}")).collect(); + format!("The ore bins hold {}: {tons} of {} t.", listed(&kinds), h.ore_capacity) +} + +/// "a, b and c". +fn listed(items: &[String]) -> String { + match items { + [] => String::new(), + [one] => one.clone(), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) +} + +impl Scene for Aboard { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + self.come_back(); + if let Some(pause) = &mut self.pause { + let Some(choice) = pause.key(key) else { return Transition::None }; + self.pause = None; + return match choice { + Choice::Quit => Transition::Title, + Choice::Here | Choice::Resume => Transition::None, + }; + } + if self.inventory.is_some() { + self.inventory_key(key, ctx); + return Transition::None; + } + match key.code { + KeyCode::Char('c' | 'C') => return Transition::Push(Box::new(Sheet::new())), + KeyCode::Char('i') => { + self.inventory = Some((Inventory::new(), Beside::Lockers)); + return Transition::None; + } + KeyCode::Char('g' | ',') => { + self.pick_up(ctx); + return Transition::None; + } + KeyCode::Char('<') => { + self.walk_to(Cell::PilotSeat, ctx); + return Transition::None; + } + KeyCode::Char('>') => { + self.walk_to(Cell::Airlock, ctx); + return Transition::None; + } + KeyCode::Esc => { + self.pause = Some(Pause::new(None, Some(self.way_out()))); + return Transition::None; + } + _ => {} + } + match keys::movement(key) { + Some((dx, dy, true)) => self.run(dx, dy, ctx), + Some((dx, dy, false)) => self.step(dx, dy, ctx), + None => Transition::None, + } + } + + fn tick(&mut self, _ctx: &mut Ctx) -> Transition { + self.come_back(); + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let [main, panel] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) + .areas(frame.area()); + let [map, log] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(LOG_HEIGHT)]).areas(main); + self.draw_map(frame.buffer_mut(), map, ctx); + self.draw_log(frame, log); + self.draw_panel(frame, panel, ctx); + if let Some((inv, beside)) = &self.inventory { + let floor = inventory::lying(&ctx.hold.deck, self.player); + inv.render(frame, frame.area(), &view(ctx, *beside, &floor)); + } + if let Some(pause) = &self.pause { + pause.render(frame, frame.area()); + } + } +} + +/// A wall's glyph, joined up with the walls around it. +fn wall_glyph(p: Pos, glyphs: &[char; 16]) -> char { + let joins = |dx, dy| usize::from(layout(p.offset(dx, dy)).joins()); + glyphs[joins(0, -1) | joins(1, 0) << 1 | joins(0, 1) << 2 | joins(-1, 0) << 3] +} + +/// A console's lights, a few of them off at any moment. +fn lights(p: Pos, ticks: u64, on: Rgb) -> Rgb { + let k = (ticks / 10) as i64 + i64::from(p.x) * 7 + i64::from(p.y) * 3; + if k.rem_euclid(5) == 0 { mix(on, (0, 0, 0), 0.55) } else { on } +} + +fn bin_look((tons, ore): (u32, Option<Rgb>)) -> (char, Rgb) { + match ore { + Some(colour) if tons >= BIN_TONS => ('■', colour), + Some(colour) if tons > 0 => ('▪', colour), + _ => ('□', (96, 100, 116)), + } +} + +/// What's out of the windscreen when you're down: the ground, or the dock. +fn outside_rgb(ctx: &Ctx, place: usize) -> Rgb { + let s = &ctx.system; + match s.places[place].kind { + PlaceKind::Planet(p) => s.planets[p].kind.rgb, + PlaceKind::Moon(p, m) => s.planets[p].moons[m].kind.rgb, + PlaceKind::Station(_) | PlaceKind::Belt => (150, 160, 180), + } +} + +impl Aboard { + fn draw_map(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { + let left = camera(self.player.x, WIDTH, i32::from(area.width)); + let top = camera(self.player.y, HEIGHT, i32::from(area.height)); + let racks = racks(&ctx.hold); + let bins: Vec<Pos> = + self.bins.iter().filter(|&&(set, _)| set <= racks).map(|&(_, p)| p).collect(); + let contents = bin_contents(&ctx.hold, bins.len()); + for sy in 0..area.height { + for sx in 0..area.width { + let p = Pos::new(left + i32::from(sx), top + i32::from(sy)); + let (glyph, colour, bold) = if p == self.player { + ('@', (255, 255, 255), true) + } else { + self.look(p, ctx, &bins, &contents) + }; + let cell = &mut buf[(area.x + sx, area.y + sy)]; + cell.set_char(glyph).set_fg(rgb(colour)); + if bold { + cell.modifier.insert(Modifier::BOLD); + } + } + } + } + + /// How the cell at `p` looks, leaving out you. + fn look( + &self, + p: Pos, + ctx: &Ctx, + bins: &[Pos], + contents: &[(u32, Option<Rgb>)], + ) -> (char, Rgb, bool) { + let ticks = ctx.ticks; + // What's put down on the deck, the last thing on top. + if let Some((_, stack)) = ctx.hold.deck.iter().rev().find(|(at, _)| *at == p) { + let (glyph, colour) = inventory::look(&stack.item); + return (glyph, colour, false); + } + let (glyph, colour) = match self.cell(p, &ctx.hold) { + Cell::Void => (' ', (0, 0, 0)), + Cell::Hull => (wall_glyph(p, &DOUBLE), (122, 134, 160)), + Cell::Wall => (wall_glyph(p, &SINGLE), (150, 150, 162)), + // Where racks would be bolted, if you bought them. + Cell::Deck if matches!(layout(p), Cell::Bin(_)) => ('·', (84, 88, 104)), + Cell::Deck => ('.', (100, 104, 120)), + Cell::Grating => (':', (84, 88, 104)), + Cell::Door => ('\'', (216, 170, 86)), + Cell::Airlock => return ('Θ', (90, 222, 200), true), + Cell::PilotSeat => return ('╥', (240, 200, 110), true), + Cell::Window => match self.berth { + Berth::Landed(place) => ('▒', mix(outside_rgb(ctx, place), (170, 200, 230), 0.3)), + Berth::Adrift(_) => { + let twinkle = (ticks / 20) as i64 + i64::from(p.x) * 7 + i64::from(p.y) * 11; + if twinkle.rem_euclid(13) == 0 { + ('·', (220, 226, 255)) + } else { + ('░', (44, 52, 96)) + } + } + }, + Cell::Bin(_) => { + let i = bins.iter().position(|&b| b == p); + bin_look(i.and_then(|i| contents.get(i).copied()).unwrap_or((0, None))) + } + Cell::Fixture(fixture) => self.fixture_look(fixture, p, ctx), + }; + (glyph, colour, false) + } + + fn fixture_look(&self, fixture: Fixture, p: Pos, ctx: &Ctx) -> (char, Rgb) { + let ticks = ctx.ticks; + let h = &ctx.hold; + match fixture { + Fixture::FlightConsole => ('▤', lights(p, ticks, (110, 220, 150))), + Fixture::NavConsole => ('▤', lights(p, ticks, (110, 180, 240))), + Fixture::Comms => ('▤', lights(p, ticks, (230, 170, 90))), + Fixture::CrewSeat => ('╥', (150, 140, 124)), + Fixture::Drive => match self.berth { + Berth::Landed(_) => ('¤', (96, 104, 130)), + Berth::Adrift(_) => { + let k = (ticks as f64 / 6.0 + f64::from(p.x + p.y)).sin() * 0.5 + 0.5; + ('¤', mix((80, 110, 210), (190, 220, 255), k)) + } + }, + Fixture::Workbench => ('╦', (150, 156, 172)), + Fixture::Bunk => ('▬', (120, 140, 190)), + Fixture::Couch => ('▬', (210, 214, 226)), + Fixture::MedCabinet if stored(h, |i| matches!(i, Item::Medpatch)).next().is_some() => { + ('+', (240, 96, 96)) + } + Fixture::MedCabinet => ('+', (120, 64, 64)), + Fixture::Shelf => { + let i = self.shelf.iter().position(|&s| s == p).unwrap_or(0); + if i < stored(h, |i| matches!(i, Item::Artifact(_))).count() { + ('*', (255, 214, 92)) + } else { + ('≡', (130, 112, 84)) + } + } + Fixture::Locker => ('▪', (130, 140, 160)), + Fixture::Crate => ('■', (170, 140, 96)), + Fixture::Table => ('╤', (150, 156, 172)), + Fixture::Chair => ('╥', (120, 124, 140)), + Fixture::Galley => ('◘', (170, 176, 190)), + Fixture::Fern => ('♣', (90, 170, 80)), + } + } + + fn draw_log(&self, frame: &mut Frame, area: Rect) { + let shown = usize::from(area.height.saturating_sub(1)); + let first = self.log.len().saturating_sub(shown); + let lines: Vec<Line> = self.log[first..] + .iter() + .enumerate() + .map(|(i, text)| { + let age = self.log.len() - first - 1 - i; + let line = Line::from(text.as_str()); + match age { + 0 => line.white(), + 1 | 2 => line.gray(), + _ => line.dark_gray(), + } + }) + .collect(); + let block = Block::new().borders(Borders::TOP).border_style(Style::new().dark_gray()); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); + } + + fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { + let block = Block::bordered() + .border_style(Style::new().dark_gray()) + .title(Line::from(format!(" {} ", ctx.character.ship_name())).bold().fg(ACCENT)) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + let (seat, airlock) = match self.berth { + Berth::Landed(place) => ( + "pilot's seat ╥: take off", + match ctx.system.landing(place) { + Landing::Market(_) => "airlock Θ: to the station", + _ => "airlock Θ: go out", + }, + ), + Berth::Adrift(Some(_)) => ("pilot's seat ╥: fly on", "airlock Θ: board the wreck"), + Berth::Adrift(None) => ("pilot's seat ╥: fly on", "airlock Θ: only vacuum"), + }; + let hints = keys::packed( + &[ + ("arrows hjkl yubn", "walk"), + ("shift", "run"), + ("g", "pick up"), + ("i", "inventory"), + ("c", "character"), + ("bump", "look at things"), + ("<", seat), + (">", airlock), + ("esc", "menu: save and quit"), + ], + inner.width, + ); + let [info, help] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(hints.len() as u16 + 1)]) + .areas(inner); + + let h = &ctx.hold; + let label = |text: &'static str| Span::raw(text).fg(DIM); + let room = room_at(self.player).unwrap_or("aboard"); + let mut lines = vec![ + Line::from("your ship").fg(DIM), + Line::from(self.whereabouts.clone()).fg(DIM), + Line::default(), + Line::from(capitalise(room)).fg(ACCENT), + ]; + if room != "the cockpit" { + lines.push(Line::from(" the cockpit is forward").fg(DIM)); + } + let somewhere_to_go = !matches!(self.berth, Berth::Adrift(None)); + if room != "the hold" && somewhere_to_go { + lines.push(Line::from(" the airlock is aft").fg(DIM)); + } + lines.extend([ + Line::default(), + Line::from(vec![label("money "), Span::raw(format!("{} ¤", h.scrip))]), + Line::from(vec![ + label("ore "), + Span::raw(format!("{}/{} t", h.ore_tons(), h.ore_capacity)), + ]), + Line::from(vec![ + label("fuel "), + Span::raw(format!("{}/{} jumps", h.fuel, h.fuel_capacity)), + ]), + Line::from(vec![label("lenses "), Span::raw(ctx.lenses.to_string())]), + Line::from(vec![ + label("carrying "), + Span::raw(format!( + "{}/{} kg", + items::kg(h.gear.carried()), + items::kg(h.gear.capacity) + )), + ]), + Line::from(vec![ + label("lockers "), + Span::raw(format!( + "{}/{} kg", + items::kg(h.locker_mass()), + items::kg(items::LOCKER_CAPACITY) + )), + ]), + Line::from(vec![label("medpatches "), Span::raw(h.medpatches().to_string())]), + ]); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info); + + // A line between what's here and the keys. + let [_, help] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(help); + frame.render_widget(Paragraph::new(hints), help); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + use std::collections::{HashMap, VecDeque}; + + use super::*; + use crate::names; + use crate::scenes::tests::test_ctx; + use crate::seed; + + fn press(scene: &mut Aboard, ctx: &mut Ctx, code: KeyCode) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + fn key_towards(dx: i32, dy: i32) -> KeyCode { + match (dx, dy) { + (-1, 0) => KeyCode::Left, + (1, 0) => KeyCode::Right, + (0, -1) => KeyCode::Up, + (0, 1) => KeyCode::Down, + (-1, -1) => KeyCode::Home, + (1, -1) => KeyCode::PageUp, + (-1, 1) => KeyCode::End, + _ => KeyCode::PageDown, + } + } + + /// The steps from `from` to every cell you can walk to, as a way back: + /// each cell's previous one. + fn paths(scene: &Aboard, hold: &Hold, from: Pos) -> HashMap<Pos, Pos> { + let mut came_from = HashMap::from([(from, from)]); + let mut queue = VecDeque::from([from]); + while let Some(p) = queue.pop_front() { + for n in p.neighbours() { + if scene.cell(n, hold).walkable() && !came_from.contains_key(&n) { + came_from.insert(n, p); + queue.push_back(n); + } + } + } + came_from + } + + /// Walks, one key at a time, to `to`, and then a step on towards + /// `bump`, returning what that last step did. + fn walk(scene: &mut Aboard, ctx: &mut Ctx, to: Pos, bump: Pos) -> Transition { + let came_from = paths(scene, &ctx.hold, scene.player); + let mut route = vec![to]; + while let Some(&before) = came_from.get(route.last().unwrap()) + && before != scene.player + { + route.push(before); + } + assert!(came_from.contains_key(&to), "can't walk to {to:?}"); + for &p in route.iter().rev() { + let (dx, dy) = (p.x - scene.player.x, p.y - scene.player.y); + assert!(matches!(press(scene, ctx, key_towards(dx, dy)), Transition::None)); + assert_eq!(scene.player, p); + } + press(scene, ctx, key_towards(bump.x - to.x, bump.y - to.y)) + } + + fn airlock() -> Pos { + positions(b'A').next().unwrap() + } + + #[test] + fn the_layout_is_whole() { + for row in LAYOUT { + for ch in row.bytes() { + assert!(parse(ch).is_some(), "{:?} isn't a cell", ch as char); + } + } + assert_eq!(positions(b'P').count(), 1); + assert_eq!(positions(b'A').count(), 1); + // Everything walkable is in a room, and you can get to all of it, + // and to everything you can bump, from the pilot's seat. + let mut ctx = test_ctx(); + ctx.hold.ore_capacity = Hold::BASE_ORE_CAPACITY; + let scene = Aboard::adrift("nowhere", None); + let reached = paths(&scene, &ctx.hold, scene.player); + for y in 0..HEIGHT { + for x in 0..WIDTH { + let p = Pos::new(x, y); + let cell = scene.cell(p, &ctx.hold); + if cell.walkable() { + assert!(reached.contains_key(&p), "can't reach {p:?}"); + assert!(room_at(p).is_some(), "{p:?} isn't in a room"); + } + // The flight console is in front of the pilot's seat, out of + // reach unless you're sitting in it. + let bumpable = matches!(cell, Cell::Fixture(_) | Cell::Bin(_) | Cell::Airlock) + && cell != Cell::Fixture(Fixture::FlightConsole); + if bumpable || cell == Cell::PilotSeat { + assert!(p.neighbours().any(|n| reached.contains_key(&n)), "can't reach {p:?}"); + } + } + } + } + + #[test] + fn there_is_a_bin_for_every_five_tons() { + let mut hold = Hold::default(); + let all = bins(); + for _ in 0..4 { + let racks = racks(&hold); + let count = all.iter().filter(|&&(set, _)| set <= racks).count() as u32; + assert_eq!(count * BIN_TONS, hold.ore_capacity); + hold.ore_capacity += trade::RACK_TONS; + } + hold.ore_capacity = Hold::BASE_ORE_CAPACITY; + hold.ore.insert("nickel", 12); + hold.ore.insert("iridium", 3); + let contents = bin_contents(&hold, 8); + let tons: Vec<u32> = contents.iter().map(|c| c.0).collect(); + assert_eq!(tons, [5, 5, 5, 0, 0, 0, 0, 0]); + let colour = |name: &str| ORES.iter().find(|o| o.name == name).unwrap().rgb; + // Ores go in by name: the first bin has 3 t of iridium in it, and + // 2 t of nickel on top. + assert_eq!(contents[0].1, Some(colour("iridium"))); + assert_eq!(contents[2].1, Some(colour("nickel"))); + assert_eq!(contents[3].1, None); + } + + #[test] + fn you_walk_aft_to_go_ashore_and_forward_to_take_off() { + let mut ctx = test_ctx(); + let home = ctx.system.home_place(); + let mut scene = Aboard::landed(&ctx, home); + assert_eq!(room_at(scene.player), Some("the cockpit")); + let door = airlock().offset(1, 0); + let out = walk(&mut scene, &mut ctx, door, airlock()); + assert!(matches!(out, Transition::Push(_)), "the airlock opens on the city"); + assert_eq!(scene.outside, Some(Outside::City)); + // Back from the city, you're just inside the airlock. + scene.tick(&mut ctx); + assert!(scene.log.last().unwrap().contains("seal the airlock")); + assert_eq!(scene.player, door); + let stand = seat().offset(-1, 0); + assert!(matches!(walk(&mut scene, &mut ctx, stand, seat()), Transition::Pop)); + } + + #[test] + fn angle_brackets_walk_you_to_the_airlock_and_the_seat() { + let mut ctx = test_ctx(); + let home = ctx.system.home_place(); + let mut scene = Aboard::landed(&ctx, home); + press(&mut scene, &mut ctx, KeyCode::Char('>')); + assert_eq!(layout(scene.player.offset(-1, 0)), Cell::Airlock, "beside it, not through"); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('h')), Transition::Push(_))); + press(&mut scene, &mut ctx, KeyCode::Char('<')); + assert_eq!(layout(scene.player.offset(1, 0)), Cell::PilotSeat); + let said = scene.said; + press(&mut scene, &mut ctx, KeyCode::Char('<')); + assert_eq!(scene.said, said + 1, "you're told you're there already"); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::Pop)); + } + + #[test] + fn running_stops_at_doorways() { + let mut ctx = test_ctx(); + let mut scene = Aboard::adrift("nowhere", None); + let run = |scene: &mut Aboard, ctx: &mut Ctx| { + scene.key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), ctx) + }; + run(&mut scene, &mut ctx); + assert_eq!(scene.player, Pos::new(40, MIDSHIPS), "out of the cockpit"); + run(&mut scene, &mut ctx); + assert_eq!(scene.player, Pos::new(18, MIDSHIPS), "down the corridor"); + assert!(matches!(run(&mut scene, &mut ctx), Transition::None)); + assert_eq!(scene.player, airlock().offset(1, 0), "across the hold, to vacuum"); + assert!(scene.log.last().unwrap().contains("vacuum")); + } + + #[test] + fn the_airlock_boards_a_wreck_alongside_once() { + let mut ctx = test_ctx(); + let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); + let mut scene = Aboard::adrift("nowhere", Some(wreck.clone())); + scene.player = airlock().offset(1, 0); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Left), Transition::Push(_))); + assert!(ctx.stripped.contains(&wreck.seed)); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Left), Transition::None)); + assert!(scene.log.last().unwrap().contains("already stripped")); + } + + #[test] + fn the_hold_and_the_shelf_show_what_you_carry() { + let mut ctx = test_ctx(); + let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); + let shelf = positions(b'*').next().unwrap(); + scene.player = shelf.offset(0, 1); + press(&mut scene, &mut ctx, KeyCode::Up); + assert!(scene.log.last().unwrap().starts_with("An empty shelf")); + let artifact = names::artifact(&mut seed::rng(4)); + let name = artifact.name().to_string(); + ctx.hold.lockers.push(Stack::one(Item::Artifact(artifact))); + press(&mut scene, &mut ctx, KeyCode::Up); + assert_eq!(scene.log.last().unwrap(), &format!("On the shelf: {name}.")); + + ctx.hold.ore.insert("nickel", 7); + let (_, bin) = bins()[0]; + scene.player = bin.offset(0, 1); + press(&mut scene, &mut ctx, KeyCode::Up); + assert_eq!(scene.log.last().unwrap(), "The ore bins hold 7 t of nickel: 7 of 40 t."); + // Racks you haven't bought are bare deck you can walk over. + let (_, rack) = *bins().iter().find(|&&(set, _)| set == 1).unwrap(); + assert!(scene.cell(rack, &ctx.hold).walkable()); + ctx.hold.ore_capacity += trade::RACK_TONS; + assert_eq!(scene.cell(rack, &ctx.hold), Cell::Bin(1)); + } + + #[test] + fn what_you_put_down_on_the_deck_stays_there() { + let mut ctx = test_ctx(); + let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); + let wire = + Item::Salvage(items::Salvage { title: "coil of wire".into(), value: 9, mass: 2000 }); + ctx.hold.gear.add(Stack { item: wire.clone(), count: 3 }); + let start = scene.player; + + press(&mut scene, &mut ctx, KeyCode::Char('i')); + assert!(scene.inventory.is_some()); + let shown = screen(&scene, &ctx); + assert!(shown.contains("YOU ·") && shown.contains("LOCKERS"), "{shown}"); + for c in "/wire".chars() { + press(&mut scene, &mut ctx, KeyCode::Char(c)); + } + press(&mut scene, &mut ctx, KeyCode::Enter); + for c in "2d".chars() { + press(&mut scene, &mut ctx, KeyCode::Char(c)); + } + press(&mut scene, &mut ctx, KeyCode::Char('m')); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(scene.inventory.is_some(), "esc clears what you were finding first"); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(scene.inventory.is_none()); + assert_eq!( + inventory::lying(&ctx.hold.deck, start), + [Stack { item: wire.clone(), count: 2 }] + ); + assert_eq!(ctx.hold.lockers, [Stack::one(wire.clone())]); + assert!(ctx.hold.gear.pack.iter().all(|s| s.item != wire)); + + // Walk off and it's on the deck; walk back on and you're told. + press(&mut scene, &mut ctx, KeyCode::Char('h')); + assert_ne!(scene.player, start); + let (glyph, _, _) = scene.look(start, &ctx, &[], &[]); + assert_eq!(glyph, '('); + press(&mut scene, &mut ctx, KeyCode::Char('l')); + assert!(scene.log.last().unwrap().contains("2 coils of wire here")); + press(&mut scene, &mut ctx, KeyCode::Char('g')); + assert!(ctx.hold.deck.is_empty()); + assert!(ctx.hold.gear.pack.contains(&Stack { item: wire, count: 2 })); + } + + #[test] + fn the_weapons_locker_opens_the_lockers() { + let mut ctx = test_ctx(); + let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); + let locker = positions(b'l').find(|p| p.y < MIDSHIPS).unwrap(); + let from = locker.neighbours().find(|&n| scene.cell(n, &ctx.hold).walkable()).unwrap(); + scene.player = from; + press(&mut scene, &mut ctx, key_towards(locker.x - from.x, locker.y - from.y)); + assert!(matches!(scene.inventory, Some((_, Beside::Lockers)))); + } + + fn screen(scene: &Aboard, ctx: &Ctx) -> String { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap(); + terminal.draw(|frame| scene.render(frame, ctx)).unwrap(); + terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect() + } +} diff --git a/src/scenes/character.rs b/src/scenes/character.rs new file mode 100644 index 0000000..1ed2b11 --- /dev/null +++ b/src/scenes/character.rs @@ -0,0 +1,821 @@ +//! 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::<Vec<_>>().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<String>, +} + +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::<u64>() { + 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::<u64>(); + 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<Entry>, + /// 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<Line<'static>> { + 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<Line<'static>> { + 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<String> = 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<Line<'static>> { + 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> = + 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<_>>(), 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"); + } +} diff --git a/src/scenes/derelict.rs b/src/scenes/derelict.rs index fc18f50..5b705a4 100644 --- a/src/scenes/derelict.rs +++ b/src/scenes/derelict.rs @@ -1,5 +1,8 @@ //! Aboard a derelict: the map, your status, and the log. The rules live in -//! `dungeon::crawl`; this turns keys into actions and draws the result. +//! `dungeon::crawl`; this turns keys into actions and draws the result, +//! including the line of fire while you aim, and shots in flight. `i` opens +//! the inventory (`scenes::inventory`), with what's on the floor beside it, +//! and `c` your character sheet, with what you carry here. use ratatui::Frame; use ratatui::buffer::Buffer; @@ -9,14 +12,28 @@ use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}; +use super::character::Sheet; +use super::inventory::{self, Beside, Command, Inventory, Reply, View}; +use super::keys::{self, direction}; +use super::pause::{Choice, Pause}; use super::{Scene, Transition}; use crate::app::Ctx; -use crate::dungeon::crawl::{Action, Crawl, Item, Outcome, SIGHT_RADIUS}; +use crate::dungeon::arms::{Dmg, Weapon}; +use crate::dungeon::crawl::{Action, Affinity, Crawl, Monster, Outcome, SIGHT_RADIUS, Trace}; use crate::dungeon::{Pos, Tile}; +use crate::items::{self, Gear, Item, Stack}; use crate::seed; const PANEL_WIDTH: u16 = 34; const LOG_HEIGHT: u16 = 7; +/// How fast shots cross the screen: cells for every two ticks. +const SHOT_SPEED: u64 = 3; +/// How long a shot's impact shows, in cells' worth of flight. +const IMPACT: u64 = 6; +/// Most monsters listed as in view. +const IN_VIEW: usize = 6; +/// The most steps a run takes. +const RUN_LIMIT: usize = 80; type Rgb = (u8, u8, u8); /// Cells you've seen but can't see now. @@ -24,18 +41,113 @@ const REMEMBERED: Rgb = (64, 68, 84); /// The hull's outline, seen from outside as you docked. const SILHOUETTE: Rgb = (40, 44, 60); const STAR: Rgb = (72, 72, 92); +const LINE_OF_FIRE: Color = Color::Rgb(46, 40, 20); +const RETICLE: Color = Color::Rgb(160, 128, 40); + +#[derive(Clone, Copy)] +enum Mode { + Explore, + /// Picking where to fire. + Aim(Pos), + /// Asking whether to leave by the emergency hatch. + Abandon, +} pub struct Derelict { crawl: Crawl, - /// Asking whether to leave by the emergency hatch. - abandoning: bool, + mode: Mode, + /// Open over the map, when you're looking through what you carry. + inventory: Option<Inventory>, + /// What you came aboard with, to tell what you found. + brought: Gear, /// Once the crawl is over: a title and lines for the closing summary. ending: Option<(&'static str, Vec<String>)>, + /// The tick this turn's shots set off, for drawing them in flight. + volley_at: u64, + /// The menu `esc` opens. + pause: Option<Pause>, } impl Derelict { pub fn new(seed: u64) -> Self { - Derelict { crawl: Crawl::new(seed), abandoning: false, ending: None } + Derelict { + crawl: Crawl::new(seed), + mode: Mode::Explore, + inventory: None, + brought: Gear::default(), + ending: None, + volley_at: 0, + pause: None, + } + } + + /// Back aboard the wreck a save left you in, just as you left it: what + /// you carried, what you'd seen, what's still hunting you. + pub fn resumed(ctx: &Ctx) -> Option<Derelict> { + let mut crawl = Crawl::resume(ctx.crawl.as_ref()?)?; + crawl.known.clone_from(&ctx.bestiary); + crawl.say(format!("You're back aboard {}, where you left off.", crawl.name)); + let mut brought = ctx.hold.gear.clone(); + brought.reload_all(); + Some(Derelict { + crawl, + mode: Mode::Explore, + inventory: None, + brought, + ending: None, + volley_at: 0, + pause: None, + }) + } + + /// Boards a derelict with everything you carry. Whatever's left of it + /// comes back with you if you get out; your own torch and rivet gun come + /// back whatever happens. What's in your ship's lockers stays safe + /// aboard. What you know about what lives aboard wrecks, you know here + /// too. + /// + /// The hold isn't touched until the crawl ends. The crawl itself is + /// kept in `ctx.crawl` after every move, so a game saved halfway + /// through (or quit) comes back aboard the wreck. + pub fn board(seed: u64, ctx: &mut Ctx) -> Self { + let mut derelict = Derelict::new(seed); + ctx.record.boarded += 1; + let c = &mut derelict.crawl; + c.gear = ctx.hold.gear.clone(); + c.gear.reload_all(); + c.known.clone_from(&ctx.bestiary); + derelict.brought = c.gear.clone(); + let mut brought: Vec<String> = + everything(&c.gear).iter().filter(|s| !s.item.is_kit()).map(Stack::name).collect(); + if !brought.is_empty() { + if brought.len() > 4 { + brought.truncate(3); + brought.push("more".into()); + } + let brought = listed(&brought); + c.say(format!("You've brought {brought}.")); + } + ctx.crawl = Some(derelict.crawl.saved()); + derelict + } + + /// Takes a turn (or doesn't), and keeps up with what came of it. + fn act(&mut self, action: Action, ctx: &mut Ctx) { + self.crawl.act(action); + if !self.crawl.volley.is_empty() { + self.volley_at = ctx.ticks; + } + ctx.bestiary.extend(self.crawl.known.iter().copied()); + for kind in self.crawl.slain.drain(..) { + *ctx.record.slain.entry(kind.to_string()).or_default() += 1; + } + ctx.crawl = self.crawl.outcome.is_none().then(|| self.crawl.saved()); + if let Some(outcome) = self.crawl.outcome + && self.ending.is_none() + { + self.mode = Mode::Explore; + self.finish(outcome, ctx); + } } /// Called once, when the crawl ends: banks what you carried if you got @@ -44,47 +156,190 @@ impl Derelict { let c = &self.crawl; let lines = match outcome { Outcome::Escaped => { - ctx.hold.artifacts.extend(c.artifacts.iter().cloned()); - ctx.hold.scrip += c.scrip; - let mut lines = vec![ - format!("You cast off from {}.", c.name), - String::new(), - format!( - "Recovered {} and {} ¤ of salvage.", - count(c.artifacts.len(), "artifact"), - c.scrip - ), - ]; - lines.extend(c.artifacts.iter().map(|a| format!(" * {}", a.unidentified))); + ctx.record.escaped += 1; + ctx.hold.gear = c.gear.clone(); + let found = gained(&self.brought, &c.gear); + let mut lines = vec![format!("You cast off from {}.", c.name), String::new()]; + if found.is_empty() { + lines.push("You come away with nothing new.".into()); + } else { + lines.push("You come away with:".into()); + lines.extend(found.iter().map(|s| { + let (glyph, _) = inventory::look(&s.item); + format!(" {glyph} {}", s.name()) + })); + } + let worth: u32 = found + .iter() + .map(|s| match &s.item { + Item::Salvage(v) => v.value * s.count, + _ => 0, + }) + .sum(); + if worth > 0 { + lines.push(format!("The salvage is worth about {worth} ¤ to a broker.")); + } lines.push(String::new()); lines.push(format!( - "Your hold now carries {} and {} ¤.", - count(ctx.hold.artifacts.len(), "artifact"), - ctx.hold.scrip + "You carry {} of {} kg. Stow what you don't need in the lockers [i].", + items::kg(c.gear.carried()), + items::kg(c.gear.capacity) )); lines } - Outcome::Died { killer } => vec![ - format!("The {killer} brings you down aboard {}.", c.name), - "Your crew drags you back to your ship, but".into(), - "everything you carried stays with the wreck.".into(), - ], + Outcome::Died { killer } => { + ctx.record.downed += 1; + ctx.hold.lose_carried(&c.gear); + vec![ + format!("The {killer} brings you down aboard {}.", c.name), + format!("Your crew drags you back to {}, but", ctx.character.ship_name()), + "everything you carried stays with the wreck.".into(), + ] + } }; let title = match outcome { Outcome::Escaped => " SAFE ABOARD YOUR SHIP ", Outcome::Died { .. } => " PULLED BACK FROM THE EDGE ", }; self.ending = Some((title, lines)); + // You may well be holding the key that walked you into the + // airlock: its repeats mustn't wave the summary away unread. + ctx.settle(); + } + + /// Shift and a direction: steps that way until there's something to + /// stop for: something in sight, a wound, something said or underfoot, + /// a doorway, another room, a closed door or the airlock ahead. With + /// something in sight already, it's just a step. + fn run(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) { + for step in 0..RUN_LIMIT { + let c = &self.crawl; + // Opening a door and leaving are steps you take, not ones a + // run carries you into. + let ahead = c.map.tile(c.player.offset(dx, dy)); + if step > 0 && matches!(ahead, Tile::Airlock | Tile::Door { open: false }) { + break; + } + let (from, hp, said) = (c.player, c.hp, c.said); + let room = c.map.room_index(c.player); + self.act(Action::Move(dx, dy), ctx); + let c = &self.crawl; + let stop = c.outcome.is_some() + || c.player == from + || c.hp < hp + || c.said != said + || c.monsters.iter().any(|m| c.fov.contains(m.pos)) + || !c.here().is_empty() + || matches!(c.map.tile(c.player), Tile::Door { .. }) + // Out of a doorway, the first step is into the room. + || (step > 0 && c.map.room_index(c.player) != room); + if stop { + break; + } + } } - fn render_map(&self, buf: &mut Buffer, area: Rect) { + fn explore(&mut self, code: KeyCode) -> Option<Action> { + if let Some((dx, dy)) = direction(code) { + return Some(Action::Move(dx, dy)); + } + match code { + KeyCode::Char('.' | '5') => Some(Action::Wait), + KeyCode::Char('a') => Some(Action::UseMedpatch), + KeyCode::Char('r') => Some(Action::Reload), + KeyCode::Char('g' | ',') if self.crawl.here().len() > 1 => { + self.inventory = Some(Inventory::beside()); + None + } + KeyCode::Char('g' | ',') => Some(Action::PickUp), + KeyCode::Char('f') if self.crawl.gear.gun.loaded == 0 => Some(Action::Reload), + KeyCode::Char('f') => { + let at = self.targets().first().copied().unwrap_or(self.crawl.player); + self.mode = Mode::Aim(at); + None + } + KeyCode::Char('i') => { + self.inventory = Some(Inventory::new()); + None + } + KeyCode::Esc => { + let note = format!( + "Aboard {}. Quit, and you'll come back aboard just as you left it.", + self.crawl.name + ); + self.pause = Some(Pause::new(Some("Abandon ship"), Some(note))); + None + } + _ => None, + } + } + + fn aim(&mut self, code: KeyCode, at: Pos) -> Option<Action> { + if let Some((dx, dy)) = direction(code) { + let to = at.offset(dx, dy); + if self.crawl.map.contains(to) { + self.mode = Mode::Aim(to); + } + return None; + } + match code { + KeyCode::Tab | KeyCode::Char('+' | '=') => self.mode = Mode::Aim(self.cycle(at, 1)), + KeyCode::BackTab | KeyCode::Char('-') => self.mode = Mode::Aim(self.cycle(at, -1)), + KeyCode::Enter | KeyCode::Char('f' | 't') if at != self.crawl.player => { + self.mode = Mode::Explore; + return Some(Action::Fire(at)); + } + KeyCode::Esc | KeyCode::Char('q') => self.mode = Mode::Explore, + _ => {} + } + None + } + + /// Where the things you can see are, nearest first. + fn targets(&self) -> Vec<Pos> { + let c = &self.crawl; + let mut targets: Vec<Pos> = + c.monsters.iter().map(|m| m.pos).filter(|&p| c.fov.contains(p)).collect(); + targets.sort_by_key(|&p| (distance2(c.player, p), p.y, p.x)); + targets + } + + /// The next target after (or before) the one at `at`. + fn cycle(&self, at: Pos, by: isize) -> Pos { + let targets = self.targets(); + match targets.iter().position(|&p| p == at) { + _ if targets.is_empty() => at, + Some(i) => targets[(i as isize + by).rem_euclid(targets.len() as isize) as usize], + None => targets[0], + } + } + + fn render_map(&self, buf: &mut Buffer, area: Rect, ticks: u64) { let c = &self.crawl; let left = camera(c.player.x, c.map.width, i32::from(area.width)); let top = camera(c.player.y, c.map.height, i32::from(area.height)); + let flying = self.in_flight(ticks); + let (aim, line) = match self.mode { + Mode::Aim(at) => (Some(at), c.line_of_fire(at)), + _ => (None, Vec::new()), + }; for sy in 0..area.height { for sx in 0..area.width { let p = Pos::new(left + i32::from(sx), top + i32::from(sy)); - let (glyph, style) = self.look(p); + let (mut glyph, mut style) = self.look(p); + if line.contains(&p) { + style = style.bg(LINE_OF_FIRE); + } + if aim == Some(p) { + style = style.bg(RETICLE); + if p != c.player && c.monster_at(p).is_none() { + (glyph, style) = ('×', style.fg(Color::Black)); + } + } + if let Some(&(_, g, colour)) = flying.iter().find(|(q, ..)| *q == p) { + glyph = g; + style = style.fg(rgb(colour, 1.0)).bold(); + } if let Some(cell) = buf.cell_mut((area.x + sx, area.y + sy)) { cell.set_char(glyph).set_style(style); } @@ -92,6 +347,29 @@ impl Derelict { } } + /// What of this turn's shots is on screen `ticks` in: each round's head + /// and a fading tail while it flies, then a flash where it stops. + fn in_flight(&self, ticks: u64) -> Vec<(Pos, char, Rgb)> { + let c = &self.crawl; + let flown = ticks.saturating_sub(self.volley_at) * SHOT_SPEED / 2; + let mut out = Vec::new(); + for trace in &c.volley { + let Some(flown) = flown.checked_sub(u64::from(trace.delay)) else { continue }; + let (len, colour) = (trace.cells.len() as u64, trace.dmg.rgb()); + let Some(&end) = trace.cells.last() else { continue }; + if flown < len { + out.push((trace.cells[flown as usize], shot_glyph(trace), colour)); + if flown > 0 { + out.push((trace.cells[flown as usize - 1], '·', dim(colour, 0.6))); + } + } else if flown < len + IMPACT { + out.push((end, if trace.hit { '*' } else { '∙' }, colour)); + } + } + out.retain(|(p, ..)| c.fov.contains(*p)); + out + } + /// How the cell at `p` looks from where you stand: lit by your lamp if /// you can see it, from memory if you've seen it, and otherwise only the /// hull's outline against the stars. @@ -105,10 +383,10 @@ impl Derelict { if let Some(m) = c.monster_at(p) { return (m.kind.glyph, Style::new().fg(rgb(m.kind.rgb, 1.0)).bold()); } - let dist = f64::from((p.x - c.player.x).pow(2) + (p.y - c.player.y).pow(2)).sqrt(); + let dist = f64::from(distance2(p, c.player)).sqrt(); let light = 1.0 - 0.6 * (dist / f64::from(SIGHT_RADIUS)).min(1.0); if let Some(loot) = c.loot_at(p) { - let (glyph, colour) = item_look(&loot.item); + let (glyph, colour) = inventory::look(&loot.stack.item); return (glyph, Style::new().fg(rgb(colour, light.max(0.8))).bold()); } let (glyph, colour) = tile_look(tile); @@ -116,7 +394,7 @@ impl Derelict { } if c.seen(p) { if let Some(loot) = c.loot_at(p) { - let (glyph, colour) = item_look(&loot.item); + let (glyph, colour) = inventory::look(&loot.stack.item); return (glyph, Style::new().fg(rgb(colour, 0.45))); } return (tile_look(tile).0, Style::new().fg(rgb(REMEMBERED, 1.0))); @@ -138,45 +416,213 @@ impl Derelict { .padding(Padding::horizontal(1)); let inner = block.inner(area); frame.render_widget(block, area); - let [info, help] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(5)]).areas(inner); + let help = self.help(inner.width); + let [info, keys] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(help.len() as u16)]) + .areas(inner); - let label = |text: &'static str| Span::from(text).dark_gray(); let mut lines = vec![ Line::from(c.name.as_str()).bold().fg(Color::Yellow), Line::from(c.description.as_str()).italic().dark_gray(), Line::default(), Line::from(vec![label("health "), health_bar(c.hp, c.max_hp, 12)]), - Line::from(vec![label("medpatches"), Span::from(format!(" {}", c.medpatches))]), + ]; + if c.bleeding > 0 { + lines.push(Line::from(vec![ + label(" "), + format!("bleeding {}", c.bleeding).red(), + ])); + } + if c.burning > 0 { + lines.push(Line::from(vec![ + label(" "), + Span::from(format!("alight {}", c.burning)).fg(rgb(Dmg::Burn.rgb(), 1.0)), + ])); + } + lines.extend([ + Line::from(vec![label("medpatches"), Span::from(format!(" {}", c.gear.medpatches()))]), Line::from(vec![label("here "), Span::from(c.location())]), Line::from(vec![label("turn "), Span::from(c.turns.to_string())]), Line::default(), - Line::from(vec![label("salvage "), Span::from(format!("{} ¤", c.scrip))]), - Line::from(vec![label("artifacts "), Span::from(c.artifacts.len().to_string())]), - ]; - for artifact in &c.artifacts { - lines.push(Line::from(format!("* {}", artifact.unidentified)).fg(Color::Yellow)); + ]); + + let (blade, gun) = (&c.gear.melee, &c.gear.gun); + let blade_stats = format!("{} · {}%", blade.damage_text(), percent(blade.accuracy)); + lines.extend([ + Line::from(vec![label("melee "), title(blade, 20)]), + Line::from(vec![label(" "), blade_stats.dark_gray()]), + Line::from(vec![label("ranged "), title(gun, 20)]), + Line::from(vec![ + label(" "), + magazine(gun), + format!(" {}", gun.damage_text()).dark_gray(), + ]), + Line::from(vec![ + label("carrying "), + Span::from(format!( + "{} / {} kg", + items::kg(c.gear.carried()), + items::kg(c.gear.capacity) + )), + ]), + Line::default(), + ]); + + match self.mode { + Mode::Aim(at) => self.target_lines(at, &mut lines), + _ => self.in_view_lines(&mut lines), + } + + let found = gained(&self.brought, &c.gear); + if !found.is_empty() { + lines.push(Line::from("found").dark_gray()); + } + for stack in &found { + let (glyph, colour) = inventory::look(&stack.item); + lines.push(Line::from(vec![ + Span::from(format!("{glyph} ")).fg(rgb(colour, 1.0)), + Span::from(stack.name()), + ])); } frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info); + frame.render_widget(Paragraph::new(help), keys); + } - // Pairs of (key, what it does) on one line. - let keys = |pairs: &[(&'static str, &'static str)]| { - Line::from( - pairs - .iter() - .flat_map(|&(k, what)| [Span::from(k).bold(), Span::from(what).dark_gray()]) - .collect::<Vec<_>>(), - ) + /// The monsters you can see, nearest first, with how hurt they are. + fn in_view_lines<'a>(&'a self, lines: &mut Vec<Line<'a>>) { + let c = &self.crawl; + let mut seen: Vec<&Monster> = c.monsters.iter().filter(|m| c.fov.contains(m.pos)).collect(); + if seen.is_empty() { + return; + } + seen.sort_by_key(|m| distance2(c.player, m.pos)); + lines.push(Line::from("in view").dark_gray()); + for m in seen.iter().take(IN_VIEW) { + let mut spans = vec![ + Span::styled( + m.kind.glyph.to_string(), + Style::new().fg(rgb(m.kind.rgb, 1.0)).bold(), + ), + Span::from(format!(" {:<18}", m.kind.name)), + mini_bar(m.hp, m.kind.hp, 6), + ]; + if m.bleeding > 0 { + spans.push("•".red()); + } + if m.burning > 0 { + spans.push(Span::from("*").fg(rgb(Dmg::Burn.rgb(), 1.0))); + } + if m.stunned > 0 { + spans.push("z".yellow()); + } + lines.push(Line::from(spans)); + } + if seen.len() > IN_VIEW { + lines.push(Line::from(format!(" and {} more", seen.len() - IN_VIEW)).dark_gray()); + } + lines.push(Line::default()); + } + + /// What's under the reticle: how hurt it is, the odds, and what you + /// know of how it takes each kind of damage. + fn target_lines<'a>(&'a self, at: Pos, lines: &mut Vec<Line<'a>>) { + let c = &self.crawl; + if at == c.player { + lines.push(Line::from("Nothing in sight to shoot.").dark_gray()); + lines.push(Line::from("Aim with the move keys.").dark_gray()); + lines.push(Line::default()); + return; + } + let away = f64::from(distance2(c.player, at)).sqrt().round(); + let away = + if away == 1.0 { "1 cell away".to_string() } else { format!("{away} cells away") }; + let Some(m) = c.monster_at(at).filter(|m| c.fov.contains(m.pos)) else { + lines.push(Line::from(vec![label("target "), Span::from("nothing there")])); + let range = if c.in_range(at) { "" } else { " · out of range" }; + lines.push(Line::from(format!("{away}{range}")).dark_gray()); + lines.push(Line::default()); + return; }; - frame.render_widget( - Paragraph::new(vec![ - keys(&[("arrows hjkl yubn", " move")]), - keys(&[(".", " wait "), ("m", " medpatch")]), - keys(&[("walk into Θ", " leave with loot")]), - keys(&[("esc", " abandon ship, loot and all")]), - ]), - help, - ); + let kind = m.kind; + lines.push(Line::from(vec![ + label("target "), + Span::styled(kind.name, Style::new().fg(rgb(kind.rgb, 1.0)).bold()), + ])); + let mut state = vec![mini_bar(m.hp, kind.hp, 12)]; + if m.bleeding > 0 { + state.push(format!(" bleeding {}", m.bleeding).red()); + } + if m.burning > 0 { + state.push(Span::from(" burning").fg(rgb(Dmg::Burn.rgb(), 1.0))); + } + if m.stunned > 0 { + state.push(" stunned".yellow()); + } + lines.push(Line::from(state)); + let odds = match c.odds(at) { + _ if !c.in_range(at) => "out of range".to_string(), + Some(p) if c.gear.gun.gun.is_some_and(|g| g.pellets > 1) => { + format!("{}% a pellet", percent(p)) + } + Some(p) => format!("{}% to hit", percent(p)), + None => String::new(), + }; + lines.push(Line::from(format!("{away} · {odds}")).dark_gray()); + + let mut known: [(Affinity, Vec<&str>); 4] = [ + (Affinity::Weak, Vec::new()), + (Affinity::Resists, Vec::new()), + (Affinity::Immune, Vec::new()), + (Affinity::Normal, Vec::new()), + ]; + let mut untried = Vec::new(); + for dmg in Dmg::ALL { + match c.knows(kind, dmg) { + Some(a) => known.iter_mut().find(|(k, _)| *k == a).unwrap().1.push(dmg.name()), + None => untried.push(dmg.name()), + } + } + for (affinity, dmgs) in known { + if dmgs.is_empty() { + continue; + } + let (word, colour) = match affinity { + Affinity::Weak => ("weak to ", Color::Green), + Affinity::Resists => ("resists ", Color::Yellow), + Affinity::Immune => ("immune to ", Color::Red), + Affinity::Normal => ("no matter: ", Color::Gray), + }; + lines.push(Line::from(vec![Span::from(word).fg(colour), Span::from(dmgs.join(", "))])); + } + if !untried.is_empty() { + lines.push(Line::from(format!("untried: {}", untried.join(", "))).dark_gray()); + } + lines.push(Line::default()); + } + + fn help(&self, width: u16) -> Vec<Line<'static>> { + let hints: &[(&str, &str)] = match self.mode { + Mode::Aim(_) => &[ + ("move keys", "aim"), + ("tab", "next target"), + ("f enter", "fire"), + ("esc", "lower it"), + ], + _ => &[ + ("arrows hjkl yubn", "move, attack"), + ("shift", "run"), + ("f", "fire"), + ("r", "reload"), + (".", "wait"), + ("a", "medpatch"), + ("g", "pick up"), + ("i", "inventory"), + ("c", "character"), + ("into Θ", "leave with loot"), + ("esc", "menu: quit or abandon ship"), + ], + }; + keys::packed(hints, width) } fn render_log(&self, frame: &mut Frame, area: Rect) { @@ -201,75 +647,214 @@ impl Derelict { } } +/// Everything on you, as stacks: worn, ready and packed. +fn everything(gear: &Gear) -> Vec<Stack> { + let worn = gear.worn.values().map(|a| Stack::one(Item::Armor(a.clone()))); + let ready = [&gear.melee, &gear.gun].into_iter().map(|w| Stack::one(Item::Weapon(w.clone()))); + worn.chain(ready).chain(gear.pack.iter().cloned()).collect() +} + +/// What you have now that you didn't come aboard with. +fn gained(before: &Gear, after: &Gear) -> Vec<Stack> { + // A gun you've fired is still the gun you brought. + let unloaded = |mut s: Stack| { + if let Item::Weapon(w) = &mut s.item { + w.loaded = 0; + } + s + }; + let mut had: Vec<Stack> = everything(before).into_iter().map(unloaded).collect(); + let mut found = Vec::new(); + for stack in everything(after).into_iter().map(unloaded) { + let mut count = stack.count; + if let Some(old) = had.iter_mut().find(|s| s.item == stack.item) { + let same = old.count.min(count); + old.count -= same; + count -= same; + } + if count > 0 { + items::stow(&mut found, Stack { count, ..stack }); + } + } + found +} + impl Scene for Derelict { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { if self.ending.is_some() { return Transition::Pop; } - if self.abandoning { - self.abandoning = false; - return match key.code { - KeyCode::Char('y' | 'Y') => Transition::Pop, - _ => Transition::None, + if let Some(pause) = &mut self.pause { + return match pause.key(key) { + None => Transition::None, + Some(choice) => { + self.pause = None; + match choice { + Choice::Quit => Transition::Title, + Choice::Here => { + self.mode = Mode::Abandon; + Transition::None + } + Choice::Resume => Transition::None, + } + } }; } - // Vi keys, arrows, and the number pad with or without num lock. - let action = match key.code { - KeyCode::Left | KeyCode::Char('h' | '4') => Action::Move(-1, 0), - KeyCode::Right | KeyCode::Char('l' | '6') => Action::Move(1, 0), - KeyCode::Up | KeyCode::Char('k' | '8') => Action::Move(0, -1), - KeyCode::Down | KeyCode::Char('j' | '2') => Action::Move(0, 1), - KeyCode::Home | KeyCode::Char('y' | '7') => Action::Move(-1, -1), - KeyCode::PageUp | KeyCode::Char('u' | '9') => Action::Move(1, -1), - KeyCode::End | KeyCode::Char('b' | '1') => Action::Move(-1, 1), - KeyCode::PageDown | KeyCode::Char('n' | '3') => Action::Move(1, 1), - KeyCode::Char('.' | '5') => Action::Wait, - KeyCode::Char('m') => Action::UseMedpatch, - KeyCode::Esc => { - self.abandoning = true; - return Transition::None; + if let Some(inv) = &mut self.inventory { + let here: Vec<Stack> = + self.crawl.here().iter().map(|&i| self.crawl.loot[i].stack.clone()).collect(); + let view = View { + gear: &self.crawl.gear, + beside: (!here.is_empty()).then_some((Beside::Floor("on the floor"), &here[..])), + known: &self.crawl.known, + hurt: true, + shop: None, + sort: ctx.inventory_sort, + }; + let action = match inv.key(key, &view) { + Reply::Stay => return Transition::None, + Reply::Close => { + self.inventory = None; + return Transition::None; + } + Reply::Sort(sort) => { + ctx.inventory_sort = sort; + return Transition::None; + } + Reply::Do(command) => match command { + Command::Ready(i) => Action::Ready(i), + Command::Wear(i) => Action::Wear(i), + Command::TakeOff(slot) => Action::TakeOff(slot), + Command::Eat(i) => Action::Eat(i), + Command::UseMedpatch => Action::UseMedpatch, + Command::Drop(spot, n) | Command::Stow(spot, n) => Action::Drop(spot, n), + Command::Fetch(i, n) => Action::Take(i, n), + // There's no one to trade with aboard a wreck. + Command::Buy(_) + | Command::Sell(_) + | Command::SellAboard(_) + | Command::Mend(_) + | Command::MendAboard(_) + | Command::MendAll => { + return Transition::None; + } + }, + }; + let turns = self.crawl.turns; + self.act(action, ctx); + // Anything that took a turn closes it, so you see what happened. + match &mut self.inventory { + Some(_) if self.crawl.turns != turns || self.ending.is_some() => { + self.inventory = None; + } + Some(inv) => inv.note = self.crawl.log.last().cloned(), + None => {} + } + return Transition::None; + } + let action = match self.mode { + Mode::Abandon => { + self.mode = Mode::Explore; + return match key.code { + KeyCode::Char('y' | 'Y') => { + ctx.record.abandoned += 1; + ctx.hold.lose_carried(&self.crawl.gear); + ctx.crawl = None; + Transition::Pop + } + _ => Transition::None, + }; } - _ => return Transition::None, + Mode::Explore => match keys::movement(key) { + Some((dx, dy, true)) => { + self.run(dx, dy, ctx); + return Transition::None; + } + _ if matches!(key.code, KeyCode::Char('c' | 'C')) => { + let gear = self.crawl.gear.clone(); + return Transition::Push(Box::new(Sheet::aboard(&self.crawl.name, gear))); + } + _ => self.explore(key.code), + }, + Mode::Aim(at) => self.aim(key.code, at), }; - self.crawl.act(action); - if let Some(outcome) = self.crawl.outcome { - self.finish(outcome, ctx); + if let Some(action) = action { + self.act(action, ctx); } Transition::None } - fn render(&self, frame: &mut Frame, _ctx: &Ctx) { + fn render(&self, frame: &mut Frame, ctx: &Ctx) { let [main, panel] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) .areas(frame.area()); let [map, log] = Layout::vertical([Constraint::Fill(1), Constraint::Length(LOG_HEIGHT)]).areas(main); - self.render_map(frame.buffer_mut(), map); + self.render_map(frame.buffer_mut(), map, ctx.ticks); self.render_log(frame, log); self.render_panel(frame, panel); - if self.abandoning { - popup( + if let Some((title, lines)) = &self.ending { + let mut lines: Vec<Line> = lines.iter().map(|l| Line::from(l.as_str())).collect(); + lines.extend([Line::default(), keys::line(&[("any key", "carry on")])]); + popup(frame, title, lines); + return; + } + match self.mode { + Mode::Abandon => popup( frame, " ABANDON SHIP? ", vec![ - format!("Leave {} by the emergency hatch?", self.crawl.name), - "Whatever you're carrying stays behind.".into(), - String::new(), - "[y] abandon [any other key] keep exploring".into(), + Line::from(format!("Leave {} by the emergency hatch?", self.crawl.name)), + Line::from("Whatever you're carrying stays behind."), + Line::default(), + keys::line(&[("y", "abandon"), ("any other key", "keep exploring")]), ], - ); - } else if let Some((title, lines)) = &self.ending { - let mut lines = lines.clone(); - lines.extend([String::new(), "[any key]".into()]); - popup(frame, title, lines); + ), + Mode::Explore | Mode::Aim(_) => {} + } + if let Some(inv) = &self.inventory { + let here: Vec<Stack> = + self.crawl.here().iter().map(|&i| self.crawl.loot[i].stack.clone()).collect(); + let view = View { + gear: &self.crawl.gear, + beside: (!here.is_empty()).then_some((Beside::Floor("on the floor"), &here[..])), + known: &self.crawl.known, + hurt: true, + shop: None, + sort: ctx.inventory_sort, + }; + inv.render(frame, frame.area(), &view); + } + if let Some(pause) = &self.pause { + pause.render(frame, frame.area()); } } } +/// A round in flight, pointing the way it's going. Cells are about twice as +/// tall as they're wide, so the slope on screen is double the slope in cells. +fn shot_glyph(trace: &Trace) -> char { + match trace.dmg { + Dmg::Burn => return '•', + Dmg::Shock => return '~', + Dmg::Hymn => return '♪', + _ => {} + } + let (Some(first), Some(last)) = (trace.cells.first(), trace.cells.last()) else { return '*' }; + let (dx, dy) = (f64::from(last.x - first.x), f64::from(last.y - first.y) * 2.0); + let angle = dy.atan2(dx).to_degrees().rem_euclid(180.0); + match angle { + a if !(22.5..157.5).contains(&a) => '-', + a if a < 67.5 => '\\', + a if a < 112.5 => '|', + _ => '/', + } +} + /// The left or top edge of the view: keeps `focus` centred, but doesn't /// scroll past the map's edges, and centres a map smaller than the view. -fn camera(focus: i32, map_size: i32, view_size: i32) -> i32 { +pub(crate) fn camera(focus: i32, map_size: i32, view_size: i32) -> i32 { if map_size <= view_size { -(view_size - map_size) / 2 } else { @@ -290,12 +875,34 @@ pub(crate) fn tile_look(tile: Tile) -> (char, Rgb) { } } -pub(crate) fn item_look(item: &Item) -> (char, Rgb) { - match item { - Item::Artifact(_) => ('*', (255, 214, 92)), - Item::Salvage { .. } => ('%', (150, 200, 222)), - Item::Medpatch => ('!', (240, 96, 96)), +/// A weapon's name in the colour of its damage, cut to `width`. Relics +/// stand out. +fn title(w: &Weapon, width: usize) -> Span<'static> { + let name: String = w.name().chars().take(width).collect(); + let style = Style::new().fg(rgb(w.dmg.rgb(), 1.0)); + Span::styled(name, if w.named { style.bold() } else { style }) +} + +/// Rounds left, as pips. +fn magazine(gun: &Weapon) -> Span<'static> { + let Some(g) = gun.gun else { return Span::from("") }; + if gun.loaded == 0 { + return "empty (r)".red(); } + let pips = "▮".repeat(gun.loaded as usize) + &"▯".repeat((g.magazine - gun.loaded) as usize); + Span::from(pips).white() +} + +fn label(text: &'static str) -> Span<'static> { + Span::from(text).dark_gray() +} + +fn percent(p: f64) -> i32 { + (p * 100.0).round() as i32 +} + +fn distance2(a: Pos, b: Pos) -> i32 { + (a.x - b.x).pow(2) + (a.y - b.y).pow(2) } fn rgb((r, g, b): Rgb, light: f64) -> Color { @@ -303,6 +910,11 @@ fn rgb((r, g, b): Rgb, light: f64) -> Color { Color::Rgb(scale(r), scale(g), scale(b)) } +fn dim((r, g, b): Rgb, k: f64) -> Rgb { + let scale = |c: u8| (f64::from(c) * k).round() as u8; + (scale(r), scale(g), scale(b)) +} + /// A sprinkling of stars that stays put as the view scrolls. fn is_star(p: Pos) -> bool { let cell = (u64::from(p.x as u32) << 32) | u64::from(p.y as u32); @@ -320,14 +932,32 @@ fn health_bar(hp: i32, max: i32, width: i32) -> Span<'static> { Span::styled(format!("{bar} {hp}/{max}"), Style::new().fg(colour)) } -fn count(n: usize, noun: &str) -> String { - if n == 1 { format!("1 {noun}") } else { format!("{n} {noun}s") } +/// A monster's health, without the numbers: you can see how hurt it is, +/// not count its hit points. +fn mini_bar(hp: i32, max: i32, width: i32) -> Span<'static> { + let hp = hp.clamp(0, max); + let filled = ((hp * width + max - 1) / max).clamp(0, width); + let colour = match hp * 4 / max { + 3.. => Color::Green, + 2 => Color::Yellow, + _ => Color::Red, + }; + let bar = "█".repeat(filled as usize) + &"░".repeat((width - filled) as usize); + Span::styled(bar, Style::new().fg(colour)) } -fn popup(frame: &mut Frame, title: &str, lines: Vec<String>) { +fn listed(items: &[String]) -> String { + match items { + [] => String::new(), + [one] => one.clone(), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} + +fn popup(frame: &mut Frame, title: &str, lines: Vec<Line>) { let area = frame.area(); - let widest = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16; - let width = (widest + 4).min(area.width); + let widest = lines.iter().map(Line::width).max().unwrap_or(0) as u16; + let width = (widest + 4).max(title.chars().count() as u16 + 4).min(area.width); let height = (lines.len() as u16 + 2).min(area.height); let [popup] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area); let [popup] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(popup); @@ -335,7 +965,6 @@ fn popup(frame: &mut Frame, title: &str, lines: Vec<String>) { .border_style(Style::new().fg(Color::Yellow)) .title(Line::from(title.to_string()).bold()) .padding(Padding::horizontal(1)); - let lines: Vec<Line> = lines.into_iter().map(Line::from).collect(); frame.render_widget(Clear, popup); frame.render_widget(Paragraph::new(lines).block(block), popup); } @@ -347,7 +976,9 @@ mod tests { use ratatui::crossterm::event::KeyModifiers; use super::*; + use crate::dungeon::arms::{self, Source}; use crate::term::KeyboardCaps; + use ratatui::crossterm::event::KeyCode as Key; fn ctx() -> Ctx { Ctx::new(1, KeyboardCaps { key_release: false }) @@ -379,27 +1010,100 @@ mod tests { }) } + fn shifted(scene: &mut Derelict, code: KeyCode, ctx: &mut Ctx) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::SHIFT), ctx) + } + + #[test] + fn running_goes_until_something_happens_but_not_out_the_airlock() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + let start = scene.crawl.player; + let toward = key_toward_airlock(&scene); + let KeyCode::Char(c) = toward else { unreachable!() }; + let away = keys::direction(toward).map(|(dx, dy)| (-dx, -dy)).unwrap(); + let away = [ + Key::Left, + Key::Right, + Key::Up, + Key::Down, + Key::Home, + Key::PageUp, + Key::End, + Key::PageDown, + ] + .into_iter() + .find(|&k| keys::direction(k) == Some(away)) + .unwrap(); + shifted(&mut scene, away, &mut ctx); + let ran = scene.crawl.player.steps_to(start); + assert!(ran >= 1, "it gets going"); + assert_eq!(scene.crawl.turns, ran as u32, "a turn a step, straight on"); + // And back: it stops beside the airlock rather than go through. + for _ in 0..ran { + shifted(&mut scene, KeyCode::Char(c.to_ascii_uppercase()), &mut ctx); + if scene.crawl.player == start { + break; + } + } + assert_eq!(scene.crawl.player, start); + assert!(scene.crawl.outcome.is_none(), "still aboard"); + assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None)); + assert!(scene.crawl.outcome.is_some(), "a step of your own takes you out"); + } + + #[test] + fn a_medpatch_is_a_and_the_sheet_is_c() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(medpatches(1)); + scene.crawl.hp -= 5; + press(&mut scene, KeyCode::Char('a'), &mut ctx); + assert_eq!(scene.crawl.gear.medpatches(), 0); + assert!(matches!(press(&mut scene, KeyCode::Char('c'), &mut ctx), Transition::Push(_))); + } + + fn medpatches(n: u32) -> Stack { + Stack { item: Item::Medpatch, count: n } + } + + fn salvage() -> Item { + Item::Salvage(items::Salvage { title: "coil of wire".into(), value: 12, mass: 2000 }) + } + #[test] fn escaping_banks_what_you_carry() { let mut ctx = ctx(); - let mut scene = Derelict::new(3); - scene.crawl.scrip = 55; + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(Stack { item: salvage(), count: 2 }); let toward = key_toward_airlock(&scene); assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None)); - assert_eq!(ctx.hold.scrip, 55); - assert!(screen(&scene, &ctx).contains("SAFE ABOARD YOUR SHIP")); + assert_eq!(ctx.hold.gear.pack.iter().filter(|s| s.item == salvage()).count(), 1); + let shown = screen(&scene, &ctx); + assert!(shown.contains("SAFE ABOARD YOUR SHIP")); + assert!(shown.contains("2 coils of wire") && shown.contains("24 ¤"), "{shown}"); assert!(matches!(press(&mut scene, KeyCode::Char(' '), &mut ctx), Transition::Pop)); } + /// Esc, then the menu's second choice, then yes. + fn abandon(scene: &mut Derelict, ctx: &mut Ctx) { + press(scene, KeyCode::Esc, ctx); + press(scene, KeyCode::Char('2'), ctx); + press(scene, KeyCode::Char('y'), ctx); + } + #[test] fn abandoning_leaves_it_behind() { let mut ctx = ctx(); - let mut scene = Derelict::new(3); - scene.crawl.scrip = 55; + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(Stack::one(salvage())); press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(screen(&scene, &ctx).contains("PAUSED")); + press(&mut scene, KeyCode::Char('2'), &mut ctx); assert!(screen(&scene, &ctx).contains("ABANDON SHIP?")); assert!(matches!(press(&mut scene, KeyCode::Char('y'), &mut ctx), Transition::Pop)); - assert_eq!(ctx.hold.scrip, 0); + assert!(ctx.hold.gear.pack.iter().all(|s| s.item.is_kit())); + assert!(ctx.crawl.is_none(), "nothing to come back to"); } #[test] @@ -407,7 +1111,196 @@ mod tests { let mut ctx = ctx(); let mut scene = Derelict::new(3); press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(matches!(press(&mut scene, KeyCode::Char('q'), &mut ctx), Transition::None)); + assert!(!screen(&scene, &ctx).contains("PAUSED")); + press(&mut scene, KeyCode::Esc, &mut ctx); + press(&mut scene, KeyCode::Char('2'), &mut ctx); assert!(matches!(press(&mut scene, KeyCode::Char('x'), &mut ctx), Transition::None)); assert!(!screen(&scene, &ctx).contains("ABANDON SHIP?")); } + + #[test] + fn saving_and_quitting_brings_you_back_where_you_were() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(Stack::one(salvage())); + let key = key_toward_airlock(&scene); + // A step away from the airlock and back is a turn or two in. + let away = match key { + KeyCode::Char('h') => KeyCode::Char('l'), + KeyCode::Char('l') => KeyCode::Char('h'), + KeyCode::Char('j') => KeyCode::Char('k'), + _ => KeyCode::Char('j'), + }; + press(&mut scene, away, &mut ctx); + press(&mut scene, KeyCode::Char('.'), &mut ctx); + press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(matches!(press(&mut scene, KeyCode::Char('3'), &mut ctx), Transition::Title)); + let saved = ctx.crawl.clone().expect("the wreck is kept"); + assert_eq!(saved, scene.crawl.saved()); + + let back = Derelict::resumed(&ctx).unwrap(); + let (was, is) = (&scene.crawl, &back.crawl); + assert_eq!((is.player, is.hp, is.turns), (was.player, was.hp, was.turns)); + assert_eq!(is.gear, was.gear); + assert_eq!(is.loot, was.loot); + assert_eq!(is.monsters.len(), was.monsters.len()); + assert!(is.log.last().unwrap().contains("where you left off")); + assert_eq!(back.brought, ctx.hold.gear, "what's found is what you didn't bring"); + assert_eq!(ctx.record.boarded, 1, "coming back isn't boarding again"); + let seen = |c: &Crawl| c.map.positions().filter(|&p| c.seen(p)).count(); + assert_eq!(seen(is), seen(was), "what you'd seen you still have"); + assert!(was.map.positions().all(|p| is.seen(p) == was.seen(p))); + } + + #[test] + fn medpatches_go_aboard_and_come_back_if_you_do() { + let mut ctx = ctx(); + ctx.hold.gear.add(medpatches(3)); + ctx.hold.lockers.push(medpatches(4)); + let mut scene = Derelict::board(3, &mut ctx); + assert_eq!((scene.crawl.gear.medpatches(), ctx.hold.medpatches()), (3, 7)); + let key = key_toward_airlock(&scene); + press(&mut scene, key, &mut ctx); + assert_eq!(scene.crawl.outcome, Some(Outcome::Escaped)); + assert_eq!(ctx.hold.gear.medpatches(), 3); + + let mut scene = Derelict::board(3, &mut ctx); + abandon(&mut scene, &mut ctx); + assert_eq!(ctx.hold.gear.medpatches(), 0, "abandon ship and they stay behind"); + assert_eq!(ctx.hold.medpatches(), 4, "but not what's in the lockers"); + } + + #[test] + fn weapons_go_aboard_and_come_back_if_you_do() { + let mut ctx = ctx(); + let mut cleaver = arms::find(&mut seed::rng(2), Source::Crew, false); + cleaver.loaded = 0; + ctx.hold.gear.add(Stack::one(Item::Weapon(cleaver.clone()))); + let found = |g: &Gear| g.weapons().filter(|w| !w.kit).count(); + let mut scene = Derelict::board(3, &mut ctx); + assert_eq!((found(&scene.crawl.gear), found(&ctx.hold.gear)), (1, 1)); + assert!(scene.crawl.gear.weapons().all(|w| w.loaded == w.gun.map_or(0, |g| g.magazine))); + let key = key_toward_airlock(&scene); + press(&mut scene, key, &mut ctx); + assert_eq!(found(&ctx.hold.gear), 1); + assert!(screen(&scene, &ctx).contains("nothing new"), "what you brought isn't found"); + + let mut scene = Derelict::board(3, &mut ctx); + abandon(&mut scene, &mut ctx); + assert_eq!(found(&ctx.hold.gear), 0, "abandon ship and it stays behind"); + assert_eq!(ctx.hold.gear.weapons().count(), 2, "but not your own torch and rivet gun"); + } + + #[test] + fn aiming_and_the_inventory_open_and_close() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + press(&mut scene, KeyCode::Char('f'), &mut ctx); + assert!(matches!(scene.mode, Mode::Aim(_))); + assert!(screen(&scene, &ctx).contains("next target")); + press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(matches!(scene.mode, Mode::Explore)); + assert_eq!(scene.crawl.turns, 0, "lowering your gun takes no time"); + + press(&mut scene, KeyCode::Char('i'), &mut ctx); + let shown = screen(&scene, &ctx); + assert!(shown.contains("YOU ·") && shown.contains("cutting torch"), "{shown}"); + press(&mut scene, KeyCode::Char('j'), &mut ctx); + assert_eq!(scene.crawl.turns, 0, "looking takes no time"); + press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(scene.inventory.is_none()); + } + + #[test] + fn using_things_from_the_inventory_takes_a_turn_and_closes_it() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + scene.crawl.gear.add(Stack::one(salvage())); + scene.crawl.gear.add(medpatches(1)); + scene.crawl.hp -= 5; + press(&mut scene, KeyCode::Char('i'), &mut ctx); + find(&mut scene, "coil", &mut ctx); + press(&mut scene, KeyCode::Char('d'), &mut ctx); + assert_eq!(scene.crawl.turns, 0, "dropping is free"); + assert!(scene.inventory.is_some(), "and leaves it open"); + assert_eq!(scene.crawl.here().len(), 1); + find(&mut scene, "medpatch", &mut ctx); + press(&mut scene, KeyCode::Char('a'), &mut ctx); + assert_eq!(scene.crawl.turns, 1, "a medpatch takes a turn"); + assert!(scene.inventory.is_none(), "and closes it"); + } + + /// Picks out the first thing in the open inventory called `what`. + fn find(scene: &mut Derelict, what: &str, ctx: &mut Ctx) { + press(scene, KeyCode::Char('/'), ctx); + press(scene, KeyCode::Esc, ctx); + press(scene, KeyCode::Char('/'), ctx); + for c in what.chars() { + press(scene, KeyCode::Char(c), ctx); + } + press(scene, KeyCode::Enter, ctx); + } + + #[test] + fn firing_takes_a_turn_and_spends_a_round() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + press(&mut scene, KeyCode::Char('f'), &mut ctx); + scene.mode = Mode::Aim(scene.crawl.player.offset(2, 0)); + ctx.ticks = 100; + press(&mut scene, KeyCode::Char('f'), &mut ctx); + assert_eq!(scene.crawl.turns, 1); + assert_eq!(scene.crawl.gear.gun.loaded, scene.crawl.gear.gun.gun.unwrap().magazine - 1); + assert!(!scene.crawl.volley.is_empty()); + assert_eq!(scene.volley_at, 100); + assert!(scene.in_flight(100_000).is_empty(), "long since landed"); + } + + #[test] + fn your_record_counts_wrecks_kills_and_how_you_left_them() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + let kind = scene.crawl.monsters[0].kind.name; + scene.crawl.monsters[0].hp = 0; + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert_eq!(ctx.record.slain.get(kind), Some(&1)); + let key = key_toward_airlock(&scene); + press(&mut scene, key, &mut ctx); + + let mut scene = Derelict::board(3, &mut ctx); + abandon(&mut scene, &mut ctx); + + ctx.hold.gear.add(medpatches(2)); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.bleeding = 100; + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert!(matches!(scene.crawl.outcome, Some(Outcome::Died { .. }))); + assert!(screen(&scene, &ctx).contains(&ctx.character.ship_name())); + assert_eq!(ctx.hold.gear.medpatches(), 0, "brought down, and they stay behind"); + + let r = &ctx.record; + assert_eq!((r.boarded, r.escaped, r.abandoned, r.downed), (3, 1, 1, 1)); + } + + #[test] + fn until_you_leave_the_wreck_your_hold_is_as_you_left_it() { + let mut ctx = ctx(); + ctx.hold.gear.add(medpatches(2)); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear = Gear::default(); + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert_eq!(ctx.hold.gear.medpatches(), 2, "so a game saved now still has them"); + } + + #[test] + fn what_you_learn_you_keep() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.known.insert(("rust-mite", Dmg::Blunt)); + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert!(ctx.bestiary.contains(&("rust-mite", Dmg::Blunt))); + let scene = Derelict::board(4, &mut ctx); + assert!(scene.crawl.known.contains(&("rust-mite", Dmg::Blunt))); + } } diff --git a/src/scenes/flight.rs b/src/scenes/flight.rs new file mode 100644 index 0000000..04c56d0 --- /dev/null +++ b/src/scenes/flight.rs @@ -0,0 +1,1163 @@ +//! Flying around one place: the local map, the ship, the laser, and a side +//! panel with a radar and your status. The rules live in `space::flight`; +//! this turns keys into `Controls` and draws the result. +//! +//! On terminals that report key releases, you hold keys to thrust, turn and +//! fire. Elsewhere there's no telling when a key is let go, so thrust is a +//! throttle you step up and down, each turn press swings you round 22.5°, +//! and each laser press fires for a moment. + +use std::f64::consts::{FRAC_PI_2, PI}; + +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Padding, Paragraph, Wrap}; + +use super::aboard::Aboard; +use super::character::Sheet; +use super::derelict::Derelict; +use super::galaxy_map::GalaxyMap; +use super::keys; +use super::pause::{Choice, Pause}; +use super::port::{FUEL, Port}; +use super::sky::{self, Surface, View, mix}; +use super::system_map::SystemMap; +use super::{Scene, Transition}; +use crate::app::{Ctx, Hold, TICKS_PER_SECOND}; +use crate::seed; +use crate::space::Vec2; +use crate::space::flight::{ + Controls, Event, FLOTSAM_LIFE, Flight, LANDING_SPEED, SPARK_LIFE, THRUST, TURN_RATE, +}; +use crate::space::locale::{self, BACKDROP_PARALLAX, Size}; +use crate::space::system::Landing; +use crate::trade; + +const DT: f64 = 1.0 / TICKS_PER_SECOND as f64; +const PANEL_WIDTH: u16 = 30; +/// Below this width the panel gives way to a one-line status bar. +const PANEL_MIN_SCREEN: u16 = 76; +/// Throttle steps, for terminals without held keys. +const THROTTLE_STEPS: u8 = 4; +/// How far one turn press swings you, without held keys. +const TURN_STEP: f64 = PI / 8.0; +/// How long one laser press fires for, without held keys. +const FIRE_TICKS: u64 = TICKS_PER_SECOND as u64 * 6 / 10; +/// Seconds a message stays up. +const MESSAGE_SECONDS: u64 = 6; + +const HULL: Color = Color::Rgb(112, 118, 140); +const ROCK: (u8, u8, u8) = (150, 138, 124); +const BEAM: Color = Color::Rgb(255, 110, 220); +const DIM: Color = Color::Rgb(90, 94, 112); +const ACCENT: Color = Color::Rgb(240, 200, 110); + +/// Why you're out of the pilot's seat, walking about the ship: that scene +/// is on top of this one until you sit back down. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Walking { + /// Landed or docked. + Landed, + /// Got up while flying slowly, and the ship holds station. + Adrift, +} + +/// What tab points you at. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Target { + /// Whichever rock with ore in it is nearest right now. + NearestOre, + /// The planet, moon or station in the middle. + Middle, + /// A wreck, by index into `locale.wrecks`. + Wreck(usize), +} + +pub struct FlightScene { + flight: Flight, + /// Which system, place and day `flight` was built for. When the map + /// moves you on, these fall out of date and the scene rebuilds. + star: usize, + place: usize, + day: f64, + throttle: u8, + /// Turning still to do, in radians, from turn presses. + pending_turn: f64, + /// Turning to face backwards and thrusting until stopped. + braking: bool, + fire_until: u64, + target: Option<Target>, + messages: Vec<(String, u64)>, + walking: Option<Walking>, + /// The menu `esc` opens. Everything holds still while it's open. + pause: Option<Pause>, +} + +impl FlightScene { + pub fn new(ctx: &Ctx) -> FlightScene { + let mut scene = FlightScene { + flight: arrive(ctx), + star: ctx.star, + place: ctx.location, + day: ctx.day, + throttle: 0, + pending_turn: 0.0, + braking: false, + fire_until: 0, + target: None, + messages: Vec::new(), + walking: None, + pause: None, + }; + let l = &scene.flight.locale; + let text = format!("You're {}.", l.what); + scene.say(text, ctx); + scene + } + + fn say(&mut self, text: String, ctx: &Ctx) { + self.messages.retain(|(t, _)| *t != text); + self.messages.push((text, ctx.ticks)); + if self.messages.len() > 6 { + self.messages.remove(0); + } + } + + /// If the map has moved you on (or let time pass), arrives at the new + /// place. + fn catch_up(&mut self, ctx: &mut Ctx) { + if self.star == ctx.star && self.place == ctx.location && self.day == ctx.day { + return; + } + let days = ctx.day - self.day; + let moved = self.place != ctx.location; + let jumped = self.star != ctx.star; + self.flight = arrive(ctx); + self.star = ctx.star; + self.place = ctx.location; + self.day = ctx.day; + self.throttle = 0; + self.pending_turn = 0.0; + self.braking = false; + self.target = None; + self.messages.clear(); + let l = &self.flight.locale; + let note = ctx.arrival_note.take(); + let text = if let Some(note) = note { + note.replace("{where}", &l.what) + } else if jumped { + format!( + "{} later you arrive in the {} system, {}.", + days_text(days), + ctx.system.name, + l.what + ) + } else if moved { + format!("{} later you arrive {}.", days_text(days), l.what) + } else if (days - 1.0).abs() < 0.05 { + "A day passes, and the rocks drift on.".to_string() + } else { + format!("{} pass, and the rocks drift on.", days_text(days)) + }; + self.say(text, ctx); + if jumped && ctx.stranded() { + let text = + "Your tank's dry, and no one here sells fuel. [g] the star map, to call a tow."; + self.say(text.to_string(), ctx); + } + } + + /// Back in the pilot's seat, if you'd got up. + fn sit_down(&mut self, ctx: &mut Ctx) { + let Some(walking) = self.walking.take() else { return }; + ctx.landed = false; + let name = &ctx.system.places[self.place].name; + let text = match walking { + Walking::Landed if matches!(ctx.system.landing(self.place), Landing::Market(_)) => { + format!("You undock from {name}.") + } + Walking::Landed => format!("You lift off from {name}."), + Walking::Adrift => "You sit back down and take the controls.".to_string(), + }; + self.say(text, ctx); + } + + fn held(ctx: &Ctx, codes: &[KeyCode]) -> bool { + codes.iter().any(|&c| ctx.is_held(c)) + } + + fn controls(&mut self, ctx: &Ctx) -> Controls { + let mut c = Controls::default(); + if ctx.keyboard.key_release { + let left = Self::held(ctx, &[KeyCode::Left, KeyCode::Char('a')]); + let right = Self::held(ctx, &[KeyCode::Right, KeyCode::Char('d')]); + let thrust = Self::held(ctx, &[KeyCode::Up, KeyCode::Char('w')]); + if left || right || thrust { + self.braking = false; + } + if Self::held(ctx, &[KeyCode::Down, KeyCode::Char('s')]) { + self.braking = true; + } + c.turn = f64::from(u8::from(right)) - f64::from(u8::from(left)); + c.thrust = f64::from(u8::from(thrust)); + c.firing = Self::held(ctx, &[KeyCode::Char(' '), KeyCode::Char('f')]); + } else { + let step = TURN_RATE * DT; + c.turn = (self.pending_turn / step).clamp(-1.0, 1.0); + self.pending_turn -= c.turn * step; + c.thrust = f64::from(self.throttle) / f64::from(THROTTLE_STEPS); + c.firing = ctx.ticks < self.fire_until; + } + if self.braking { + let speed = self.flight.speed(); + if speed < 0.3 { + // Close enough: hold station. + self.flight.ship.vel = Vec2::ZERO; + self.braking = false; + self.throttle = 0; + } else { + c.retro = true; + // Full thrust against the drift, easing off on the last tick + // so as not to overshoot. + let enough = speed / (THRUST * DT); + c.thrust = if self.flight.facing_retro() { enough.min(1.0) } else { 0.0 }; + } + } + c + } + + fn key_fallback(&mut self, code: KeyCode, ctx: &Ctx) -> bool { + match code { + KeyCode::Up | KeyCode::Char('w') => { + self.throttle = (self.throttle + 1).min(THROTTLE_STEPS); + self.braking = false; + } + KeyCode::Down | KeyCode::Char('s') => { + if self.throttle > 0 { + self.throttle -= 1; + } else { + self.braking = true; + } + } + KeyCode::Left | KeyCode::Char('a') => self.turn_by(-TURN_STEP), + KeyCode::Right | KeyCode::Char('d') => self.turn_by(TURN_STEP), + KeyCode::Char(' ' | 'f') => self.fire_until = ctx.ticks + FIRE_TICKS, + KeyCode::Char('x') => self.throttle = 0, + _ => return false, + } + true + } + + fn turn_by(&mut self, angle: f64) { + self.braking = false; + self.pending_turn = (self.pending_turn + angle).clamp(-FRAC_PI_2, FRAC_PI_2); + } + + fn land(&mut self, ctx: &mut Ctx) -> Transition { + let f = &self.flight; + let l = &f.locale; + let fast = f.speed() > LANDING_SPEED; + let refusal = if let Some(body) = &l.body { + if !f.over_body() { + format!("Fly over {} to land.", body.name) + } else if body.kind.giant { + format!( + "{} has no surface to land on: it's {} all the way down.", + body.name, body.kind.name + ) + } else if fast { + format!("Too fast to land. Slow below {LANDING_SPEED:.0}.") + } else { + return self.touch_down(ctx); + } + } else if let Some(dock) = &l.dock { + if !f.at_dock() { + format!("Fly in close to {} to dock.", dock.name) + } else if fast { + format!("Too fast to dock. Slow below {LANDING_SPEED:.0}.") + } else { + return self.touch_down(ctx); + } + } else { + "There's nowhere to land out here.".to_string() + }; + self.say(refusal, ctx); + Transition::None + } + + /// Lands, or docks. Outside a capital you get up from the controls, + /// and the way into the city is at the other end of the ship; anywhere + /// else there's no city to walk to, so you're straight out at the dock, + /// or on the ground. + fn touch_down(&mut self, ctx: &mut Ctx) -> Transition { + self.stop(); + self.walking = Some(Walking::Landed); + ctx.landed = true; + Transition::Push(landed(ctx, self.place)) + } + + /// Gets up from the controls to walk about the ship, if you're going + /// slowly enough to leave it holding station. + fn get_up(&mut self, ctx: &mut Ctx) -> Transition { + if self.flight.speed() > LANDING_SPEED { + let text = format!("Too fast to leave the controls. Slow below {LANDING_SPEED:.0}."); + self.say(text, ctx); + return Transition::None; + } + let wreck = + self.flight.wreck_alongside().map(|i| self.flight.locale.wrecks[i].wreck.clone()); + self.stop(); + self.walking = Some(Walking::Adrift); + Transition::Push(Box::new(Aboard::adrift(&self.flight.locale.what, wreck))) + } + + fn stop(&mut self) { + self.flight.ship.vel = Vec2::ZERO; + self.throttle = 0; + self.braking = false; + self.pending_turn = 0.0; + } + + fn board(&mut self, ctx: &mut Ctx) -> Transition { + let Some(i) = self.flight.wreck_alongside() else { + let text = if self.flight.locale.wrecks.is_empty() { + "There are no wrecks here." + } else { + "Fly alongside a wreck to board it." + }; + self.say(text.to_string(), ctx); + return Transition::None; + }; + let wreck = &self.flight.locale.wrecks[i].wreck; + if self.flight.speed() > LANDING_SPEED { + let text = format!("Too fast to board. Slow below {LANDING_SPEED:.0}."); + self.say(text, ctx); + Transition::None + } else if ctx.stripped.contains(&wreck.seed) { + let text = format!("You've already stripped {}. Nothing's left aboard.", wreck.name); + self.say(text, ctx); + Transition::None + } else { + let seed = wreck.seed; + ctx.stripped.insert(seed); + self.stop(); + Transition::Push(Box::new(Derelict::board(seed, ctx))) + } + } + + fn next_target(&mut self) { + let l = &self.flight.locale; + let mut targets = vec![]; + if l.rocks.iter().any(|r| r.ore.is_some()) { + targets.push(Target::NearestOre); + } + if l.body.is_some() || l.dock.is_some() { + targets.push(Target::Middle); + } + targets.extend((0..l.wrecks.len()).map(Target::Wreck)); + self.target = match self.target.and_then(|t| targets.iter().position(|&x| x == t)) { + Some(i) if i + 1 < targets.len() => Some(targets[i + 1]), + Some(_) => None, + None => targets.first().copied(), + }; + } + + /// Where the target is and what to call it. + fn target_info(&self) -> Option<(Vec2, f64, String)> { + let l = &self.flight.locale; + let ship = self.flight.ship.pos; + match self.target? { + Target::NearestOre => { + let rock = l + .rocks + .iter() + .filter(|r| r.ore.is_some()) + .min_by(|a, b| a.pos.distance(ship).total_cmp(&b.pos.distance(ship)))?; + let size = match rock.size { + Size::Small => "small", + Size::Medium => "", + Size::Large => "large", + }; + let name = format!("{size} rock, {}", rock.ore.unwrap().name); + Some((rock.pos, rock.size.radius(), name.trim_start().to_string())) + } + Target::Middle => match (&l.body, &l.dock) { + (Some(b), _) => Some((Vec2::ZERO, b.radius, b.name.clone())), + (None, Some(d)) => Some((Vec2::ZERO, locale::DOCK_RADIUS, d.name.clone())), + (None, None) => None, + }, + Target::Wreck(i) => { + let w = l.wrecks.get(i)?; + Some((w.pos, 2.0, format!("wreck of {}", w.wreck.name))) + } + } + } +} + +/// The scenes that pick up a game where it was left: flying wherever you +/// are, and, if you'd set down there, aboard your ship on the ground. +/// Where a saved game picks up: flying, aboard your ship where it's landed, +/// or aboard the wreck you were in. +pub fn resume(ctx: &Ctx) -> Vec<Box<dyn Scene>> { + let mut flying = FlightScene::new(ctx); + let mut scenes: Vec<Box<dyn Scene>> = if ctx.landed { + // Where you'd have been to land: over the planet, or at the dock. + flying.flight.ship.pos = Vec2::ZERO; + flying.walking = Some(Walking::Landed); + let landed = if has_city(ctx, ctx.location) { + Box::new(Aboard::resumed(ctx, ctx.location)) + } else { + landed(ctx, ctx.location) + }; + vec![Box::new(flying), landed] + } else { + vec![Box::new(flying)] + }; + if let Some(wreck) = Derelict::resumed(ctx) { + scenes.push(Box::new(wreck)); + } + scenes +} + +fn has_city(ctx: &Ctx, place: usize) -> bool { + matches!(ctx.system.landing(place), Landing::Capital(_)) +} + +/// Where you are once you've set down at `place`: aboard, if there's a city +/// to walk out to, or else straight out at the dock or on the ground. +fn landed(ctx: &Ctx, place: usize) -> Box<dyn Scene> { + if has_city(ctx, place) { + Box::new(Aboard::landed(ctx, place)) + } else { + Box::new(Port::new(ctx, place)) + } +} + +fn arrive(ctx: &Ctx) -> Flight { + let locale = locale::generate(&ctx.system, ctx.location, ctx.day, ctx.departed, &ctx.stripped); + let seed = seed::derive(ctx.seed, "arrival", ctx.day.to_bits() ^ ctx.location as u64); + Flight::new(locale, seed) +} + +fn days_text(days: f64) -> String { + if days < 1.0 { + format!("{:.0} hours", days * 24.0) + } else if (days - 1.0).abs() < 0.05 { + "A day".to_string() + } else { + format!("{days:.1} days") + } +} + +impl Scene for FlightScene { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + if let Some(pause) = &mut self.pause { + return match pause.key(key) { + None => Transition::None, + Some(choice) => { + self.pause = None; + match choice { + Choice::Quit => Transition::Title, + Choice::Here | Choice::Resume => Transition::None, + } + } + }; + } + self.sit_down(ctx); + self.catch_up(ctx); + let repeat = key.kind == KeyEventKind::Repeat; + if !ctx.keyboard.key_release && self.key_fallback(key.code, ctx) { + return Transition::None; + } + if repeat { + return Transition::None; + } + match key.code { + KeyCode::Char(c) => match c.to_ascii_lowercase() { + 'l' => self.land(ctx), + 'b' => self.board(ctx), + 'e' => self.get_up(ctx), + 'm' => Transition::Push(Box::new(SystemMap::new(ctx))), + 'g' => Transition::Push(Box::new(GalaxyMap::new(ctx))), + 'c' => Transition::Push(Box::new(Sheet::new())), + 'r' => { + self.braking = true; + Transition::None + } + _ => Transition::None, + }, + KeyCode::Tab => { + self.next_target(); + Transition::None + } + KeyCode::Esc => { + let note = format!( + "You're {}. Everything holds still while this is open.", + self.flight.locale.what + ); + self.pause = Some(Pause::new(None, Some(note))); + Transition::None + } + _ => Transition::None, + } + } + + fn tick(&mut self, ctx: &mut Ctx) -> Transition { + if self.pause.is_some() { + return Transition::None; + } + self.sit_down(ctx); + self.catch_up(ctx); + let controls = self.controls(ctx); + self.flight.laser_power = trade::laser_power(ctx.lenses); + for event in self.flight.step(DT, controls, ctx.hold.ore_room()) { + let text = match event { + Event::Collected(ore) => { + *ctx.hold.ore.entry(ore.name).or_default() += 1; + format!("Scooped up a ton of {}. Hold: {} t.", ore.name, ore_line(&ctx.hold)) + } + Event::Shattered { ore: Some(ore), .. } => { + format!("The rock splits, spilling {ore}.") + } + Event::Shattered { ore: None, .. } => continue, + Event::HoldFull => { + "Your hold is full of ore. Land somewhere to sell it.".to_string() + } + }; + self.say(text, ctx); + } + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let area = frame.area(); + let (map, panel) = if area.width >= PANEL_MIN_SCREEN { + let [map, panel] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) + .areas(area); + (map, Some(panel)) + } else { + (area, None) + }; + let view = View { area: map, camera: self.flight.ship.pos }; + self.draw_map(frame.buffer_mut(), &view, ctx); + self.draw_messages(frame.buffer_mut(), map, ctx); + match panel { + Some(panel) => self.draw_panel(frame, panel, ctx), + None => self.draw_status_bar(frame.buffer_mut(), map, ctx), + } + if let Some(pause) = &self.pause { + pause.render(frame, area); + } + } +} + +impl FlightScene { + fn draw_map(&self, buf: &mut Buffer, view: &View, ctx: &Ctx) { + let f = &self.flight; + let l = &f.locale; + let look = seed::derive(ctx.system.seed, "look", l.place as u64); + sky::starfield(buf, view.area, view.camera, look); + + for (i, b) in l.backdrops.iter().enumerate() { + let at = b.pos + view.camera * (1.0 - BACKDROP_PARALLAX); + let surface = Surface { + rgb: b.kind.rgb, + banded: b.kind.banded, + lights: false, + ramp: sky::PLANET_RAMP, + seed: seed::derive(look, "backdrop", i as u64), + }; + let (col, row) = view.screen(at); + if !sky::sphere(buf, view.area, (col, row), b.radius, l.sun, &surface) { + view.put(buf, at, '·', sky::rgb(b.kind.rgb)); + } + let below = row + b.radius / 2.0 + 1.0; + sky::label(buf, view.area, col, below, &b.name, Color::Rgb(64, 66, 82)); + } + + self.draw_edge(buf, view); + + if let Some(body) = &l.body { + let surface = Surface { + rgb: body.kind.rgb, + banded: body.kind.banded, + lights: body.settled, + ramp: sky::PLANET_RAMP, + seed: look, + }; + let (col, row) = view.screen(Vec2::ZERO); + sky::sphere(buf, view.area, (col, row), body.radius, l.sun, &surface); + let below = row + body.radius / 2.0 + 1.0; + sky::label(buf, view.area, col, below, &body.name, DIM); + } + if let Some(dock) = &l.dock { + sky::draw_station(buf, view, dock.culture, ctx.ticks); + let (col, row) = view.screen(Vec2::ZERO); + sky::label(buf, view.area, col, row + 3.0, &dock.name, DIM); + } + + for w in &l.wrecks { + let wreck = &w.wreck; + let beacon = !ctx.stripped.contains(&wreck.seed) && wreck.calling(ctx.day); + sky::draw_wreck(buf, view, w.pos, w.heading, beacon, ctx.ticks); + if w.pos.distance(f.ship.pos) < 30.0 { + let (col, row) = view.screen(w.pos); + sky::label(buf, view.area, col, row + 1.0, &wreck.name, DIM); + } + } + + for rock in &l.rocks { + let mut rgb = ROCK; + if let Some(ore) = rock.ore { + rgb = mix(rgb, ore.rgb, 0.55); + } + // Glows hotter as the laser works on it. + let worn = 1.0 - rock.integrity / rock.size.toughness(); + rgb = mix(rgb, (255, 150, 90), worn * 0.8); + let surface = + Surface { rgb, banded: false, lights: false, ramp: sky::ROCK_RAMP, seed: 0 }; + let drawn = rock.size != Size::Small + && sky::sphere( + buf, + view.area, + view.screen(rock.pos), + rock.size.radius(), + l.sun, + &surface, + ); + if !drawn { + view.put(buf, rock.pos, 'o', sky::shade(rgb, 1.0)); + } + } + + for bit in &f.flotsam { + let fading = FLOTSAM_LIFE - bit.age < 8.0 && ctx.ticks % 10 < 5; + if !fading { + view.put(buf, bit.pos, '*', sky::shade(bit.ore.rgb, 1.15)); + } + } + + for spark in &f.sparks { + let k = spark.age / SPARK_LIFE; + let colour = mix((255, 226, 150), (150, 50, 30), k); + view.put(buf, spark.pos, if k < 0.5 { '∙' } else { '·' }, sky::rgb(colour)); + } + + if let Some(beam) = &f.beam { + let length = beam.from.distance(beam.to); + let dir = (beam.to - beam.from).normalised(); + let steps = (length * 2.0) as usize; + for i in 0..steps { + view.put(buf, beam.from + dir * (i as f64 * 0.5), '·', BEAM); + } + if beam.hit { + let glyph = if ctx.ticks % 4 < 2 { '*' } else { '+' }; + view.put(buf, beam.to, glyph, Color::Rgb(255, 236, 200)); + } + } + + if let Some((pos, r, _)) = self.target_info() { + let reach = r + 1.5; + view.put(buf, Vec2::new(pos.x - reach, pos.y), '[', ACCENT); + view.put(buf, Vec2::new(pos.x + reach, pos.y), ']', ACCENT); + } + + sky::draw_ship(buf, view, &f.ship, ctx.ticks); + } + + /// Faint dots along the edge of the map, where it starts pushing back. + fn draw_edge(&self, buf: &mut Buffer, view: &View) { + let e = self.flight.locale.extent; + let colour = Color::Rgb(56, 58, 74); + let a = view.area; + let (half_w, half_h) = (f64::from(a.width) / 2.0 + 1.0, f64::from(a.height) + 2.0); + let (cx, cy) = (view.camera.x, view.camera.y); + for x in [-e, e] { + if (x - cx).abs() <= half_w { + let (from, to) = ((cy - half_h).max(-e), (cy + half_h).min(e)); + let mut y = (from / 2.0).ceil() * 2.0; + while y <= to { + view.put(buf, Vec2::new(x, y), '┊', colour); + y += 2.0; + } + } + } + for y in [-e, e] { + if (y - cy).abs() <= half_h { + let (from, to) = ((cx - half_w).max(-e), (cx + half_w).min(e)); + let mut x = from.ceil(); + while x <= to { + view.put(buf, Vec2::new(x, y), '┈', colour); + x += 1.0; + } + } + } + } + + fn draw_messages(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { + let fresh: Vec<&String> = self + .messages + .iter() + .filter(|(_, at)| { + ctx.ticks.saturating_sub(*at) < MESSAGE_SECONDS * u64::from(TICKS_PER_SECOND) + }) + .map(|(t, _)| t) + .collect(); + let shown = fresh.iter().rev().take(3).rev(); + let count = fresh.len().min(3) as u16; + let first = area.bottom().saturating_sub(count + 1); + if self.flight.past_edge() { + let warning = "You're leaving the vicinity. [m] opens the map, to travel on."; + text_at(buf, area, first.saturating_sub(1), warning, ACCENT); + } + for (row, text) in (first..).zip(shown) { + text_at(buf, area, row, text, Color::Rgb(220, 220, 230)); + } + } + + fn draw_status_bar(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { + let text = format!( + " {} · day {:.1} · speed {:.0} · ore {} · fuel {} ", + self.flight.locale.name, + ctx.day, + self.flight.speed(), + ore_line(&ctx.hold), + ctx.hold.fuel + ); + let mut line = keys::line(&[("m", "map"), ("g", "stars"), ("esc", "menu")]); + line.spans.insert(0, Span::raw(text).fg(ACCENT)); + let bar = Rect { height: 1, ..area }; + buf.set_style(bar, Style::new().bg(Color::Rgb(20, 20, 30))); + buf.set_line(area.x, area.y, &line, area.width); + } + + fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { + let l = &self.flight.locale; + let block = Block::bordered() + .title(format!(" {} ", l.name)) + .border_style(Style::new().fg(DIM)) + .title_style(Style::new().fg(ACCENT).bold()) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let hints: &[(&str, &str)] = if ctx.keyboard.key_release { + &[ + ("↑ w", "thrust"), + ("← → a d", "turn"), + ("↓ s r", "brake"), + ("space f", "laser"), + ("l", "land, dock"), + ("b", "board"), + ("e", "get up"), + ("tab", "target"), + ("m", "system map"), + ("g", "star map"), + ("c", "character"), + ("esc", "menu: save and quit"), + ] + } else { + &[ + ("↑ ↓ w s", "throttle"), + ("← → a d", "turn"), + ("r", "brake"), + ("x", "cut engines"), + ("space f", "laser"), + ("b", "board"), + ("l", "land, dock"), + ("e", "get up"), + ("tab", "target"), + ("c", "character"), + ("m", "system map"), + ("g", "star map"), + ("esc", "menu: save and quit"), + ] + }; + let hints = keys::packed(hints, inner.width); + // What's left after the text, up to a square the width of the panel. + let text_rows = 4 + 6 + 3 + 1 + hints.len() as u16; + let radar_rows = inner.height.saturating_sub(text_rows).min(inner.width / 2).min(13); + let [about, radar, status, target, _, keys] = Layout::vertical([ + Constraint::Length(4), + Constraint::Length(radar_rows), + Constraint::Length(6), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(hints.len() as u16), + ]) + .areas(inner); + + let about_lines = vec![ + Line::from(l.what.clone()).fg(DIM), + Line::from(format!("{} system, day {:.1}", ctx.system.name, ctx.day)).fg(DIM), + ]; + frame.render_widget(Paragraph::new(about_lines).wrap(Wrap { trim: true }), about); + + if radar.height >= 3 { + self.draw_radar(frame.buffer_mut(), radar, ctx); + } + + let f = &self.flight; + let mut lines = vec![Line::from(vec![ + Span::raw("speed ").fg(DIM), + Span::raw(format!("{:>4.1}", f.speed())), + if f.speed() <= LANDING_SPEED { + Span::raw(" · can land").fg(DIM) + } else { + Span::raw("") + }, + ])]; + if !ctx.keyboard.key_release { + let bars: String = + (1..=THROTTLE_STEPS).map(|i| if i <= self.throttle { '▮' } else { '▯' }).collect(); + lines.push(Line::from(vec![Span::raw("thrust ").fg(DIM), Span::raw(bars).fg(ACCENT)])); + } + if self.braking { + lines.push(Line::from("braking").fg(ACCENT)); + } + let h = &ctx.hold; + lines.push(Line::from(vec![Span::raw("ore ").fg(DIM), Span::raw(ore_line(h))])); + let gauge: String = + (0..h.fuel_capacity).map(|k| if k < h.fuel { '◆' } else { '◇' }).collect(); + lines.push(Line::from(vec![ + Span::raw("fuel ").fg(DIM), + Span::raw(gauge).fg(if h.fuel == 0 { Color::Rgb(214, 112, 100) } else { FUEL }), + ])); + lines.push(Line::from(vec![ + Span::raw("hold ").fg(DIM), + Span::raw(format!("{} artifacts, {} ¤", h.artifacts(), h.scrip)), + ])); + frame.render_widget(Paragraph::new(lines), status); + + let target_lines = match self.target_info() { + Some((pos, _, name)) => { + let off = pos - f.ship.pos; + vec![ + Line::from(name).fg(ACCENT), + Line::from(format!("{} {:.0} away", sky::arrow(off.heading()), off.length())), + ] + } + None if self.target == Some(Target::NearestOre) => { + vec![Line::from("no ore in sight").fg(DIM)] + } + None => vec![keys::line(&[("tab", "pick a target")])], + }; + frame.render_widget(Paragraph::new(target_lines), target); + + frame.render_widget(Paragraph::new(hints), keys); + } + + /// The whole local map, shrunk to fit. + fn draw_radar(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { + let l = &self.flight.locale; + let w = f64::from(area.width.min(area.height * 2)); + let h = w / 2.0; + let left = f64::from(area.x) + (f64::from(area.width) - w) / 2.0; + let top = f64::from(area.y); + let e = l.extent; + let cell = |p: Vec2| { + let col = left + (p.x + e) / (2.0 * e) * w; + let row = top + (p.y + e) / (2.0 * e) * h; + let inside = col >= left && col < left + w && row >= top && row < top + h; + inside.then_some((col as u16, row as u16)) + }; + for row in 0..h as u16 { + for col in 0..w as u16 { + buf[(left as u16 + col, area.y + row)].set_char('·').set_fg(Color::Rgb(40, 42, 54)); + } + } + let mut put = |p: Vec2, ch: char, fg: Color| { + if let Some(pos) = cell(p) { + buf[pos].set_char(ch).set_fg(fg); + } + }; + if let Some(body) = &l.body { + let step = 2.0 * e / w; + let mut y = -body.radius; + while y <= body.radius { + let mut x = -body.radius; + while x <= body.radius { + if x * x + y * y <= body.radius * body.radius { + put(Vec2::new(x, y), 'o', sky::shade(body.kind.rgb, 0.7)); + } + x += step; + } + y += step * 2.0; + } + } + if l.dock.is_some() { + put(Vec2::ZERO, '#', ACCENT); + } + for rock in &l.rocks { + let fg = rock.ore.map_or(Color::Rgb(110, 104, 96), |o| sky::rgb(o.rgb)); + put(rock.pos, '∙', fg); + } + for bit in &self.flight.flotsam { + put(bit.pos, '*', sky::rgb(bit.ore.rgb)); + } + for wreck in &l.wrecks { + let stripped = ctx.stripped.contains(&wreck.wreck.seed); + put(wreck.pos, '≡', if stripped { HULL } else { Color::Rgb(255, 90, 80) }); + } + let ship = &self.flight.ship; + put(ship.pos, sky::arrow(ship.heading), Color::White); + } +} + +/// One line of text from the left of `area`, a cell in, clipped to fit. +fn text_at(buf: &mut Buffer, area: Rect, row: u16, text: &str, fg: Color) { + if row < area.y || row >= area.bottom() || area.width < 3 { + return; + } + let text: String = text.chars().take(usize::from(area.width - 2)).collect(); + buf.set_string(area.x + 1, row, text, Style::new().fg(fg)); +} + +/// "12/40 t". +fn ore_line(hold: &Hold) -> String { + format!("{}/{} t", hold.ore_tons(), hold.ore_capacity) +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::scenes::tests::test_ctx; + use crate::space::system::PlaceKind; + + fn press(scene: &mut FlightScene, ctx: &mut Ctx, code: KeyCode) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + fn ticks(scene: &mut FlightScene, ctx: &mut Ctx, n: u32) { + for _ in 0..n { + ctx.ticks += 1; + scene.tick(ctx); + } + } + + #[test] + fn esc_holds_everything_still_and_esc_again_carries_on() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + for _ in 0..4 { + press(&mut scene, &mut ctx, KeyCode::Up); + } + ticks(&mut scene, &mut ctx, 5); + press(&mut scene, &mut ctx, KeyCode::Esc); + let held = scene.flight.ship.pos; + ticks(&mut scene, &mut ctx, 30); + assert_eq!(scene.flight.ship.pos, held, "nothing moves while the menu's open"); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Esc), Transition::None)); + ticks(&mut scene, &mut ctx, 5); + assert_ne!(scene.flight.ship.pos, held, "and carries on after"); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('2')), Transition::Title)); + } + + #[test] + fn the_throttle_flies_you_forward_and_brake_stops_you() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + let start = scene.flight.ship.pos; + for _ in 0..4 { + press(&mut scene, &mut ctx, KeyCode::Up); + } + ticks(&mut scene, &mut ctx, 15); + assert!(scene.flight.speed() > 5.0); + assert!(scene.flight.ship.pos.distance(start) > 1.0); + press(&mut scene, &mut ctx, KeyCode::Char('x')); + press(&mut scene, &mut ctx, KeyCode::Char('r')); + ticks(&mut scene, &mut ctx, 150); + assert!(scene.flight.speed() < 0.5, "braked to {}", scene.flight.speed()); + assert!(!scene.braking); + } + + #[test] + fn turn_presses_swing_you_round_an_eighth_of_a_half_turn() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + let before = scene.flight.ship.heading; + press(&mut scene, &mut ctx, KeyCode::Right); + ticks(&mut scene, &mut ctx, 10); + let turned = crate::space::turn_between(before, scene.flight.ship.heading); + assert!((turned - TURN_STEP).abs() < 1e-9, "turned {turned}"); + } + + #[test] + fn travelling_on_the_map_rebuilds_the_place() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + let belt = ctx.system.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap(); + ctx.location = belt; + ctx.day = 14.0; + ticks(&mut scene, &mut ctx, 1); + assert_eq!(scene.place, belt); + assert!(scene.flight.locale.body.is_none() && scene.flight.locale.rocks.len() > 50); + assert!(scene.messages.last().unwrap().0.contains("14.0 days later")); + } + + #[test] + fn jumping_brings_you_out_at_the_edge_of_another_system() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + let mut map = GalaxyMap::new(&ctx); + map.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut ctx); + map.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut ctx); + ticks(&mut scene, &mut ctx, 1); + assert_eq!((scene.star, scene.place), (ctx.star, ctx.system.edge_place())); + let said = &scene.messages.last().unwrap().0; + assert!(said.contains(&format!("edge of the {} system", ctx.system.name)), "{said}"); + assert!(!said.contains("{where}"), "{said}"); + assert!(ctx.arrival_note.is_none()); + } + + #[test] + fn you_land_only_over_the_planet_and_slowly() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::None)); + assert!(scene.messages.last().unwrap().0.starts_with("Fly over")); + scene.flight.ship.pos = Vec2::ZERO; + scene.flight.ship.vel = Vec2::new(20.0, 0.0); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::None)); + assert!(scene.messages.last().unwrap().0.starts_with("Too fast")); + scene.flight.ship.vel = Vec2::new(2.0, 0.0); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::Push(_))); + // Walking back to the pilot's seat pops the ship's inside off, and + // the next tick finds you back at the controls. + ticks(&mut scene, &mut ctx, 1); + assert!(scene.messages.last().unwrap().0.starts_with("You lift off from")); + } + + #[test] + fn without_a_city_you_land_straight_at_the_dock() { + let mut ctx = test_ctx(); + let station = + ctx.system.places.iter().position(|p| matches!(p.kind, PlaceKind::Station(_))).unwrap(); + ctx.location = station; + let mut scene = FlightScene::new(&ctx); + let Transition::Push(mut docked) = scene.touch_down(&mut ctx) else { panic!("docks") }; + let l = KeyEvent::from(KeyCode::Char('l')); + assert!(matches!(docked.key(l, &mut ctx), Transition::Pop), "l undocks, from the dock"); + ticks(&mut scene, &mut ctx, 1); + assert!(scene.messages.last().unwrap().0.starts_with("You undock from")); + assert!(!ctx.landed); + + // A game saved there picks up there too. + ctx.landed = true; + let mut stack = resume(&ctx); + assert!(matches!(stack[1].key(l, &mut ctx), Transition::Pop)); + } + + #[test] + fn you_pick_up_where_you_were_landed_or_flying() { + let mut ctx = test_ctx(); + assert_eq!(resume(&ctx).len(), 1, "flying"); + let mut scene = FlightScene::new(&ctx); + scene.flight.ship.pos = Vec2::ZERO; + press(&mut scene, &mut ctx, KeyCode::Char('l')); + assert!(ctx.landed); + let mut stack = resume(&ctx); + assert_eq!(stack.len(), 2, "aboard, landed"); + ticks(&mut scene, &mut ctx, 1); + assert!(!ctx.landed, "taking off"); + + // Taking off from a game picked up landed is from where you landed. + ctx.landed = true; + stack[0].tick(&mut ctx); + assert!(!ctx.landed); + } + + #[test] + fn you_get_up_from_the_controls_only_when_slow() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + scene.flight.ship.vel = Vec2::new(20.0, 0.0); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('e')), Transition::None)); + assert!(scene.messages.last().unwrap().0.starts_with("Too fast")); + scene.flight.ship.vel = Vec2::new(2.0, 0.0); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('e')), Transition::Push(_))); + assert_eq!(scene.flight.speed(), 0.0, "the ship holds station"); + ticks(&mut scene, &mut ctx, 1); + assert!(scene.messages.last().unwrap().0.contains("take the controls")); + } + + #[test] + fn scooped_ore_goes_in_the_hold() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + let ore = &locale::ORES[2]; + let pos = scene.flight.ship.pos; + scene.flight.flotsam.push(crate::space::flight::Flotsam { + pos, + vel: Vec2::ZERO, + ore, + age: 0.0, + }); + ticks(&mut scene, &mut ctx, 1); + assert_eq!(ctx.hold.ore.get("iridium"), Some(&1)); + } + + #[test] + fn boarding_a_wreck_strips_it() { + let mut ctx = test_ctx(); + let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); + ctx.location = wreck.place; + let mut scene = FlightScene::new(&ctx); + let w = scene.flight.locale.wrecks.iter().find(|w| w.wreck == wreck).unwrap(); + scene.flight.ship.pos = w.pos; + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('b')), Transition::Push(_))); + assert!(ctx.stripped.contains(&wreck.seed)); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('b')), Transition::None)); + assert!(scene.messages.last().unwrap().0.contains("already stripped")); + // The hulk stays while you're here, and is gone when you come back. + let again = FlightScene::new(&ctx); + assert!(again.flight.locale.wrecks.iter().all(|w| w.wreck != wreck)); + } + + #[test] + fn wrecks_you_left_alone_drift_off_in_time() { + let mut ctx = test_ctx(); + let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); + ctx.location = wreck.place; + let here = |ctx: &Ctx| { + let scene = FlightScene::new(ctx); + scene.flight.locale.wrecks.iter().find(|w| w.wreck == wreck).map(|w| w.pos) + }; + let first = here(&ctx).expect("calling today"); + ctx.day = (ctx.day + wreck.leaves) / 2.0; + assert_eq!(here(&ctx), Some(first), "still there, in the same spot"); + ctx.day = wreck.leaves + 0.5; + assert_eq!(here(&ctx), None, "its beacon failed, and it's gone"); + } + + #[test] + fn a_wreck_you_set_out_for_waits_for_you() { + let mut ctx = test_ctx(); + let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); + let elsewhere = (0..ctx.system.places.len()).find(|&p| p != wreck.place).unwrap(); + ctx.location = elsewhere; + let here = |ctx: &Ctx| { + let scene = FlightScene::new(ctx); + scene.flight.locale.wrecks.iter().any(|w| w.wreck == wreck) + }; + // A trip so slow that its beacon fails on the way. + ctx.travel(wreck.place, wreck.leaves - ctx.day + 1.0); + assert!(!wreck.calling(ctx.day)); + assert!(here(&ctx), "you came by its last fix"); + assert!(ctx.wrecks().contains(&wreck), "and the map knows it's here"); + ctx.day += 3.0; + assert!(here(&ctx), "it stays while you do"); + ctx.travel(elsewhere, 1.0); + assert!(!ctx.wrecks().contains(&wreck)); + ctx.travel(wreck.place, 1.0); + assert!(!here(&ctx), "come back another time and it's gone"); + } +} diff --git a/src/scenes/forge.rs b/src/scenes/forge.rs index f856c8f..a49e098 100644 --- a/src/scenes/forge.rs +++ b/src/scenes/forge.rs @@ -10,6 +10,7 @@ use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Wrap}; +use super::keys; use super::{Scene, Transition}; use crate::app::Ctx; use crate::names::{self, Artifact, Culture}; @@ -51,7 +52,8 @@ impl Forge { } } -fn colour(culture: Culture) -> Color { +/// Each people's colour, wherever they're shown. +pub fn colour(culture: Culture) -> Color { match culture { Culture::Qaari => Color::Yellow, Culture::Cant => Color::Red, @@ -147,7 +149,11 @@ impl Scene for Forge { frame.render_widget(Paragraph::new(lines).block(block), artifacts); frame.render_widget( - Paragraph::new(" [space/→] forge again [←] previous [esc] back").dark_gray(), + Paragraph::new(keys::line(&[ + ("space →", "forge again"), + ("←", "previous"), + ("esc", "back"), + ])), footer, ); } diff --git a/src/scenes/galaxy_map.rs b/src/scenes/galaxy_map.rs new file mode 100644 index 0000000..5059b01 --- /dev/null +++ b/src/scenes/galaxy_map.rs @@ -0,0 +1,929 @@ +//! The star map: every system in the galaxy, the lanes between them, and +//! whose space is whose. +//! +//! The map is drawn at one scale, a light-year to a cell across (two down), +//! and scrolls to keep the chosen system in view; an inset in the corner +//! shows the whole galaxy and where the view is. Each people's territory is +//! a faint stipple in their colour round the systems they hold, brighter +//! along the borders. +//! +//! You know the way only to systems you've charted: those you've been to +//! and those one lane from them. Choose one and the map plots the fewest +//! jumps there through charted space; `enter` makes them, a jump's worth of +//! fuel at a time. Beyond the charted systems are faint dots, unnamed, +//! except for the four seats, which everyone knows. +//! +//! Run dry where no one sells fuel and `t` calls a tow, to the nearest +//! system that does. It costs what it costs: what you can't pay, you owe. + +use std::collections::HashSet; + +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::symbols::Marker; +use ratatui::text::{Line, Span}; +use ratatui::widgets::canvas::{Canvas, Line as Lane, Points}; +use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; + +use super::keys; +use super::port::FUEL; +use super::sky; +use super::system_map::{FAILING, FAINT_WRECK, SystemMap, wrap}; +use super::{Scene, Transition}; +use crate::app::Ctx; +use crate::names::{self, Culture}; +use crate::space::Vec2; +use crate::space::galaxy::Galaxy; +use crate::space::system::{Landing, Rgb, System, SystemKind}; +use crate::trade; + +const PANEL_WIDTH: u16 = 44; +/// Below this width the panel goes under the map instead of beside it. +const SIDE_BY_SIDE: u16 = 90; +const ACCENT: Color = Color::Rgb(240, 200, 110); +const DIM: Color = Color::Rgb(110, 114, 132); +const LANE: Color = Color::Rgb(86, 92, 116); +const FAINT_LANE: Color = Color::Rgb(50, 53, 68); +const NO_FUEL: Color = Color::Rgb(214, 112, 100); +/// How far a people's territory reaches past their outermost stars, in +/// light-years. +const REACH: f64 = 11.0; +/// The inset showing the whole galaxy, inside its border. +const INSET: (u16, u16) = (26, 8); + +/// A people's colour on the map: the colour of their stations. +pub fn people_rgb(culture: Culture) -> Rgb { + sky::station_art(culture).1 +} + +pub struct GalaxyMap { + selected: usize, + /// The chosen system, generated, if you've been there and it isn't + /// where you are (that's `ctx.system`). + known: Option<System>, + /// What happened when you last tried to go somewhere. + report: Option<String>, +} + +impl GalaxyMap { + pub fn new(ctx: &Ctx) -> GalaxyMap { + GalaxyMap { selected: ctx.star, known: None, report: None } + } + + fn select(&mut self, site: usize, ctx: &Ctx) { + if site == self.selected { + return; + } + self.selected = site; + self.report = None; + self.known = + (site != ctx.star && ctx.visited.contains(&site)).then(|| ctx.galaxy.system(site)); + } + + /// The system nearest the chosen one in a direction (y up), within a + /// cone either side of it. + fn step(&mut self, dir: Vec2, ctx: &Ctx) { + let g = &ctx.galaxy; + let from = g.sites[self.selected].pos; + let best = g + .sites + .iter() + .enumerate() + .filter(|&(i, _)| i != self.selected) + .filter_map(|(i, s)| { + let off = s.pos - from; + let d = off.length(); + let cos = off.dot(dir) / d; + (cos > 0.5).then_some((i, d * (1.0 + 2.0 * (1.0 - cos)))) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)); + if let Some((i, _)) = best { + self.select(i, ctx); + } + } + + /// The next system a lane from here leads to. + fn next_lane(&mut self, ctx: &Ctx) { + let lanes = &ctx.galaxy.sites[ctx.star].lanes; + let next = match lanes.iter().position(|&l| l == self.selected) { + Some(i) => lanes[(i + 1) % lanes.len()], + None => lanes[0], + }; + self.select(next, ctx); + } + + /// The jumps from here to the chosen system, through charted space. + fn route(&self, ctx: &Ctx, charted: &[bool]) -> Option<Vec<usize>> { + if !charted[self.selected] { + return None; + } + ctx.galaxy.route(ctx.star, self.selected, |i| charted[i]) + } + + /// Jumps to the chosen system, if you can. + fn travel(&mut self, ctx: &mut Ctx) -> Transition { + let g = &ctx.galaxy; + let to = self.selected; + let charted = g.charted(&ctx.visited); + let refusal = if to == ctx.star { + Some(format!("You're in {} already.", g.sites[to].name)) + } else if !charted[to] { + Some( + "That system's uncharted: you don't know the lanes that far. Jump closer first." + .into(), + ) + } else { + None + }; + if let Some(refusal) = refusal { + self.report = Some(refusal); + return Transition::None; + } + let Some(route) = self.route(ctx, &charted) else { + self.report = Some("There's no charted route there.".into()); + return Transition::None; + }; + let jumps = route.len() as u32; + let fuel = ctx.hold.fuel; + if fuel < jumps { + self.report = Some(if fuel == 0 && ctx.stranded() { + "Your tank's dry, and no one here sells fuel. [t] calls a tow.".into() + } else if fuel == 0 { + "Your tank's dry. Fuel's sold here: at a station, or a city's shipyard.".into() + } else { + format!( + "That's {jumps} jumps, and you've fuel for {}. Choose somewhere nearer, \ + or fill up first.", + plural(fuel, "jump") + ) + }); + return Transition::None; + } + let days = g.route_days(ctx.star, &route); + let name = g.sites[to].name.clone(); + ctx.day += days; + ctx.hold.fuel -= jumps; + ctx.visited.extend(route.iter().copied()); + ctx.enter_system(to); + ctx.arrival_note = Some(format!( + "{} and {} later, you come out of the jump at the edge of the {name} system, {{where}}.", + capitalise(&plural(jumps, "jump")), + days_text(days) + )); + Transition::Pop + } + + /// Calls a tow, if you're stranded. + fn tow(&mut self, ctx: &mut Ctx) -> Transition { + if !ctx.stranded() { + self.report = Some(if ctx.hold.fuel > 0 { + "You've fuel enough to jump. No one tows a ship that can fly.".into() + } else { + "Fuel's for sale here, at a station or a city's shipyard. No tow will come for that." + .into() + }); + return Transition::None; + } + let g = &ctx.galaxy; + let Some(route) = g.nearest(ctx.star, |i| g.sites[i].kind.sells_fuel()) else { + self.report = Some("No one answers.".into()); + return Transition::None; + }; + let to = *route.last().unwrap(); + let fee = trade::tow_fee(route.len()); + // Towing is slow: twice as long as jumping yourself. + let days = 2.0 * g.route_days(ctx.star, &route); + let name = g.sites[to].name.clone(); + ctx.day += days; + ctx.visited.extend(route.iter().copied()); + let owed = ctx.hold.charge(fee); + ctx.enter_system(to); + if let Some(place) = ctx.system.fuel_place() { + ctx.location = place; + } + let bill = if owed == 0 { + format!("The fee is {fee} ¤, and you pay it.") + } else { + format!( + "The fee is {fee} ¤. You pay what you can, and owe {owed} ¤; \ + what you earn goes to it first." + ) + }; + ctx.arrival_note = Some(format!( + "{} later the tow casts you off in the {name} system, {{where}}. {bill}", + capitalise(&days_text(days)) + )); + Transition::Pop + } +} + +impl Scene for GalaxyMap { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + match key.code { + KeyCode::Up | KeyCode::Char('w' | 'k') => self.step(Vec2::new(0.0, 1.0), ctx), + KeyCode::Down | KeyCode::Char('s' | 'j') => self.step(Vec2::new(0.0, -1.0), ctx), + KeyCode::Left | KeyCode::Char('a' | 'h') => self.step(Vec2::new(-1.0, 0.0), ctx), + KeyCode::Right | KeyCode::Char('d' | 'l') => self.step(Vec2::new(1.0, 0.0), ctx), + KeyCode::Tab => self.next_lane(ctx), + KeyCode::Char('.') => self.select(ctx.star, ctx), + KeyCode::Enter => return self.travel(ctx), + KeyCode::Char('t') => return self.tow(ctx), + KeyCode::Char('m') => return Transition::Replace(vec![Box::new(SystemMap::new(ctx))]), + KeyCode::Esc | KeyCode::Char('g' | 'q') => return Transition::Pop, + _ => {} + } + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let area = frame.area(); + let (map, panel) = if area.width >= SIDE_BY_SIDE { + let [map, panel] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) + .areas(area); + (map, panel) + } else { + let rows = (area.height / 2).min(16); + let [map, panel] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(rows)]).areas(area); + (map, panel) + }; + let charted = ctx.galaxy.charted(&ctx.visited); + let route = self.route(ctx, &charted).unwrap_or_default(); + if map.width >= 4 && map.height >= 2 { + let view = View::new(&ctx.galaxy, map, ctx.galaxy.sites[self.selected].pos); + self.draw_map(frame, &view, ctx, &charted, &route); + if map.width >= 60 && map.height >= 18 && !view.shows_all(&ctx.galaxy) { + draw_inset(frame.buffer_mut(), map, &view, ctx, self.selected); + } + } + self.draw_panel(frame, panel, ctx, &charted, &route); + } +} + +/// Which part of the galaxy is on screen: a light-year to a cell across, +/// two to a cell down. +struct View { + area: Rect, + left: f64, + top: f64, +} + +impl View { + /// Centred on `focus`, as far as that doesn't scroll off into empty + /// space. + fn new(galaxy: &Galaxy, area: Rect, focus: Vec2) -> View { + let (low, high) = galaxy.bounds(); + let margin = 8.0; + let w = f64::from(area.width); + let h = f64::from(area.height) * 2.0; + let fit = |focus: f64, low: f64, high: f64, span: f64| { + if high - low + 2.0 * margin <= span { + (low + high) / 2.0 + } else { + focus.clamp(low - margin + span / 2.0, high + margin - span / 2.0) + } + }; + let cx = fit(focus.x, low.x, high.x, w); + let cy = fit(focus.y, low.y, high.y, h); + View { area, left: cx - w / 2.0, top: cy + h / 2.0 } + } + + fn right(&self) -> f64 { + self.left + f64::from(self.area.width) + } + + fn bottom(&self) -> f64 { + self.top - 2.0 * f64::from(self.area.height) + } + + /// The cell a point falls in, if it's on screen. + fn cell(&self, p: Vec2) -> Option<(u16, u16)> { + let col = (p.x - self.left).floor(); + let row = ((self.top - p.y) / 2.0).floor(); + let inside = col >= 0.0 + && row >= 0.0 + && col < f64::from(self.area.width) + && row < f64::from(self.area.height); + inside.then(|| (self.area.x + col as u16, self.area.y + row as u16)) + } + + fn shows_all(&self, galaxy: &Galaxy) -> bool { + let (low, high) = galaxy.bounds(); + low.x >= self.left && high.x < self.right() && high.y <= self.top && low.y > self.bottom() + } +} + +/// Which people's territory a point is in, if any, and whether it's near +/// a border. +fn territory(galaxy: &Galaxy, p: Vec2) -> Option<(Culture, bool)> { + let mut nearest = (f64::MAX, Culture::Qaari); + for s in &galaxy.sites { + let d = s.pos.distance(p); + if d < nearest.0 { + nearest = (d, s.culture); + } + } + if nearest.0 > REACH { + return None; + } + let other = galaxy + .sites + .iter() + .filter(|s| s.culture != nearest.1) + .map(|s| s.pos.distance(p)) + .fold(f64::MAX, f64::min); + Some((nearest.1, other - nearest.0 < 1.6)) +} + +impl GalaxyMap { + fn draw_map( + &self, + frame: &mut Frame, + view: &View, + ctx: &Ctx, + charted: &[bool], + route: &[usize], + ) { + let g = &ctx.galaxy; + let area = view.area; + + // Territory: a faint dot in every other cell, and every cell along + // a border. Each people's dots are drawn together, in their colour. + let mut tint: Vec<(Color, Vec<(f64, f64)>)> = Vec::new(); + for c in Culture::ALL { + let rgb = people_rgb(c); + tint.push((sky::shade(rgb, 0.5), Vec::new())); + tint.push((sky::shade(rgb, 0.85), Vec::new())); + } + for row in 0..area.height { + for col in 0..area.width { + let p = Vec2::new( + view.left + f64::from(col) + 0.25, + view.top - 2.0 * f64::from(row) - 0.75, + ); + let Some((culture, border)) = territory(g, p) else { continue }; + if border || (col + row) % 2 == 0 { + tint[culture as usize * 2 + usize::from(border)].1.push((p.x, p.y)); + } + } + } + let on_route = |a: usize, b: usize| { + let mut stops = vec![ctx.star]; + stops.extend(route); + stops.windows(2).any(|w| (w[0] == a && w[1] == b) || (w[0] == b && w[1] == a)) + }; + let canvas = Canvas::default() + .marker(Marker::Braille) + .background_color(Color::Reset) + .x_bounds([view.left, view.right()]) + .y_bounds([view.bottom(), view.top]) + .paint(|c| { + for (color, coords) in &tint { + c.draw(&Points { coords, color: *color }); + } + c.layer(); + for (i, s) in g.sites.iter().enumerate() { + for &j in s.lanes.iter().filter(|&&j| j > i) { + let colour = match (charted[i], charted[j]) { + _ if on_route(i, j) => continue, + (true, true) => LANE, + (true, false) | (false, true) => FAINT_LANE, + (false, false) => continue, + }; + let t = &g.sites[j]; + c.draw(&Lane::new(s.pos.x, s.pos.y, t.pos.x, t.pos.y, colour)); + } + } + c.layer(); + let mut at = g.sites[ctx.star].pos; + for &next in route { + let to = g.sites[next].pos; + c.draw(&Lane::new(at.x, at.y, to.x, to.y, ACCENT)); + at = to; + } + }); + frame.render_widget(canvas, area); + self.draw_stars(frame.buffer_mut(), view, ctx, charted, route); + } + + /// Each star, then names, most important first, wherever they fit. + fn draw_stars( + &self, + buf: &mut Buffer, + view: &View, + ctx: &Ctx, + charted: &[bool], + route: &[usize], + ) { + let g = &ctx.galaxy; + let mut taken: HashSet<(u16, u16)> = HashSet::new(); + for (i, s) in g.sites.iter().enumerate() { + let Some(cell) = view.cell(s.pos) else { continue }; + let rgb = people_rgb(s.culture); + let (glyph, style) = if i == ctx.star { + ("@", Style::new().fg(Color::White).bold()) + } else if g.is_seat(i) && (charted[i] || ctx.visited.contains(&i)) { + ("◉", Style::new().fg(sky::rgb(rgb)).bold()) + } else if g.is_seat(i) { + ("◉", Style::new().fg(sky::shade(rgb, 0.7))) + } else if ctx.visited.contains(&i) { + ("●", Style::new().fg(sky::rgb(rgb))) + } else if charted[i] { + ("○", Style::new().fg(sky::rgb(rgb))) + } else { + ("·", Style::new().fg(sky::shade(rgb, 0.75))) + }; + buf[cell].set_symbol(glyph).set_style(style); + taken.insert(cell); + if i == self.selected { + for (dx, bracket) in [(-1i32, "["), (1, "]")] { + let x = i32::from(cell.0) + dx; + if x >= i32::from(view.area.x) && x < i32::from(view.area.right()) { + buf[(x as u16, cell.1)].set_symbol(bracket).set_fg(ACCENT); + taken.insert((x as u16, cell.1)); + } + } + } + } + + // Who gets a name, in order: you, the choice, the way there, the + // seats, where you've been, what's charted. + let mut order: Vec<usize> = vec![ctx.star, self.selected]; + order.extend(route); + order.extend((0..g.sites.len()).filter(|&i| g.is_seat(i))); + order.extend(ctx.visited.iter().copied()); + order.extend((0..g.sites.len()).filter(|&i| charted[i])); + let mut named = HashSet::new(); + for i in order { + if !named.insert(i) { + continue; + } + let s = &g.sites[i]; + let Some((col, row)) = view.cell(s.pos) else { continue }; + let known = charted[i] || g.is_seat(i) || ctx.visited.contains(&i); + let text = if known { s.name.clone() } else { "uncharted".to_string() }; + let fg = if i == self.selected { + ACCENT + } else if i == ctx.star { + Color::White + } else if ctx.visited.contains(&i) && !s.kind.sells_fuel() { + NO_FUEL + } else if ctx.visited.contains(&i) { + Color::Rgb(200, 204, 216) + } else if charted[i] { + Color::Rgb(148, 152, 170) + } else { + sky::shade(people_rgb(s.culture), 0.7) + }; + let start = col + 2; + let width = text.chars().count() as u16; + let end = start.saturating_add(width).min(view.area.right()); + if start >= end { + continue; + } + let must = i == ctx.star || i == self.selected; + let clear = (start.saturating_sub(1)..end + 1).all(|x| !taken.contains(&(x, row))); + if !(must || clear) { + continue; + } + let shown: String = text.chars().take(usize::from(end - start)).collect(); + buf.set_string(start, row, shown, Style::new().fg(fg)); + for x in start..end { + taken.insert((x, row)); + } + } + } + + fn draw_panel( + &self, + frame: &mut Frame, + area: Rect, + ctx: &Ctx, + charted: &[bool], + route: &[usize], + ) { + let block = Block::bordered() + .title(format!(" star map · day {:.1} ", ctx.day)) + .title_style(Style::new().fg(ACCENT).bold()) + .border_style(Style::new().fg(DIM)); + let inner = block.inner(area); + frame.render_widget(block, area); + let mut hints = vec![("←↑↓→", "choose"), ("tab", "lanes"), ("enter", "jump")]; + if ctx.stranded() { + hints.push(("t", "tow")); + } + hints.extend([(".", "where you are"), ("m", "system map"), ("esc", "back")]); + let hints = keys::packed(&hints, inner.width); + let [about, keys] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(2 + hints.len() as u16)]) + .areas(inner); + let width = usize::from(about.width); + frame.render_widget( + Paragraph::new(self.about(ctx, charted, route, width)).wrap(Wrap { trim: false }), + about, + ); + + let mut legend = vec![ + Line::from(vec![ + Span::raw("@").fg(Color::White).bold(), + Span::raw(" you ").fg(DIM), + Span::raw("◉").fg(DIM), + Span::raw(" seat ").fg(DIM), + Span::raw("●").fg(DIM), + Span::raw(" been ").fg(DIM), + Span::raw("○").fg(DIM), + Span::raw(" charted ").fg(DIM), + Span::raw("·").fg(DIM), + Span::raw(" not").fg(DIM), + ]), + Line::from( + Culture::ALL + .into_iter() + .flat_map(|c| { + [ + Span::raw("■ ").fg(sky::rgb(people_rgb(c))), + Span::raw(format!("{} ", c.adjective())).fg(DIM), + ] + }) + .collect::<Vec<_>>(), + ), + ]; + legend.extend(hints); + frame.render_widget(Paragraph::new(legend), keys); + } + + /// What's known about the chosen system, and the way there. + fn about( + &self, + ctx: &Ctx, + charted: &[bool], + route: &[usize], + width: usize, + ) -> Vec<Line<'static>> { + let g = &ctx.galaxy; + let i = self.selected; + let site = &g.sites[i]; + let h = &ctx.hold; + let dim = Style::new().fg(DIM); + let plain = Style::new(); + let label = |text: &str| vec![Span::raw(format!("{text:<8}")).fg(DIM)]; + + let gauge: String = + (0..h.fuel_capacity).map(|k| if k < h.fuel { '◆' } else { '◇' }).collect(); + let mut lines = vec![Line::from(vec![ + Span::raw(format!("{:<8}", "fuel")).fg(DIM), + Span::raw(gauge).fg(FUEL), + Span::raw(format!(" {} of {}", h.fuel, plural(h.fuel_capacity, "jump"))), + ])]; + if h.debt > 0 { + lines.push(Line::from(vec![ + Span::raw(format!("{:<8}", "owed")).fg(DIM), + Span::raw(format!("{} ¤, paid from what you earn", h.debt)), + ])); + } + if ctx.stranded() { + lines.push(Line::default()); + let (to, fee) = match g.nearest(ctx.star, |s| g.sites[s].kind.sells_fuel()) { + Some(r) => (g.sites[*r.last().unwrap()].name.clone(), trade::tow_fee(r.len())), + None => ("nowhere".into(), 0), + }; + lines.extend(wrap( + vec![Span::raw("Stranded. ").fg(NO_FUEL).bold()], + &[( + format!( + "Your tank's dry and no one here sells fuel. [t] calls a tow to {to}, \ + for {fee} ¤; what you can't pay, you'll owe." + ), + plain, + )], + width, + )); + } + lines.push(Line::default()); + + let known = charted[i] || g.is_seat(i) || ctx.visited.contains(&i); + let title = if known { site.name.clone() } else { "An uncharted system".into() }; + lines.push(Line::from(title).fg(ACCENT).bold()); + let whose = site.culture.name(); + let what = if ctx.visited.contains(&i) { + match site.kind { + SystemKind::Seat => format!("the seat of {whose}"), + kind => format!("{} of {whose}", kind.name()), + } + } else if g.is_seat(i) { + format!("the seat of {whose}; you've not been there") + } else if charted[i] { + format!("in the space of {whose}; you've not been there") + } else { + format!("in the space of {whose}, past what you've charted") + }; + let head = vec![Span::raw("■ ").fg(sky::rgb(people_rgb(site.culture)))]; + lines.extend(wrap(head, &[(what, plain)], width)); + // The way there. + if i == ctx.star { + let lanes: Vec<String> = site.lanes.iter().map(|&l| g.sites[l].name.clone()).collect(); + lines.extend(wrap( + label("lanes"), + &[(format!("to {}", join_names(&lanes)), plain)], + width, + )); + } else if !charted[i] { + lines.extend(wrap( + Vec::new(), + &[("Uncharted: jump closer to learn the way.".into(), dim)], + width, + )); + } else if route.is_empty() { + lines.extend(wrap(Vec::new(), &[("No charted route there.".into(), dim)], width)); + } else { + let jumps = route.len() as u32; + let days = g.route_days(ctx.star, route); + let short = h.fuel < jumps; + let fuel_text = format!("· {} of fuel", plural(jumps, "jump")); + lines.extend(wrap( + label("route"), + &[ + (format!("{}, {}", plural(jumps, "jump"), days_text(days)), plain), + ( + fuel_text, + if short { Style::new().fg(NO_FUEL) } else { Style::new().fg(FUEL) }, + ), + ], + width, + )); + if route.len() > 1 { + let via: Vec<String> = + route[..route.len() - 1].iter().map(|&s| g.sites[s].name.clone()).collect(); + lines.extend(wrap(label("via"), &[(via.join(" → "), plain)], width)); + } + if short { + lines.extend(wrap( + Vec::new(), + &[( + format!("You've fuel for {}.", plural(h.fuel, "jump")), + Style::new().fg(NO_FUEL), + )], + width, + )); + } + } + if let Some(report) = &self.report { + lines.push(Line::default()); + lines.extend(wrap(Vec::new(), &[(report.clone(), Style::new().fg(ACCENT))], width)); + } + lines.push(Line::default()); + + // What it is, and what's there if you've been. + if known { + lines.extend(wrap( + label("star"), + &[ + (names::a(site.star.name), plain), + (format!("· {:.0} ly from here", g.distance(ctx.star, i)), dim), + ], + width, + )); + } + if let Some(neighbour) = site.neighbour.filter(|_| ctx.visited.contains(&i)) { + lines.extend(wrap( + label("border"), + &[(format!("with {}, who settle here too", neighbour.name()), plain)], + width, + )); + } + + let system = if i == ctx.star { Some(&ctx.system) } else { self.known.as_ref() }; + if let Some(s) = system { + lines.extend(wrap(Vec::new(), &[(site.kind.blurb().to_string(), dim)], width)); + lines.extend(contents(s, ctx, width)); + } else if charted[i] { + lines.extend(wrap( + Vec::new(), + &[("What's there, you'll know when you've been. Ask in a tavern.".into(), dim)], + width, + )); + } + lines + } +} + +/// What's in a system you've been to: its capitals, stations, fuel and +/// wrecks. +fn contents(s: &System, ctx: &Ctx, width: usize) -> Vec<Line<'static>> { + let plain = Style::new(); + let dim = Style::new().fg(DIM); + let label = |text: &str| vec![Span::raw(format!("{text:<8}")).fg(DIM)]; + let mut lines = Vec::new(); + let capitals: Vec<String> = (0..s.places.len()) + .filter(|&p| matches!(s.landing(p), Landing::Capital(_))) + .map(|p| s.places[p].name.clone()) + .collect(); + let worlds = s.planets.len(); + let mut parts = vec![(plural(worlds as u32, "world"), plain)]; + match capitals.len() { + 0 => parts.push(("· no capital".into(), dim)), + _ => parts.push(( + format!("· ⌂ {}", join_names(&capitals)), + Style::new().fg(Color::Rgb(130, 214, 140)), + )), + } + lines.extend(wrap(label("worlds"), &parts, width)); + if !s.stations.is_empty() { + let names: Vec<String> = s.stations.iter().map(|st| st.name.clone()).collect(); + lines.extend(wrap(label("docks"), &[(join_names(&names), plain)], width)); + } + let fuel = if s.sells_fuel() { + ("for sale".to_string(), Style::new().fg(FUEL)) + } else { + ("none for sale".to_string(), Style::new().fg(NO_FUEL)) + }; + lines.extend(wrap(label("fuel"), &[fuel], width)); + let beacons = s.beacons(ctx.day, &ctx.stripped); + if !beacons.is_empty() { + let failing = beacons.iter().filter(|w| w.leaves - ctx.day <= FAILING).count(); + let mut parts = vec![( + format!("{} calling", plural(beacons.len() as u32, "beacon")), + Style::new().fg(Color::Rgb(255, 120, 100)), + )]; + if failing > 0 { + parts.push((format!("· {failing} failing"), Style::new().fg(FAINT_WRECK))); + } + lines.extend(wrap(label("wrecks"), &parts, width)); + } + if s.belt.density > 1.0 { + lines.extend(wrap( + label("belt"), + &[(format!("{}: dense, and rich in ore", s.belt.name), plain)], + width, + )); + } + lines +} + +/// The whole galaxy, small, in the map's top-left corner: each people's +/// space, where you are, the choice, and the part the map shows. +fn draw_inset(buf: &mut Buffer, map: Rect, view: &View, ctx: &Ctx, selected: usize) { + let g = &ctx.galaxy; + let (w, h) = INSET; + let outer = Rect::new(map.x, map.y, w + 2, h + 2); + let block = Block::bordered() + .title(" galaxy ") + .title_style(Style::new().fg(DIM)) + .border_style(Style::new().fg(DIM)); + let inner = block.inner(outer); + ratatui::widgets::Clear.render(outer, buf); + block.render(outer, buf); + let (low, high) = g.bounds(); + let margin = 6.0; + let (low, high) = (low - Vec2::new(margin, margin), high + Vec2::new(margin, margin)); + let to_cell = |p: Vec2| { + let col = ((p.x - low.x) / (high.x - low.x) * f64::from(w)).floor(); + let row = ((high.y - p.y) / (high.y - low.y) * f64::from(h)).floor(); + (col.clamp(0.0, f64::from(w - 1)) as u16, row.clamp(0.0, f64::from(h - 1)) as u16) + }; + for row in 0..h { + for col in 0..w { + let p = Vec2::new( + low.x + (f64::from(col) + 0.5) / f64::from(w) * (high.x - low.x), + high.y - (f64::from(row) + 0.5) / f64::from(h) * (high.y - low.y), + ); + let cell = &mut buf[(inner.x + col, inner.y + row)]; + cell.set_symbol(" "); + if let Some((c, _)) = territory(g, p) { + cell.set_bg(sky::shade(people_rgb(c), 0.28)); + } + } + } + // The view, as corners. + let (a, b) = + (to_cell(Vec2::new(view.left, view.top)), to_cell(Vec2::new(view.right(), view.bottom()))); + for (x, y, ch) in [(a.0, a.1, "┌"), (b.0, a.1, "┐"), (a.0, b.1, "└"), (b.0, b.1, "┘")] { + buf[(inner.x + x, inner.y + y)].set_symbol(ch).set_fg(Color::Rgb(200, 204, 216)); + } + let (x, y) = to_cell(g.sites[selected].pos); + buf[(inner.x + x, inner.y + y)].set_symbol("+").set_style(Style::new().fg(ACCENT).bold()); + let (x, y) = to_cell(g.sites[ctx.star].pos); + buf[(inner.x + x, inner.y + y)].set_symbol("@").set_style(Style::new().fg(Color::White).bold()); +} + +/// "1 jump", "3 jumps". +fn plural(n: u32, what: &str) -> String { + if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") } +} + +fn days_text(days: f64) -> String { + format!("{days:.1} days") +} + +/// "A", "A and B", "A, B and C". +fn join_names(names: &[String]) -> String { + match names { + [] => String::new(), + [one] => one.clone(), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::scenes::tests::test_ctx; + + fn press(map: &mut GalaxyMap, ctx: &mut Ctx, code: KeyCode) -> Transition { + map.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + #[test] + fn jumping_along_a_lane_costs_fuel_and_days() { + let mut ctx = test_ctx(); + let mut map = GalaxyMap::new(&ctx); + let start = ctx.star; + press(&mut map, &mut ctx, KeyCode::Tab); + let to = map.selected; + assert!(ctx.galaxy.sites[start].lanes.contains(&to)); + let days = ctx.galaxy.jump_days(start, to); + ctx.departed = Some(0.0); + assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::Pop)); + assert_eq!(ctx.star, to); + assert_eq!(ctx.departed, None, "no wreck here waits for you"); + assert_eq!(ctx.system.name, ctx.galaxy.sites[to].name); + assert_eq!(ctx.location, ctx.system.edge_place(), "you come out at the edge"); + assert_eq!(ctx.hold.fuel, crate::app::Hold::BASE_FUEL - 1); + assert!((ctx.day - days).abs() < 1e-9); + assert!(ctx.visited.contains(&to)); + assert!(ctx.arrival_note.as_ref().unwrap().contains("1 jump and")); + } + + #[test] + fn you_can_only_plot_through_charted_space_and_on_the_fuel_you_have() { + let mut ctx = test_ctx(); + let g = &ctx.galaxy; + let charted = g.charted(&ctx.visited); + let far = (0..g.sites.len()).find(|&i| !charted[i]).unwrap(); + let mut map = GalaxyMap::new(&ctx); + map.select(far, &ctx); + assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::None)); + assert!(map.report.as_ref().unwrap().contains("uncharted")); + + let next = ctx.galaxy.sites[ctx.star].lanes[0]; + map.select(next, &ctx); + ctx.hold.fuel = 0; + assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::None)); + assert!(map.report.as_ref().unwrap().contains("dry")); + assert_eq!(ctx.day, 0.0); + } + + #[test] + fn stranded_ships_are_towed_and_owe_for_it() { + let mut ctx = test_ctx(); + let g = &ctx.galaxy; + let dead = (0..g.sites.len()).find(|&i| !g.sites[i].kind.sells_fuel()).unwrap(); + ctx.enter_system(dead); + ctx.hold.fuel = 0; + ctx.hold.scrip = 20; + assert!(ctx.stranded()); + let mut map = GalaxyMap::new(&ctx); + assert!(matches!(press(&mut map, &mut ctx, KeyCode::Char('t')), Transition::Pop)); + assert!(ctx.system.sells_fuel()); + assert!(ctx.system.sells_fuel_at(ctx.location), "dropped where fuel's sold"); + assert_eq!(ctx.hold.scrip, 0); + assert!(ctx.hold.debt > 0); + assert!(ctx.day > 0.0); + assert!(!ctx.stranded()); + // No tow for a ship that can buy fuel where it is. + let mut map = GalaxyMap::new(&ctx); + assert!(matches!(press(&mut map, &mut ctx, KeyCode::Char('t')), Transition::None)); + } + + #[test] + fn arrows_move_between_stars() { + let ctx = test_ctx(); + let mut map = GalaxyMap::new(&ctx); + let from = ctx.galaxy.sites[map.selected].pos; + map.step(Vec2::new(1.0, 0.0), &ctx); + if map.selected != ctx.star { + assert!(ctx.galaxy.sites[map.selected].pos.x > from.x); + } + map.select(ctx.star, &ctx); + assert_eq!(map.selected, ctx.star); + } + + #[test] + fn names_join_naturally() { + let names = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>(); + assert_eq!(join_names(&names(&["A"])), "A"); + assert_eq!(join_names(&names(&["A", "B", "C"])), "A, B and C"); + assert_eq!(plural(1, "jump"), "1 jump"); + } +} diff --git a/src/scenes/inventory.rs b/src/scenes/inventory.rs new file mode 100644 index 0000000..d9dd249 --- /dev/null +++ b/src/scenes/inventory.rs @@ -0,0 +1,1808 @@ +//! The inventory, after Cataclysm's: everything on you, grouped by what it +//! is, with all about whatever's picked out; and beside it, when there is +//! one, another pile to move things to and from: your ship's lockers, what's +//! on the floor where you stand, or an armourer's rack to buy from and sell +//! to, with your armour to mend. +//! +//! Vim keys throughout: `j` `k` move, `gg` `G` go to the top and bottom, +//! ctrl-d and ctrl-u by half a page, `h` `l` between the lists, `/` finds. +//! `o` sorts both lists by something else (`Sort`), within each kind of +//! thing, and `O` turns the sort round. Every row shows what it weighs and +//! what it would fetch. +//! A count before a key does it that many times: `3d` drops three, `D` the +//! whole stack. `enter` lists everything you can do with what's picked out. +//! +//! It isn't a scene of its own. The scene you're walking about in opens it +//! over itself and carries out what it asks for (`Command`), since only +//! that scene knows what putting something down means there, and whether +//! it takes a turn. + +use std::cell::Cell; +use std::collections::BTreeSet; + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap}; +use serde::{Deserialize, Serialize}; + +use super::keys; +use super::sky::rgb; +use crate::dungeon::armour::Armor; +use crate::dungeon::arms::{Condition, Dmg, Weapon}; +use crate::dungeon::crawl::{Affinity, MEDPATCH_HEAL, MONSTERS}; +use crate::items::{self, Category, Gear, Item, LOCKER_CAPACITY, Slot, Spot, Stack}; +use crate::space::system::Rgb; +use crate::trade::{self, Terms}; + +const ACCENT: Color = Color::Rgb(240, 200, 110); +/// What something would fetch somewhere else: about this. +const GUESS: Color = Color::Rgb(184, 166, 120); +const DIM: Color = Color::Rgb(120, 124, 142); +const HEADING: Color = Color::Rgb(160, 166, 190); +/// Wider than this, the details sit beside the lists rather than below. +const WIDE: u16 = 110; +/// A list narrower than this shows what things fetch, but not what they +/// weigh. +const NARROW: usize = 30; +/// How far in things sit from the heading they're under. +const INDENT: &str = " "; +const DETAILS_HEIGHT: u16 = 13; + +/// How something looks lying about: a glyph and a colour. +pub fn look(item: &Item) -> (char, Rgb) { + match item { + Item::Weapon(w) => (if w.is_gun() { '}' } else { ')' }, w.dmg.rgb()), + Item::Armor(_) => ('[', (170, 182, 204)), + Item::Food(_) => ('%', (214, 176, 112)), + Item::Medpatch => ('!', (240, 96, 96)), + Item::Artifact(_) => ('*', (255, 214, 92)), + Item::Salvage(_) => ('(', (150, 200, 222)), + } +} + +/// The pile beside you. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Beside { + /// Your ship's lockers. + Lockers, + /// The floor where you stand: "on the floor", "on the deck". + Floor(&'static str), + /// An armourer's rack: what's for sale (see `View::shop`). + Rack, +} + +/// The armourer you're trading with, whose rack is the pile beside you. +#[derive(Clone, Copy)] +pub struct Counter<'a> { + pub terms: Terms, + /// What's in your ship's lockers: they'll buy from there too. + pub aboard: &'a [Stack], + pub scrip: u32, +} + +/// What the lists are sorted by, within each kind of thing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SortBy { + #[default] + Name, + Weight, + /// What it fetches, or here costs. + Price, + /// Price for its weight. + Density, + /// Armour, most worn first; the rest after. + Durability, + /// Weapons by their average damage, armour by what it guards. + Strength, + /// The order you came by things in. + Newest, +} + +impl SortBy { + const ALL: [SortBy; 7] = [ + SortBy::Name, + SortBy::Weight, + SortBy::Price, + SortBy::Density, + SortBy::Durability, + SortBy::Strength, + SortBy::Newest, + ]; + + /// The sort after this one, round to the first. + fn next(self) -> SortBy { + let i = SortBy::ALL.iter().position(|&s| s == self).unwrap_or(0); + SortBy::ALL[(i + 1) % SortBy::ALL.len()] + } +} + +/// How the lists are sorted: by what, and which way round. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Sort { + pub by: SortBy, + /// The other way round from usual: names z–a, the lightest first, the + /// cheapest first... + #[serde(default)] + pub reversed: bool, +} + +impl Sort { + /// How it reads: "heaviest first", "by name". + pub fn says(self) -> &'static str { + match (self.by, self.reversed) { + (SortBy::Name, false) => "by name", + (SortBy::Name, true) => "by name, z–a", + (SortBy::Weight, false) => "heaviest first", + (SortBy::Weight, true) => "lightest first", + (SortBy::Price, false) => "dearest first", + (SortBy::Price, true) => "cheapest first", + (SortBy::Density, false) => "most ¤/kg first", + (SortBy::Density, true) => "least ¤/kg first", + (SortBy::Durability, false) => "most worn first", + (SortBy::Durability, true) => "least worn first", + (SortBy::Strength, false) => "strongest first", + (SortBy::Strength, true) => "weakest first", + (SortBy::Newest, false) => "newest first", + (SortBy::Newest, true) => "oldest first", + } + } +} + +/// What something would fetch, or cost, as a row shows it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Price { + /// What the armourer you're at asks or pays, and whether you can pay. + Here(u32, bool), + /// About what a buyer elsewhere would pay. + About(u32), + /// A scholar has yet to tell you what it is. + Unknown, + /// No one buys it. + Nothing, +} + +impl Price { + fn amount(self) -> Option<u32> { + match self { + Price::Here(n, _) | Price::About(n) => Some(n), + Price::Unknown | Price::Nothing => None, + } + } +} + +/// What a scene shows the inventory, each time it asks it anything. +#[derive(Clone, Copy)] +pub struct View<'a> { + pub gear: &'a Gear, + /// The pile beside you, if there's one, and what's in it. + pub beside: Option<(Beside, &'a [Stack])>, + /// How you know monsters take each kind of damage. + pub known: &'a BTreeSet<(&'static str, Dmg)>, + /// Whether you can be hurt here, and so use a medpatch: only in a wreck. + pub hurt: bool, + /// The shop you're trading with, if you're at an armourer's. + pub shop: Option<Counter<'a>>, + /// How the lists are sorted. + pub sort: Sort, +} + +/// What the inventory asks the scene to do. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Command { + /// Ready the weapon at this index in your pack. + Ready(usize), + /// Put on the armour at this index in your pack. + Wear(usize), + TakeOff(Slot), + /// Eat one of the food at this index in your pack. + Eat(usize), + UseMedpatch, + /// Put down this many of what's at a spot on you, where you stand. + Drop(Spot, u32), + /// Put this many of what's at a spot on you in the lockers. + Stow(Spot, u32), + /// Take this many of the `n`th stack in the pile beside you. + Fetch(usize, u32), + /// Buy the `n`th thing on the rack. + Buy(usize), + /// Sell what's at a spot on you. + Sell(Spot), + /// Sell the `n`th stack in your ship's lockers. + SellAboard(usize), + /// Have the armour at a spot on you mended. + Mend(Spot), + /// Have the armour that's the `n`th stack in your ship's lockers mended. + MendAboard(usize), + /// Have all your armour mended, on you and aboard. + MendAll, +} + +/// How the inventory answers a key. +#[derive(Debug, PartialEq, Eq)] +pub enum Reply { + Stay, + Close, + Do(Command), + /// Sort the lists this way from now on. Takes no time. + Sort(Sort), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Row { + Heading(&'static str), + /// Something on you, or a slot to wear something in with nothing in it. + You(Spot), + /// The `n`th stack in the pile beside you. + Beside(usize), + /// The `n`th stack in your ship's lockers, when you're selling. + Aboard(usize), +} + +/// One thing you can do with what's picked out. +struct Act { + key: char, + what: String, + command: Command, +} + +pub struct Inventory { + /// Whether the cursor is in the pile beside you, rather than on you. + on_beside: bool, + /// Where the cursor is in each list, counting only rows you can pick. + cursor: [usize; 2], + /// A count typed before a key. + count: Option<u32>, + /// `g` pressed once, waiting for a second. + g: bool, + /// Typing what to find, after `/`. + finding: bool, + filter: String, + /// Picking from what you can do with what's picked out, on `enter`. + menu: Option<usize>, + /// What happened last. + pub note: Option<String>, + /// How many rows the lists showed when last drawn, for paging. + page: Cell<usize>, +} + +impl Default for Inventory { + fn default() -> Inventory { + Inventory::new() + } +} + +impl Inventory { + pub fn new() -> Inventory { + Inventory { + on_beside: false, + cursor: [0, 0], + count: None, + g: false, + finding: false, + filter: String::new(), + menu: None, + note: None, + page: Cell::new(10), + } + } + + /// Opens with the cursor on the pile beside you: to pick things up. + pub fn beside() -> Inventory { + Inventory { on_beside: true, ..Inventory::new() } + } + + fn matches(&self, item: &Item) -> bool { + self.filter.is_empty() + || item.label().to_lowercase().contains(&self.filter.to_lowercase()) + || item.category().heading().to_lowercase().contains(&self.filter.to_lowercase()) + } + + /// The rows of one list, headings and all. + fn rows(&self, view: &View, beside: bool) -> Vec<Row> { + let mut rows = Vec::new(); + let pile: Vec<(Row, &Stack)> = if beside { + let Some((_, stacks)) = view.beside else { return rows }; + stacks.iter().enumerate().map(|(i, s)| (Row::Beside(i), s)).collect() + } else { + let g = view.gear; + let mut worn = Vec::new(); + for slot in Slot::ALL { + match g.worn.get(&slot) { + Some(a) if self.matches(&Item::Armor(a.clone())) => { + worn.push(Row::You(Spot::Worn(slot))); + } + None if self.filter.is_empty() => worn.push(Row::You(Spot::Worn(slot))), + _ => {} + } + } + if !worn.is_empty() { + rows.push(Row::Heading("WORN")); + rows.extend(worn); + } + let ready: Vec<Row> = [(Spot::Melee, &g.melee), (Spot::Gun, &g.gun)] + .into_iter() + .filter(|(_, w)| self.matches(&Item::Weapon((*w).clone()))) + .map(|(spot, _)| Row::You(spot)) + .collect(); + if !ready.is_empty() { + rows.push(Row::Heading("READY")); + rows.extend(ready); + } + g.pack.iter().enumerate().map(|(i, s)| (Row::You(Spot::Pack(i)), s)).collect() + }; + for category in Category::ALL { + let mut these: Vec<&(Row, &Stack)> = pile + .iter() + .filter(|(_, s)| s.item.category() == category && self.matches(&s.item)) + .collect(); + if these.is_empty() { + continue; + } + these.sort_by(|a, b| order(view, a, b)); + rows.push(Row::Heading(category.heading())); + rows.extend(these.into_iter().map(|(row, _)| *row)); + } + if !beside && let Some(shop) = &view.shop { + let mut aboard: Vec<(Row, &Stack)> = shop + .aboard + .iter() + .enumerate() + .filter(|(_, s)| shop.terms.offer(&s.item).is_some() && self.matches(&s.item)) + .map(|(i, s)| (Row::Aboard(i), s)) + .collect(); + aboard.sort_by(|a, b| order(view, a, b)); + let aboard: Vec<Row> = aboard.into_iter().map(|(row, _)| row).collect(); + if !aboard.is_empty() { + rows.push(Row::Heading("IN YOUR SHIP")); + rows.extend(aboard); + } + } + rows + } + + /// The rows you can pick in one list. + fn picks(&self, view: &View, beside: bool) -> Vec<Row> { + self.rows(view, beside).into_iter().filter(|r| !matches!(r, Row::Heading(_))).collect() + } + + fn side(&self, view: &View) -> bool { + self.on_beside && view.beside.is_some() + } + + /// The row picked out, if there's anything to pick. + fn picked(&self, view: &View) -> Option<Row> { + let side = self.side(view); + let picks = self.picks(view, side); + let i = self.cursor[usize::from(side)].min(picks.len().saturating_sub(1)); + picks.get(i).copied() + } + + fn stack(view: &View, row: Row) -> Option<Stack> { + match row { + Row::Heading(_) => None, + Row::You(spot) => view.gear.get(spot), + Row::Beside(i) => view.beside.and_then(|(_, s)| s.get(i)).cloned(), + Row::Aboard(i) => view.shop.as_ref().and_then(|s| s.aboard.get(i)).cloned(), + } + } + + /// Everything you can do with what's picked out, with `n` as the count. + fn acts(view: &View, row: Row, n: u32) -> Vec<Act> { + let mut acts = Vec::new(); + let Some(stack) = Self::stack(view, row) else { return acts }; + let mut act = |key, what: String, command| acts.push(Act { key, what, command }); + let some = n.clamp(1, stack.count); + let how_many = |verb: &str| match (stack.count, some) { + (1, _) => format!("{verb} it"), + (_, 1) => format!("{verb} one"), + (_, n) => format!("{verb} {n}"), + }; + let offer = view.shop.as_ref().and_then(|s| s.terms.offer(&stack.item)); + match row { + Row::Heading(_) => {} + Row::You(spot) => { + match (spot, &stack.item) { + (Spot::Pack(i), Item::Weapon(_)) => { + act('w', "ready it".into(), Command::Ready(i)) + } + (Spot::Pack(i), Item::Armor(_)) => act('W', "wear it".into(), Command::Wear(i)), + (Spot::Worn(slot), _) => { + act('T', "take it off".into(), Command::TakeOff(slot)); + } + (Spot::Pack(i), Item::Food(_)) => act('E', "eat one".into(), Command::Eat(i)), + (Spot::Pack(_), Item::Medpatch) if view.hurt => { + act('a', "use one".into(), Command::UseMedpatch); + } + _ => {} + } + // A ready weapon goes only if there's another to take its place. + let spared = match spot { + Spot::Melee => view.gear.spare(false).is_some(), + Spot::Gun => view.gear.spare(true).is_some(), + _ => true, + }; + if let Some(price) = offer.filter(|_| spared) { + act('s', format!("sell it for {price} ¤"), Command::Sell(spot)); + } + if let Some(cost) = mend_cost(view, &stack.item) { + act('r', format!("mend it for {cost} ¤"), Command::Mend(spot)); + } + if spared { + act('d', how_many("drop"), Command::Drop(spot, some)); + if stack.count > 1 { + act('D', "drop them all".into(), Command::Drop(spot, stack.count)); + } + if let Some((Beside::Lockers, _)) = view.beside { + act('m', how_many("stow"), Command::Stow(spot, some)); + if stack.count > 1 { + act('M', "stow them all".into(), Command::Stow(spot, stack.count)); + } + } + } + } + Row::Beside(i) => match (view.beside, &view.shop) { + (Some((Beside::Rack, _)), Some(shop)) => { + if let Some(price) = shop.terms.asking(&stack.item) { + act('b', format!("buy it for {price} ¤"), Command::Buy(i)); + } + } + _ => { + act('m', how_many("take"), Command::Fetch(i, some)); + if stack.count > 1 { + act('M', "take them all".into(), Command::Fetch(i, stack.count)); + } + } + }, + Row::Aboard(i) => { + if let Some(price) = offer { + act('s', format!("sell it for {price} ¤"), Command::SellAboard(i)); + } + if let Some(cost) = mend_cost(view, &stack.item) { + act('r', format!("mend it for {cost} ¤"), Command::MendAboard(i)); + } + } + } + if !matches!(row, Row::Beside(_)) + && let Some((n, cost)) = mend_all_cost(view) + && n > 1 + && matches!(stack.item, Item::Armor(_)) + { + act('R', format!("mend all {n} for {cost} ¤"), Command::MendAll); + } + acts + } + + fn move_by(&mut self, view: &View, by: isize) { + let side = self.side(view); + let last = self.picks(view, side).len().saturating_sub(1); + let cursor = &mut self.cursor[usize::from(side)]; + *cursor = (*cursor).min(last).saturating_add_signed(by).min(last); + } + + pub fn key(&mut self, key: KeyEvent, view: &View) -> Reply { + if self.finding { + match key.code { + KeyCode::Enter => self.finding = false, + KeyCode::Esc => { + self.finding = false; + self.filter.clear(); + } + KeyCode::Backspace if self.filter.is_empty() => self.finding = false, + KeyCode::Backspace => { + self.filter.pop(); + } + KeyCode::Char(c) => self.filter.push(c), + _ => {} + } + self.cursor = [0, 0]; + return Reply::Stay; + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if let Some(choice) = self.menu { + let Some(row) = self.picked(view) else { + self.menu = None; + return Reply::Stay; + }; + let acts = Self::acts(view, row, self.count.unwrap_or(1)); + let last = acts.len().saturating_sub(1); + let chosen = match key.code { + KeyCode::Down | KeyCode::Char('j') => { + self.menu = Some((choice + 1).min(last)); + None + } + KeyCode::Up | KeyCode::Char('k') => { + self.menu = Some(choice.saturating_sub(1)); + None + } + KeyCode::Enter | KeyCode::Char('l') => acts.get(choice), + KeyCode::Esc | KeyCode::Char('q' | 'h') => { + self.menu = None; + None + } + KeyCode::Char(c) => acts.iter().find(|a| a.key == c), + _ => None, + }; + return match chosen { + Some(act) => { + self.menu = None; + self.count = None; + Reply::Do(act.command) + } + None => Reply::Stay, + }; + } + if let KeyCode::Char(c @ '0'..='9') = key.code + && !ctrl + && (c != '0' || self.count.is_some()) + { + let digit = c.to_digit(10).unwrap_or(0); + self.count = Some((self.count.unwrap_or(0) * 10 + digit).min(9999)); + return Reply::Stay; + } + let count = self.count.take(); + let n = count.unwrap_or(1); + let twice = std::mem::take(&mut self.g); + let half = (self.page.get() / 2).max(1) as isize; + let side = self.side(view); + match key.code { + KeyCode::Char('d') if ctrl => self.move_by(view, half), + KeyCode::Char('u') if ctrl => self.move_by(view, -half), + KeyCode::Char('f') if ctrl => self.move_by(view, half * 2), + KeyCode::Char('b') if ctrl => self.move_by(view, -half * 2), + KeyCode::PageDown => self.move_by(view, half * 2), + KeyCode::PageUp => self.move_by(view, -half * 2), + KeyCode::Down | KeyCode::Char('j') => self.move_by(view, n as isize), + KeyCode::Up | KeyCode::Char('k') => self.move_by(view, -(n as isize)), + KeyCode::Home => self.cursor[usize::from(side)] = 0, + KeyCode::Char('g') if twice => self.cursor[usize::from(side)] = 0, + KeyCode::Char('g') => self.g = true, + KeyCode::End | KeyCode::Char('G') => { + let last = self.picks(view, side).len().saturating_sub(1); + let to = count.map_or(last, |n| (n as usize).saturating_sub(1).min(last)); + self.cursor[usize::from(side)] = to; + } + KeyCode::Left | KeyCode::Char('h') => self.on_beside = false, + KeyCode::Right | KeyCode::Char('l') => self.on_beside = view.beside.is_some(), + KeyCode::Tab | KeyCode::BackTab => self.on_beside = !side && view.beside.is_some(), + KeyCode::Char('/') => { + self.finding = true; + self.filter.clear(); + self.cursor = [0, 0]; + } + KeyCode::Esc if !self.filter.is_empty() => { + self.filter.clear(); + self.cursor = [0, 0]; + } + KeyCode::Esc | KeyCode::Char('q' | 'i') => return Reply::Close, + KeyCode::Char('o') => { + return self.sort(view, Sort { by: view.sort.by.next(), reversed: false }); + } + KeyCode::Char('O') => { + return self.sort(view, Sort { reversed: !view.sort.reversed, ..view.sort }); + } + KeyCode::Enter => { + if let Some(row) = self.picked(view) + && !Self::acts(view, row, n).is_empty() + { + self.menu = Some(0); + self.count = count; + } + } + KeyCode::Char(c) => { + if let Some(row) = self.picked(view) + && let Some(act) = Self::acts(view, row, n).into_iter().find(|a| a.key == c) + { + return Reply::Do(act.command); + } + // Wherever the cursor is, at an armourer's. + if c == 'R' && view.shop.is_some() { + return Reply::Do(Command::MendAll); + } + } + _ => {} + } + Reply::Stay + } + + /// Sorts the lists another way, keeping the cursor on what it was on. + fn sort(&mut self, view: &View, sort: Sort) -> Reply { + let sorted = View { sort, ..*view }; + for side in [false, true] { + let picks = self.picks(view, side); + let at = self.cursor[usize::from(side)].min(picks.len().saturating_sub(1)); + if let Some(row) = picks.get(at) { + let now = self.picks(&sorted, side).iter().position(|r| r == row); + self.cursor[usize::from(side)] = now.unwrap_or(0); + } + } + Reply::Sort(sort) + } + + /// Draws the inventory over the whole of `area`. + pub fn render(&self, frame: &mut Frame, area: Rect, view: &View) { + frame.render_widget(Clear, area); + let [body, foot] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(area); + let wide = body.width >= WIDE; + // Below the lists, on a short screen the details give up room first. + let details_height = (body.height * 2 / 5).clamp(6, DETAILS_HEIGHT); + let (you, beside, details) = match view.beside { + Some(_) if wide => { + let [a, b, c] = Layout::horizontal([Constraint::Fill(1); 3]).areas(body); + (a, Some(b), c) + } + Some(_) => { + let [lists, details] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(details_height)]) + .areas(body); + let [a, b] = Layout::horizontal([Constraint::Fill(1); 2]).areas(lists); + (a, Some(b), details) + } + None if body.width >= 72 => { + let [a, c] = + Layout::horizontal([Constraint::Fill(3), Constraint::Fill(2)]).areas(body); + (a, None, c) + } + None => { + let [a, c] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(details_height)]) + .areas(body); + (a, None, c) + } + }; + self.draw_list(frame, you, view, false); + if let Some(beside) = beside { + self.draw_list(frame, beside, view, true); + } + self.draw_details(frame, details, view); + self.draw_foot(frame, foot, view.shop.is_some()); + } + + fn draw_list(&self, frame: &mut Frame, area: Rect, view: &View, beside: bool) { + let focused = self.side(view) == beside; + let title = if beside { + match view.beside { + Some((Beside::Lockers, stacks)) => format!( + " LOCKERS · {} / {} kg ", + items::kg(items::pile_mass(stacks)), + items::kg(LOCKER_CAPACITY) + ), + Some((Beside::Floor(here), _)) => format!(" {} ", here.to_uppercase()), + Some((Beside::Rack, _)) => { + format!(" FOR SALE · you have {} ¤ ", view.shop.as_ref().map_or(0, |s| s.scrip)) + } + None => String::new(), + } + } else { + let g = view.gear; + format!( + " YOU · {} / {} kg · {} ", + items::kg(g.carried()), + items::kg(g.capacity), + view.sort.says() + ) + }; + let colour = if focused { ACCENT } else { DIM }; + let block = Block::bordered() + .border_style(Style::new().fg(colour)) + .title(Line::from(title).fg(colour).bold()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = self.rows(view, beside); + let picked = self.picked(view); + let height = usize::from(inner.height); + if focused { + self.page.set(height); + } + let at = rows.iter().position(|r| Some(*r) == picked && focused).unwrap_or(0); + // Keep the picked row in view, with its heading if there's room. + let first = + at.saturating_sub(height.saturating_sub(1) / 2).min(rows.len().saturating_sub(height)); + let width = usize::from(inner.width); + // Which column is which, over the first heading in sight. + let mut labelled = false; + let mut lines: Vec<Line> = rows + .iter() + .skip(first) + .take(height) + .map(|&row| { + let line = match row { + Row::Heading(text) => { + heading(text, width, !std::mem::replace(&mut labelled, true)) + } + _ => self.row_line(view, row, width), + }; + if Some(row) == picked && focused { + line.patch_style(Style::new().add_modifier(Modifier::REVERSED)) + } else { + line + } + }) + .collect(); + if rows.is_empty() { + let rack = matches!(view.beside, Some((Beside::Rack, _))); + let empty = match (beside, self.filter.is_empty()) { + (_, false) => "Nothing like that.", + (true, true) if rack => "The rack's bare.", + (true, true) => "Nothing here.", + (false, true) => "Nothing on you.", + }; + lines.push(Line::from(format!(" {empty}")).fg(DIM)); + } + frame.render_widget(Paragraph::new(lines), inner); + } + + fn row_line(&self, view: &View, row: Row, width: usize) -> Line<'static> { + let weighed = width >= NARROW; + let label = match row { + Row::Heading(text) => return heading(text, width, true), + Row::You(Spot::Worn(slot)) => format!("{:<6}", slot.name()), + Row::You(Spot::Melee) => "melee ".into(), + Row::You(Spot::Gun) => "gun ".into(), + _ => String::new(), + }; + let Some(stack) = Self::stack(view, row) else { + return Line::from(vec![ + Span::raw(format!("{INDENT} {label}")).fg(DIM), + Span::raw("—").fg(DIM), + ]); + }; + let (glyph, colour) = look(&stack.item); + let mut name = stack.item.label(); + if stack.count > 1 { + name = format!("{name} ×{}", stack.count); + } + if let Item::Weapon(w) = &stack.item + && let Some(g) = w.gun + { + name = format!("{name} {}/{}", w.loaded, g.magazine); + } + if let Item::Armor(a) = &stack.item + && a.durability < a.max_durability + { + name = format!("{name} {}/{}", a.durability, a.max_durability); + } + let (price, price_colour) = match price(view, row, &stack) { + Price::Here(n, true) => (n.to_string(), ACCENT), + Price::Here(n, false) => (n.to_string(), DIM), + Price::About(n) => (format!("~{n}"), GUESS), + Price::Unknown => ("?".into(), DIM), + Price::Nothing => (String::new(), DIM), + }; + let kg = items::kg(stack.mass()); + let right = columns(width, &kg, &price); + let used = INDENT.len() + 2 + label.chars().count() + 1 + right.chars().count(); + let room = width.saturating_sub(used); + let shown: String = name.chars().take(room).collect(); + let pad = room.saturating_sub(shown.chars().count()); + let named = match &stack.item { + Item::Weapon(w) => w.named, + Item::Armor(a) => a.named, + _ => false, + }; + let name_style = Style::new().fg(rgb(colour)); + let mut spans = vec![ + Span::raw(INDENT), + Span::raw(glyph.to_string()).fg(rgb(colour)), + Span::raw(" "), + Span::raw(label).fg(DIM), + Span::styled(shown, if named { name_style.bold() } else { name_style }), + Span::raw(format!("{:pad$} ", "")), + ]; + if weighed { + spans.push(Span::raw(format!("{kg:>5} ")).fg(DIM)); + } + spans.push(Span::raw(format!("{price:>5} ")).fg(price_colour)); + Line::from(spans) + } + + fn draw_details(&self, frame: &mut Frame, area: Rect, view: &View) { + let picked = self.picked(view); + let stack = picked.and_then(|row| Self::stack(view, row)); + let title = stack.as_ref().map_or(String::new(), |s| format!(" {} ", s.item.label())); + let block = Block::bordered() + .border_style(Style::new().fg(DIM)) + .title(Line::from(title).fg(ACCENT).bold()) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + let Some(row) = picked else { return }; + let mut lines = about(view, row, stack.as_ref()); + let acts = Self::acts(view, row, self.count.unwrap_or(1)); + if !acts.is_empty() { + lines.push(Line::default()); + match self.menu { + Some(choice) => { + for (i, act) in acts.iter().enumerate() { + let line = Line::from(vec![ + Span::raw(format!(" {} ", act.key)).fg(ACCENT).bold(), + Span::raw(format!(" {} ", act.what)), + ]); + let chosen = i == choice.min(acts.len() - 1); + lines.push(if chosen { + line.patch_style(Style::new().add_modifier(Modifier::REVERSED)) + } else { + line + }); + } + } + None => { + let mut spans = Vec::new(); + for act in &acts { + spans.push(Span::raw(act.key.to_string()).fg(ACCENT).bold()); + spans.push(Span::raw(format!(" {} ", act.what)).fg(DIM)); + } + lines.push(Line::from(spans)); + } + } + } + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } + + fn draw_foot(&self, frame: &mut Frame, area: Rect, shop: bool) { + let keys: Vec<Span> = if self.finding { + let mut spans = vec![ + Span::raw(format!("/{}", self.filter)).fg(ACCENT), + Span::raw("█ ").fg(ACCENT), + ]; + spans.extend(keys::line(&[("enter", "keep"), ("esc", "clear")]).spans); + spans + } else if self.menu.is_some() { + keys::line(&[("j/k", "choose"), ("enter", "do it"), ("esc", "back")]).spans + } else { + let mut hints = vec![ + ("j/k", "move"), + ("gg/G", "top/end"), + ("^d/^u", "page"), + ("h/l", "lists"), + ("/", "find"), + ("o/O", "sort"), + ("enter", "act"), + ]; + if shop { + hints.push(("b/s", "buy/sell")); + hints.push(("r/R", "mend")); + } + hints.push(("esc", if shop { "step away" } else { "close" })); + keys::line(&hints).spans + }; + // What you're finding, a count you've typed and what just happened, + // above the keys. + let mut top = Vec::new(); + if !self.filter.is_empty() && !self.finding { + top.push(Span::raw(format!(" /{}", self.filter)).fg(ACCENT)); + } + if let Some(n) = self.count { + top.push(Span::raw(format!(" {n}…")).fg(ACCENT).bold()); + } + if let Some(note) = &self.note { + top.push(Span::raw(format!(" {note}")).fg(ACCENT)); + } + let lines = vec![Line::from(top), Line::from(keys)]; + frame.render_widget(Paragraph::new(lines), area); + } +} + +/// What's on the right of a row: what it weighs, in kg, and what it would +/// fetch; too narrow for both, only what it'd fetch. +fn columns(width: usize, kg: &str, price: &str) -> String { + if width >= NARROW { format!("{kg:>5} {price:>5} ") } else { format!("{price:>5} ") } +} + +/// A heading over a kind of thing: "WEAPONS", with which column is which +/// on the right if `units`. +fn heading(text: &str, width: usize, units: bool) -> Line<'static> { + let mut spans = vec![Span::raw(format!(" {text}")).fg(HEADING).bold()]; + if units { + let units = columns(width, "kg", "¤"); + let pad = width.saturating_sub(1 + text.chars().count() + units.chars().count()); + spans.push(Span::raw(format!("{:pad$}{units}", "")).fg(DIM)); + } + Line::from(spans) +} + +/// A description as a sentence: "Dense and faintly sweet." +fn sentence(text: &str) -> String { + capitalise(&format!("{}.", text.trim_end_matches('.'))) +} + +/// All about what's picked out. +fn about(view: &View, row: Row, stack: Option<&Stack>) -> Vec<Line<'static>> { + let dim = |text: String| Line::from(text).fg(DIM); + let Some(stack) = stack else { + return match row { + Row::You(Spot::Worn(slot)) => vec![dim(format!("Nothing on your {}.", slot.name()))], + _ => Vec::new(), + }; + }; + let mut lines = vec![Line::from(capitalise(&stack.name())).fg(rgb(look(&stack.item).1)).bold()]; + match &stack.item { + Item::Weapon(w) => weapon_lines(view, w, row, &mut lines), + Item::Armor(a) => armour_lines(view, a, row, &mut lines), + Item::Food(f) => { + lines.push(Line::from(sentence(&f.description)).italic().fg(DIM)); + lines.push(dim("Nothing gets hungry yet.".into())); + } + Item::Medpatch => { + lines.push(Line::from(format!( + "Stings, then numbs: heals {MEDPATCH_HEAL} and stops bleeding." + ))); + lines.push(dim("Aboard a wreck, m uses one (or a, here).".into())); + } + Item::Artifact(a) if a.identified => { + lines.push(Line::from(format!("A scholar has told you what it is: {}.", a.true_name))); + let value = trade::artifact_value(a); + lines.push(dim(format!("A scholar would give you about {value} ¤ for it."))); + } + Item::Artifact(_) => { + lines.push(Line::from("Something strange, from a wreck.")); + lines.push(dim("A scholar can tell you what it is, and buy it.".into())); + } + Item::Salvage(s) => { + lines.push(Line::from(format!("Worth about {} ¤ to a broker or a station.", s.value))); + } + } + if let Some(shop) = &view.shop { + match row { + Row::Beside(_) => { + if let Some(asking) = shop.terms.asking(&stack.item) { + let have = shop.scrip; + lines.push(if asking <= have { + Line::from(format!("{asking} ¤. You have {have} ¤.")) + } else { + Line::from(format!("{asking} ¤: {} ¤ more than you have.", asking - have)) + .yellow() + }); + } + } + _ => { + if let Some(offer) = shop.terms.offer(&stack.item) { + lines.push(Line::from(format!("They'd give you {offer} ¤ for it."))); + let alone = match row { + Row::You(Spot::Melee) => view.gear.spare(false).is_none(), + Row::You(Spot::Gun) => view.gear.spare(true).is_none(), + _ => false, + }; + if alone { + lines.push(dim("Have another of its sort to ready first.".into())); + } + } + } + } + } + if let Row::Aboard(_) = row { + lines.push(dim("In your ship's lockers.".into())); + } + let each = items::kg(stack.item.mass()); + let mut weighs = if stack.count > 1 { + format!("{each} kg each, {} kg in all.", items::kg(stack.mass())) + } else { + format!("{each} kg.") + }; + // Salvage and artifacts say what they're worth already. + if view.shop.is_none() + && matches!(stack.item, Item::Weapon(_) | Item::Armor(_)) + && let Some(offer) = trade::typical_offer(&stack.item) + { + weighs.push_str(&format!(" An armourer would give you about {offer} ¤ for it.")); + } + lines.push(dim(weighs)); + lines +} + +/// What a row's stack would fetch, or cost: at an armourer's, their price +/// for what they deal in; anywhere else, or for anything else, about what a +/// buyer would give. +fn price(view: &View, row: Row, stack: &Stack) -> Price { + if let Some(shop) = &view.shop { + let here = match row { + Row::Beside(_) => { + shop.terms.asking(&stack.item).map(|n| Price::Here(n, n <= shop.scrip)) + } + _ => shop.terms.offer(&stack.item).map(|n| Price::Here(n, true)), + }; + if let Some(here) = here { + return here; + } + } + match (&stack.item, trade::typical_offer(&stack.item)) { + (_, Some(n)) => Price::About(n.saturating_mul(stack.count)), + (Item::Artifact(_), None) => Price::Unknown, + _ => Price::Nothing, + } +} + +/// Which of two rows of a pile comes first, within their kind, by the +/// view's sort. Things the sort says nothing about (no price, no +/// durability) go last whichever way round; ties go by name. +fn order(view: &View, a: &(Row, &Stack), b: &(Row, &Stack)) -> std::cmp::Ordering { + let name = |(_, s): &(Row, &Stack)| s.item.label().to_lowercase(); + let by_name = name(a).cmp(&name(b)); + let sort = view.sort; + if sort.by == SortBy::Name { + return if sort.reversed { by_name.reverse() } else { by_name }; + } + let key = |&(row, s): &(Row, &Stack)| -> Option<f64> { + let grams = f64::from(s.mass()); + match sort.by { + SortBy::Name => None, + SortBy::Weight => Some(grams), + SortBy::Price => price(view, row, s).amount().map(f64::from), + SortBy::Density => { + let n = price(view, row, s).amount()?; + (grams > 0.0).then(|| f64::from(n) / grams) + } + // Most worn first: least left, as a share of whole. + SortBy::Durability => match &s.item { + Item::Armor(a) if a.max_durability > 0 => { + Some(-f64::from(a.durability) / f64::from(a.max_durability)) + } + _ => None, + }, + SortBy::Strength => match &s.item { + Item::Weapon(w) => { + let pellets = w.gun.map_or(1, |g| g.pellets); + Some(f64::from(w.damage.0 + w.damage.1) / 2.0 * f64::from(pellets)) + } + Item::Armor(a) => { + Some(f64::from(Dmg::ALL.iter().map(|&d| a.guard(d)).sum::<i32>())) + } + _ => None, + }, + SortBy::Newest => match row { + Row::You(Spot::Pack(i)) | Row::Beside(i) | Row::Aboard(i) => Some(i as f64), + _ => None, + }, + } + }; + let first = match (key(a), key(b)) { + (Some(x), Some(y)) => { + // Highest first, unless turned round. + let o = y.total_cmp(&x); + if sort.reversed { o.reverse() } else { o } + } + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }; + first.then(by_name) +} + +/// What the armourer you're at wants to mend `item`, if it's armour that +/// needs it. +fn mend_cost(view: &View, item: &Item) -> Option<u32> { + let shop = view.shop.as_ref()?; + let Item::Armor(a) = item else { return None }; + Some(shop.terms.mending(a)).filter(|&cost| cost > 0) +} + +/// How many pieces of yours want mending, on you and aboard, and what the +/// armourer you're at wants for all of them. +fn mend_all_cost(view: &View) -> Option<(usize, u32)> { + let shop = view.shop.as_ref()?; + let g = view.gear; + let packed = g.pack.iter().chain(shop.aboard).filter_map(|s| match &s.item { + Item::Armor(a) => Some(a), + _ => None, + }); + let costs: Vec<u32> = + g.worn.values().chain(packed).map(|a| shop.terms.mending(a)).filter(|&c| c > 0).collect(); + (!costs.is_empty()).then(|| (costs.len(), costs.iter().sum())) +} + +fn armour_lines(view: &View, a: &Armor, row: Row, lines: &mut Vec<Line<'static>>) { + let what = capitalise(&a.description); + let what = match a.condition { + Condition::Worn => Some(format!("{what}, worn: it won't last as long.")), + Condition::Sound if a.named => Some(format!("{what}.")), + Condition::Sound => None, + Condition::Fine => Some(format!("{what}, finely made.")), + Condition::Relic => Some(format!("{what}: a relic, and stronger for it.")), + }; + lines.extend(what.map(|what| Line::from(what).italic().fg(DIM))); + let on = match row { + Row::You(Spot::Worn(_)) => "You wear it", + _ => "Worn", + }; + lines.push(Line::from(format!("{on} on your {}. Guards {}.", a.slot.name(), a.guards_text()))); + let mut durability = capitalise(&a.durability_text()); + if a.ruined() { + durability.push_str(": worn out, it guards nothing until it's mended."); + } else if a.thin() { + durability.push_str(": below half, it guards 1 less."); + } else { + durability.push('.'); + } + let line = match mend_cost(view, &Item::Armor(a.clone())) { + Some(cost) if !matches!(row, Row::Beside(_)) => Line::from(vec![ + Span::raw(durability), + Span::raw(format!(" Mending it whole: {cost} ¤.")).fg(ACCENT), + ]), + _ => Line::from(durability), + }; + lines.push(match () { + _ if a.ruined() => line.red(), + _ if a.thin() => line.yellow(), + _ => line, + }); + if !matches!(row, Row::You(Spot::Worn(_))) { + lines.push(versus_armour(a, view.gear.worn.get(&a.slot))); + } + if view.shop.is_none() { + lines.push(dim_line("Each blow it softens wears it 1. An armourer mends it.")); + } +} + +fn dim_line(text: &str) -> Line<'static> { + Line::from(text.to_string()).fg(DIM) +} + +/// How `a` measures up against what you wear in its place, as it is now: +/// "Against your vac-helmet: +1 cut, -1 pierce." Better in green, worse in +/// yellow. +fn versus_armour(a: &Armor, worn: Option<&Armor>) -> Line<'static> { + let Some(worn) = worn else { + return dim_line(&format!("You wear nothing on your {} now.", a.slot.name())); + }; + let mut diffs: Vec<(i32, String)> = Vec::new(); + for dmg in Dmg::ALL { + let d = a.guard(dmg) - worn.guard(dmg); + if d != 0 { + diffs.push((d, format!("{d:+} {}", dmg.name()))); + } + } + match (a.stanching(), worn.stanching()) { + (true, false) => diffs.push((1, "stops bleeding".into())), + (false, true) => diffs.push((-1, "lets wounds bleed".into())), + _ => {} + } + let kg = |grams: u32| f64::from(grams) / 1000.0; + let heavier = kg(a.mass) - kg(worn.mass); + if heavier.abs() >= 0.1 { + diffs.push((if heavier < 0.0 { 1 } else { -1 }, format!("{heavier:+.1} kg"))); + } + let mut spans = vec![Span::raw(format!("Against {}: ", worn.yours())).fg(DIM)]; + if diffs.is_empty() { + spans.push(Span::raw("much the same.").fg(DIM)); + } + let last = diffs.len().saturating_sub(1); + for (i, (sign, text)) in diffs.into_iter().enumerate() { + let span = Span::raw(text); + spans.push(match sign { + s if s > 0 => span.green(), + s if s < 0 => span.yellow(), + _ => span, + }); + spans.push(Span::raw(if i == last { "." } else { ", " }).fg(DIM)); + } + Line::from(spans) +} + +fn weapon_lines(view: &View, w: &Weapon, row: Row, lines: &mut Vec<Line<'static>>) { + let what = match w.condition { + _ if w.kit => { + "Your own. Lose everything else aboard a wreck and you keep this.".to_string() + } + Condition::Worn => format!("{}, worn: it hits softer.", capitalise(&w.description)), + Condition::Sound => format!("{}.", capitalise(&w.description)), + Condition::Fine => format!("{}, finely made.", capitalise(&w.description)), + Condition::Relic => { + format!("{}: a relic, and stronger for it.", capitalise(&w.description)) + } + }; + lines.push(Line::from(what).italic().fg(DIM)); + match row { + Row::You(Spot::Melee) => lines.push(Line::from("Ready: you hit things with it.")), + Row::You(Spot::Gun) => lines.push(Line::from("Ready: you fire it.")), + _ => {} + } + lines.extend(w.stats().into_iter().map(Line::from)); + if !matches!(row, Row::You(Spot::Melee | Spot::Gun)) { + let ready = if w.is_gun() { &view.gear.gun } else { &view.gear.melee }; + lines.push(versus(w, ready)); + } + lines.push(Line::from(w.dmg.about()).fg(DIM)); + let against = |pick: fn(Affinity) -> bool| { + let names: Vec<&str> = MONSTERS + .iter() + .filter(|k| view.known.contains(&(k.name, w.dmg)) && pick(k.affinity(w.dmg))) + .map(|k| k.name) + .collect(); + names.join(", ") + }; + let good = against(|a| a == Affinity::Weak); + if !good.is_empty() { + lines.push(Line::from(vec!["good against ".green(), Span::from(good)])); + } + let poor = against(|a| matches!(a, Affinity::Resists | Affinity::Immune)); + if !poor.is_empty() { + lines.push(Line::from(vec!["poor against ".yellow(), Span::from(poor)])); + } +} + +/// How `w` measures up against the weapon you have ready in its place: +/// "Against your needler: +3 damage, +5 range." Better in green, worse in +/// yellow. +fn versus(w: &Weapon, ready: &Weapon) -> Line<'static> { + let damage = |w: &Weapon| { + let (lo, hi) = w.damage; + f64::from(lo + hi) / 2.0 * f64::from(w.gun.map_or(1, |g| g.pellets)) + }; + let pct = |p: f64| (p * 100.0).round() as i32; + let mut diffs: Vec<(f64, String)> = Vec::new(); + let d = damage(w) - damage(ready); + if d.abs() >= 0.25 { + let d = (d * 2.0).round() / 2.0; + let shown = if d.fract() == 0.0 { format!("{d:+.0}") } else { format!("{d:+.1}") }; + diffs.push((d, format!("{shown} damage"))); + } + match (w.gun, ready.gun) { + (Some(g), Some(r)) => { + if g.range != r.range { + diffs + .push((f64::from(g.range - r.range), format!("{:+} range", g.range - r.range))); + } + if g.magazine != r.magazine { + let n = i64::from(g.magazine) - i64::from(r.magazine); + let rounds = if n.abs() == 1 { "round" } else { "rounds" }; + diffs.push((n as f64, format!("{n:+} {rounds}"))); + } + } + _ => { + let hit = pct(w.accuracy) - pct(ready.accuracy); + if hit != 0 { + diffs.push((f64::from(hit), format!("{hit:+}% to hit"))); + } + } + } + let crit = pct(w.crit) - pct(ready.crit); + if crit != 0 { + diffs.push((f64::from(crit), format!("{crit:+}% crit"))); + } + if w.dmg != ready.dmg { + diffs.push((0.0, format!("{} not {}", w.dmg.name(), ready.dmg.name()))); + } + let mut spans = vec![Span::raw(format!("Against {}: ", ready.yours())).fg(DIM)]; + if diffs.is_empty() { + spans.push(Span::raw("much the same.").fg(DIM)); + } + let last = diffs.len().saturating_sub(1); + for (i, (sign, text)) in diffs.into_iter().enumerate() { + let span = Span::raw(text); + spans.push(match sign { + s if s > 0.0 => span.green(), + s if s < 0.0 => span.yellow(), + _ => span, + }); + spans.push(Span::raw(if i == last { "." } else { ", " }).fg(DIM)); + } + Line::from(spans) +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) +} + +/// Carries out a command that takes no time, outside a wreck: on what you +/// carry, the lockers if they're beside you, and the pile on the floor +/// where you stand. Says how it went. +pub fn carry_out( + command: Command, + gear: &mut Gear, + lockers: Option<&mut Vec<Stack>>, + floor: &mut Vec<Stack>, + beside: Option<Beside>, +) -> String { + let said = |result: Result<String, String>| result.unwrap_or_else(|e| e); + match command { + Command::Ready(i) => said(gear.ready(i)), + Command::Wear(i) => said(gear.wear(i)), + Command::TakeOff(slot) => said(gear.take_off(slot)), + Command::Eat(i) => said(gear.eat(i)), + Command::UseMedpatch => "You're not hurt.".into(), + Command::Drop(spot, n) => match gear.remove(spot, n) { + Ok(stack) => { + let said = format!("You put down {}.", stack.name()); + items::stow(floor, stack); + said + } + Err(said) => said, + }, + Command::Stow(spot, n) => { + let Some(lockers) = lockers else { return "There are no lockers here.".into() }; + let Some(stack) = gear.get(spot) else { return "There's nothing there.".into() }; + let n = n.min(stack.count); + let room = LOCKER_CAPACITY.saturating_sub(items::pile_mass(lockers)); + if stack.item.mass().saturating_mul(n) > room { + return "The lockers are full.".into(); + } + match gear.remove(spot, n) { + Ok(stack) => { + let said = format!("You stow {} in the lockers.", stack.name()); + items::stow(lockers, stack); + said + } + Err(said) => said, + } + } + Command::Fetch(i, n) => { + let (pile, from) = match (beside, lockers) { + (Some(Beside::Lockers), Some(lockers)) => (lockers, " from the lockers"), + _ => (floor, ""), + }; + let Some(stack) = pile.get(i) else { return "There's nothing there.".into() }; + let n = n.min(stack.count).min(gear.fits(&stack.item)); + if n == 0 { + let room = items::kg(gear.room()); + return format!("You've no room for {}: only {room} kg to spare.", stack.name()); + } + let stack = items::take(pile, i, n); + let said = format!("You take {}{from}.", stack.name()); + gear.add(stack); + said + } + Command::Buy(_) | Command::Sell(_) | Command::SellAboard(_) => { + "There's no one here to trade with.".into() + } + Command::Mend(_) | Command::MendAboard(_) | Command::MendAll => { + "There's no armourer here to mend it.".into() + } + } +} + +/// Takes the stacks lying at `at` out of a list of piles, to work on. +pub fn gather<K: PartialEq + Copy>(piles: &mut Vec<(K, Stack)>, at: K) -> Vec<Stack> { + let mut here = Vec::new(); + let mut i = 0; + while i < piles.len() { + if piles[i].0 == at { + here.push(piles.remove(i).1); + } else { + i += 1; + } + } + here +} + +/// Puts back what `gather` took, after. +pub fn scatter<K: Copy>(piles: &mut Vec<(K, Stack)>, at: K, here: Vec<Stack>) { + piles.extend(here.into_iter().map(|s| (at, s))); +} + +/// What's lying at `at` in a list of piles, for showing. +pub fn lying<K: PartialEq>(piles: &[(K, Stack)], at: K) -> Vec<Stack> { + piles.iter().filter(|(k, _)| *k == at).map(|(_, s)| s.clone()).collect() +} + +/// What to say when you step onto things: "There's a cleaver here (g)." +pub fn underfoot(here: &[Stack]) -> Option<String> { + match here { + [] => None, + [one] => Some(format!("There's {} here (g picks it up).", one.name())), + _ => Some(format!("There are {} things here (g to look).", here.len())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dungeon::{Pos, arms}; + use crate::names::Culture; + use crate::seed; + + fn press(inv: &mut Inventory, view: &View, code: KeyCode) -> Reply { + inv.key(KeyEvent::new(code, KeyModifiers::NONE), view) + } + + fn typed(inv: &mut Inventory, view: &View, keys: &str) -> Vec<Reply> { + keys.chars().map(|c| press(inv, view, KeyCode::Char(c))).collect() + } + + fn gear() -> Gear { + let mut gear = Gear::default(); + gear.add(Stack { item: Item::Medpatch, count: 5 }); + let mut rng = seed::rng(2); + gear.add(Stack::one(Item::Weapon(arms::find(&mut rng, arms::Source::Crew, false)))); + gear + } + + #[test] + fn vim_keys_move_and_counts_count() { + let gear = gear(); + let known = BTreeSet::new(); + let view = View { + gear: &gear, + beside: None, + known: &known, + hurt: false, + shop: None, + sort: Sort::default(), + }; + let mut inv = Inventory::new(); + // Worn ×4, ready ×2, then the pack: the torch is ready, so the pack + // has the found weapon and the medpatches. + assert_eq!(inv.picks(&view, false).len(), 8); + typed(&mut inv, &view, "G"); + assert_eq!(inv.picked(&view), Some(Row::You(Spot::Pack(0))), "medpatches come last"); + typed(&mut inv, &view, "gg"); + assert_eq!(inv.picked(&view), Some(Row::You(Spot::Worn(Slot::Head)))); + typed(&mut inv, &view, "5j"); + assert_eq!(inv.picked(&view), Some(Row::You(Spot::Gun))); + typed(&mut inv, &view, "G"); + let replies = typed(&mut inv, &view, "3d"); + assert_eq!(replies[1], Reply::Do(Command::Drop(Spot::Pack(0), 3))); + assert_eq!(typed(&mut inv, &view, "D")[0], Reply::Do(Command::Drop(Spot::Pack(0), 5))); + assert_eq!(typed(&mut inv, &view, "a")[0], Reply::Stay, "no use for one here"); + assert_eq!(press(&mut inv, &view, KeyCode::Esc), Reply::Close); + } + + #[test] + fn at_an_armourers_you_buy_off_the_rack_and_sell_what_you_can_spare() { + // The torch and rivet gun ready, a found weapon and medpatches packed. + let gear = gear(); + let found = gear.pack.iter().position(|s| matches!(s.item, Item::Weapon(_))).unwrap(); + let mut rng = seed::rng(4); + let rack: Vec<Stack> = (0..3) + .map(|_| Stack::one(Item::Weapon(arms::stocked(&mut rng, Culture::Cant, false)))) + .collect(); + let aboard = [Stack::one(Item::Medpatch), rack[0].clone()]; + let terms = trade::armoury_terms(&crate::space::system::generate(1), 0); + let known = BTreeSet::new(); + let view = View { + gear: &gear, + beside: Some((Beside::Rack, &rack)), + known: &known, + hurt: false, + shop: Some(Counter { terms, aboard: &aboard, scrip: 40 }), + sort: Sort::default(), + }; + let mut inv = Inventory::beside(); + let Some(Row::Beside(i)) = inv.picked(&view) else { panic!("starts on the rack") }; + assert_eq!(typed(&mut inv, &view, "b")[0], Reply::Do(Command::Buy(i))); + assert_eq!(typed(&mut inv, &view, "m")[0], Reply::Stay, "you can't just take it"); + press(&mut inv, &view, KeyCode::Char('h')); + // Only what they'd buy from your lockers, under its own heading. + let rows = inv.rows(&view, false); + let heading = rows.iter().position(|r| *r == Row::Heading("IN YOUR SHIP")).unwrap(); + assert_eq!(rows[heading + 1..], [Row::Aboard(1)]); + typed(&mut inv, &view, "G"); + assert_eq!(typed(&mut inv, &view, "s")[0], Reply::Do(Command::SellAboard(1))); + let acts = |row| Inventory::acts(&view, row, 1); + let sells = |row| acts(row).iter().any(|a| a.key == 's'); + assert!(sells(Row::You(Spot::Pack(found)))); + let Item::Weapon(w) = &gear.pack[found].item else { unreachable!() }; + let (spared, alone) = + if w.is_gun() { (Spot::Gun, Spot::Melee) } else { (Spot::Melee, Spot::Gun) }; + assert!(sells(Row::You(spared)), "ready, with a spare to take its place"); + assert!(!sells(Row::You(alone)), "not with nothing else to fight with"); + assert!(!sells(Row::You(Spot::Pack(1 - found))), "they don't buy medpatches"); + // What they'd pay here, beside what it weighs. + let item = &gear.pack[found].item; + let price = terms.offer(item).unwrap(); + let line = inv.row_line(&view, Row::You(Spot::Pack(found)), 40).to_string(); + let kg = items::kg(item.mass()); + assert!(line.ends_with(&format!("{kg:>5} {price:>5} ")), "{line}"); + assert!(line.starts_with(" ) "), "indented under its heading: {line}"); + } + + #[test] + fn rows_show_what_things_weigh_and_about_what_theyd_fetch() { + let mut gear = gear(); + let mut rng = seed::rng(3); + let scrap = items::Salvage { title: "hull plate".into(), value: 20, mass: 12_000 }; + gear.add(Stack { item: Item::Salvage(scrap), count: 3 }); + gear.add(Stack::one(Item::Artifact(crate::names::artifact(&mut rng)))); + let known = BTreeSet::new(); + let view = View { + gear: &gear, + beside: None, + known: &known, + hurt: false, + shop: None, + sort: Sort::default(), + }; + let inv = Inventory::new(); + let line = |row| inv.row_line(&view, row, 50).to_string(); + let at = |pick: fn(&Item) -> bool| { + Row::You(Spot::Pack(gear.pack.iter().position(|s| pick(&s.item)).unwrap())) + }; + // A whole stack's worth, and its weight. + assert!(line(at(|i| matches!(i, Item::Salvage(_)))).ends_with(" 36.0 ~60 ")); + assert!(line(at(|i| matches!(i, Item::Artifact(_)))).ends_with(" ? "), "not named yet"); + assert!(line(at(|i| *i == Item::Medpatch)).ends_with(" "), "no one buys them"); + let weapon = at(|i| matches!(i, Item::Weapon(_))); + let Row::You(Spot::Pack(w)) = weapon else { unreachable!() }; + let about = trade::typical_offer(&gear.pack[w].item).unwrap(); + assert!(line(weapon).ends_with(&format!("~{about} "))); + // The headings say which column is which. + let heading = inv.row_line(&view, Row::Heading("WEAPONS"), 50).to_string(); + assert!(heading.starts_with(" WEAPONS") && heading.ends_with(" kg ¤ "), "{heading}"); + // Too narrow for both, just the price. + assert!(inv.row_line(&view, weapon, 24).to_string().ends_with(&format!("~{about} "))); + assert!(!inv.row_line(&view, weapon, 24).to_string().contains(" kg")); + } + + #[test] + fn o_sorts_both_lists_within_each_kind_and_o_turns_it_round() { + let mut gear = Gear::default(); + let scrap = |title: &str, value, mass| { + Stack::one(Item::Salvage(items::Salvage { title: title.into(), value, mass })) + }; + gear.add(scrap("brass fitting", 30, 1000)); + gear.add(scrap("hull plate", 20, 12_000)); + gear.add(scrap("coil", 50, 4000)); + gear.add(Stack { item: Item::Medpatch, count: 2 }); + let floor = [scrap("valve", 5, 500), scrap("anchor", 90, 30_000)]; + let known = BTreeSet::new(); + let mut view = View { + gear: &gear, + beside: Some((Beside::Floor("on the floor"), &floor)), + known: &known, + hurt: false, + shop: None, + sort: Sort::default(), + }; + let mut inv = Inventory::new(); + let salvage = |inv: &Inventory, view: &View, beside: bool| -> Vec<String> { + inv.rows(view, beside) + .into_iter() + .filter_map(|r| Inventory::stack(view, r)) + .filter(|s| matches!(s.item, Item::Salvage(_))) + .map(|s| s.item.label()) + .collect() + }; + assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]); + // On the coil, then sorted heaviest first: the cursor stays on it. + inv.cursor[0] = + inv.picks(&view, false).iter().position(|r| *r == Row::You(Spot::Pack(2))).unwrap(); + let Reply::Sort(sort) = press(&mut inv, &view, KeyCode::Char('o')) else { panic!() }; + assert_eq!(sort, Sort { by: SortBy::Weight, reversed: false }); + view.sort = sort; + assert_eq!(salvage(&inv, &view, false), ["hull plate", "coil", "brass fitting"]); + assert_eq!(salvage(&inv, &view, true), ["anchor", "valve"], "the floor too"); + assert_eq!(inv.picked(&view), Some(Row::You(Spot::Pack(2)))); + let rows = inv.rows(&view, false); + let medical = rows.iter().position(|r| *r == Row::Heading("MEDICAL")).unwrap(); + let salvage_at = rows.iter().position(|r| *r == Row::Heading("SALVAGE")).unwrap(); + assert!(medical < salvage_at, "still grouped by kind"); + + let Reply::Sort(sort) = press(&mut inv, &view, KeyCode::Char('O')) else { panic!() }; + view.sort = sort; + assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]); + assert_eq!(sort.says(), "lightest first"); + + view.sort = Sort { by: SortBy::Density, reversed: false }; + assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]); + view.sort = Sort { by: SortBy::Newest, reversed: false }; + assert_eq!(salvage(&inv, &view, false), ["coil", "hull plate", "brass fitting"]); + // Round they go, back to names. + let mut by = SortBy::Name; + for _ in 0..SortBy::ALL.len() { + by = by.next(); + } + assert_eq!(by, SortBy::Name); + } + + #[test] + fn worn_armour_sorts_first_by_durability_and_the_rest_after() { + let mut rng = seed::rng(9); + let mut gear = Gear::default(); + for left in [10, 2, 6] { + let mut a = crate::dungeon::armour::stocked(&mut rng, Culture::Cant, false); + (a.title, a.max_durability, a.durability) = (format!("piece {left}"), 10, left); + gear.add(Stack::one(Item::Armor(a))); + } + let known = BTreeSet::new(); + let mut view = View { + gear: &gear, + beside: None, + known: &known, + hurt: false, + shop: None, + sort: Sort { by: SortBy::Durability, reversed: false }, + }; + let inv = Inventory::new(); + let armour = |view: &View| -> Vec<String> { + inv.rows(view, false) + .into_iter() + .filter_map(|r| match r { + Row::You(Spot::Pack(_)) => Inventory::stack(view, r), + _ => None, + }) + .map(|s| s.item.label()) + .collect() + }; + assert_eq!(armour(&view), ["piece 2", "piece 6", "piece 10"]); + view.sort.reversed = true; + assert_eq!(armour(&view), ["piece 10", "piece 6", "piece 2"]); + } + + #[test] + fn at_an_armourers_worn_armour_can_be_mended() { + let mut rng = seed::rng(6); + let mut piece = crate::dungeon::armour::stocked(&mut rng, Culture::Drift, false); + let mut gear = Gear::default(); + piece.durability = 1; + gear.worn.insert(piece.slot, piece.clone()); + let mut spare = piece.clone(); + spare.slot = piece.slot; + gear.add(Stack::one(Item::Armor(spare))); + let whole = { + let mut p = piece.clone(); + p.repair(); + p + }; + let aboard = [Stack::one(Item::Armor(whole))]; + let terms = trade::armoury_terms(&crate::space::system::generate(1), 0); + let known = BTreeSet::new(); + let rack = Vec::new(); + let view = View { + gear: &gear, + beside: Some((Beside::Rack, &rack)), + known: &known, + hurt: false, + shop: Some(Counter { terms, aboard: &aboard, scrip: 40 }), + sort: Sort::default(), + }; + let cost = terms.mending(&piece); + let acts = Inventory::acts(&view, Row::You(Spot::Worn(piece.slot)), 1); + let mend = acts.iter().find(|a| a.key == 'r').expect("an act to mend it"); + assert_eq!(mend.what, format!("mend it for {cost} ¤")); + let all = acts.iter().find(|a| a.key == 'R').expect("and to mend them all"); + assert_eq!(all.what, format!("mend all 2 for {} ¤", cost * 2)); + let whole_acts = Inventory::acts(&view, Row::Aboard(0), 1); + assert!(!whole_acts.iter().any(|a| a.key == 'r'), "nothing to mend in a whole one"); + // R mends everything from wherever the cursor is. + let mut inv = Inventory::beside(); + assert_eq!(typed(&mut inv, &view, "R")[0], Reply::Do(Command::MendAll)); + // The list shows how worn it is. + let line = inv.row_line(&view, Row::You(Spot::Worn(piece.slot)), 60).to_string(); + assert!(line.contains(&format!(" 1/{}", piece.max_durability)), "{line}"); + } + + #[test] + fn armour_says_how_it_measures_up_to_what_you_wear() { + let mut rng = seed::rng(6); + let mut worn = crate::dungeon::armour::stocked(&mut rng, Culture::Drift, false); + worn.title = "vac-helmet".into(); + worn.guards = [(Dmg::Pierce, 1), (Dmg::Blunt, 1)].into_iter().collect(); + worn.mass = 1800; + let mut other = worn.clone(); + other.guards = [(Dmg::Cut, 1), (Dmg::Blunt, 1)].into_iter().collect(); + other.mass = 1300; + assert_eq!( + versus_armour(&other, Some(&worn)).to_string(), + "Against your vac-helmet: +1 cut, -1 pierce, -0.5 kg." + ); + assert!(versus_armour(&other, None).to_string().starts_with("You wear nothing on your")); + } + + #[test] + fn a_weapon_says_how_it_measures_up_to_the_one_you_have_ready() { + let torch = arms::cutting_torch(); + let mut better = torch.clone(); + better.damage = (4, 7); + better.accuracy = 0.75; + assert_eq!( + versus(&better, &torch).to_string(), + "Against your cutting torch: +2 damage, -5% to hit." + ); + assert_eq!( + versus(&torch, &torch).to_string(), + "Against your cutting torch: much the same." + ); + let gun = arms::rivet_gun(); + let mut longer = gun.clone(); + longer.gun.as_mut().unwrap().range += 4; + longer.dmg = Dmg::Shock; + assert_eq!( + versus(&longer, &gun).to_string(), + "Against your rivet gun: +4 range, shock not pierce." + ); + } + + #[test] + fn finding_narrows_the_list() { + let gear = gear(); + let known = BTreeSet::new(); + let view = View { + gear: &gear, + beside: None, + known: &known, + hurt: true, + shop: None, + sort: Sort::default(), + }; + let mut inv = Inventory::new(); + press(&mut inv, &view, KeyCode::Char('/')); + typed(&mut inv, &view, "medp"); + press(&mut inv, &view, KeyCode::Enter); + assert_eq!(inv.picks(&view, false), [Row::You(Spot::Pack(0))]); + assert_eq!(typed(&mut inv, &view, "a")[0], Reply::Do(Command::UseMedpatch)); + press(&mut inv, &view, KeyCode::Esc); + assert_eq!(inv.picks(&view, false).len(), 8, "esc clears it"); + } + + #[test] + fn the_menu_lists_what_you_can_do() { + let gear = gear(); + let known = BTreeSet::new(); + let lockers = vec![Stack { item: Item::Medpatch, count: 2 }]; + let view = View { + gear: &gear, + beside: Some((Beside::Lockers, &lockers)), + known: &known, + hurt: false, + shop: None, + sort: Sort::default(), + }; + let mut inv = Inventory::new(); + typed(&mut inv, &view, "G"); + press(&mut inv, &view, KeyCode::Enter); + assert_eq!(inv.menu, Some(0)); + typed(&mut inv, &view, "jj"); + // drop one, drop them all, stow one, stow them all. + assert_eq!( + press(&mut inv, &view, KeyCode::Enter), + Reply::Do(Command::Stow(Spot::Pack(0), 1)) + ); + typed(&mut inv, &view, "l"); + assert_eq!(typed(&mut inv, &view, "M")[0], Reply::Do(Command::Fetch(0, 2))); + } + + #[test] + fn carrying_out_moves_things_about() { + let mut gear = gear(); + let mut lockers = Vec::new(); + let mut floor = Vec::new(); + let lockers_beside = Some(Beside::Lockers); + let said = carry_out( + Command::Stow(Spot::Pack(0), 2), + &mut gear, + Some(&mut lockers), + &mut floor, + lockers_beside, + ); + assert_eq!(said, "You stow 2 medpatches in the lockers."); + assert_eq!((gear.medpatches(), lockers[0].count), (3, 2)); + carry_out(Command::Fetch(0, 9), &mut gear, Some(&mut lockers), &mut floor, lockers_beside); + assert_eq!((gear.medpatches(), lockers.len()), (5, 0)); + // Your own torch can go, and the blade in your pack takes its place; + // but not the blade too, with nothing left to hit things with. + let said = carry_out(Command::Drop(Spot::Melee, 1), &mut gear, None, &mut floor, None); + assert_eq!(said, "You put down your cutting torch."); + assert!(!gear.melee.kit); + let said = carry_out(Command::Drop(Spot::Melee, 1), &mut gear, None, &mut floor, None); + assert!(said.contains("nothing to hit things with"), "{said}"); + assert_eq!(floor.len(), 1); + let said = carry_out( + Command::Fetch(0, 1), + &mut gear, + None, + &mut floor, + Some(Beside::Floor("on the deck")), + ); + assert!(said.starts_with("You take"), "{said}"); + assert!(floor.is_empty()); + } + + #[test] + fn piles_gather_and_scatter() { + let mut piles = vec![ + (Pos::new(1, 1), Stack::one(Item::Medpatch)), + (Pos::new(2, 2), Stack::one(Item::Medpatch)), + (Pos::new(1, 1), Stack { item: Item::Medpatch, count: 3 }), + ]; + let here = gather(&mut piles, Pos::new(1, 1)); + assert_eq!((here.len(), piles.len()), (2, 1)); + scatter(&mut piles, Pos::new(1, 1), here); + assert_eq!(lying(&piles, Pos::new(1, 1)).len(), 2); + assert!(underfoot(&lying(&piles, Pos::new(2, 2))).unwrap().contains("a medpatch")); + } +} diff --git a/src/scenes/keys.rs b/src/scenes/keys.rs new file mode 100644 index 0000000..8c07fed --- /dev/null +++ b/src/scenes/keys.rs @@ -0,0 +1,97 @@ +//! Keys every screen shares: which way the movement keys go, and how key +//! hints are drawn. A hint is the key in the accent colour, bold, then what +//! it does, dimmed; hints sit two spaces apart, and side panels pack as +//! many to a line as fit. + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::style::{Color, Stylize}; +use ratatui::text::{Line, Span}; + +pub const ACCENT: Color = Color::Rgb(240, 200, 110); +pub const DIM: Color = Color::Rgb(120, 124, 142); + +/// Vi keys, arrows, and the number pad with or without num lock. +pub fn direction(code: KeyCode) -> Option<(i32, i32)> { + Some(match code { + KeyCode::Left | KeyCode::Char('h' | '4') => (-1, 0), + KeyCode::Right | KeyCode::Char('l' | '6') => (1, 0), + KeyCode::Up | KeyCode::Char('k' | '8') => (0, -1), + KeyCode::Down | KeyCode::Char('j' | '2') => (0, 1), + KeyCode::Home | KeyCode::Char('y' | '7') => (-1, -1), + KeyCode::PageUp | KeyCode::Char('u' | '9') => (1, -1), + KeyCode::End | KeyCode::Char('b' | '1') => (-1, 1), + KeyCode::PageDown | KeyCode::Char('n' | '3') => (1, 1), + _ => return None, + }) +} + +/// A step, or with shift a run: which way, and whether to run. Shifted vi +/// keys come as capitals. +pub fn movement(key: KeyEvent) -> Option<(i32, i32, bool)> { + let (code, run) = match key.code { + KeyCode::Char(c) if c.is_ascii_uppercase() => (KeyCode::Char(c.to_ascii_lowercase()), true), + code => (code, key.modifiers.contains(KeyModifiers::SHIFT)), + }; + direction(code).map(|(dx, dy)| (dx, dy, run)) +} + +/// One hint: "esc back". +fn hint(key: &str, what: &str) -> [Span<'static>; 2] { + [Span::raw(key.to_string()).fg(ACCENT).bold(), Span::raw(format!(" {what}")).fg(DIM)] +} + +/// Hints in a row. +pub fn line(hints: &[(&str, &str)]) -> Line<'static> { + let mut spans = Vec::new(); + for (i, &(key, what)) in hints.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.extend(hint(key, what)); + } + Line::from(spans) +} + +/// Hints packed into lines at most `width` wide, as many to a line as fit. +/// A hint is never split; one too wide for any line gets a line to itself. +pub fn packed(hints: &[(&str, &str)], width: u16) -> Vec<Line<'static>> { + let width = usize::from(width); + let mut lines: Vec<Line<'static>> = Vec::new(); + for &(key, what) in hints { + let [k, w] = hint(key, what); + let needs = k.width() + w.width(); + match lines.last_mut() { + Some(last) if last.width() + 2 + needs <= width => { + last.spans.extend([Span::raw(" "), k, w]); + } + _ => lines.push(Line::from(vec![k, w])), + } + } + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(line: &Line) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn hints_pack_without_splitting() { + let hints = [("f", "fire"), ("r", "reload"), ("esc", "menu: save and quit")]; + let lines: Vec<String> = packed(&hints, 16).iter().map(text).collect(); + assert_eq!(lines, ["f fire r reload", "esc menu: save and quit"]); + assert_eq!(text(&line(&hints[..2])), "f fire r reload"); + } + + #[test] + fn shift_or_a_capital_runs() { + let key = |code, modifiers| movement(KeyEvent::new(code, modifiers)); + assert_eq!(key(KeyCode::Char('h'), KeyModifiers::NONE), Some((-1, 0, false))); + assert_eq!(key(KeyCode::Char('L'), KeyModifiers::SHIFT), Some((1, 0, true))); + assert_eq!(key(KeyCode::Down, KeyModifiers::SHIFT), Some((0, 1, true))); + assert_eq!(key(KeyCode::Char('G'), KeyModifiers::SHIFT), None); + } +} diff --git a/src/scenes/mod.rs b/src/scenes/mod.rs index 60db647..1c20edf 100644 --- a/src/scenes/mod.rs +++ b/src/scenes/mod.rs @@ -1,9 +1,25 @@ -//! Scenes: the title screen, the name forge, and derelicts to board; -//! flight and ports later. +//! Scenes: the title screen, characters and their saves, the name forge, +//! derelicts to board, flight (around a place, across the system map, and +//! between systems on the star map), and what's on the ground: cities to +//! walk, and ports. +pub mod aboard; +pub mod character; pub mod derelict; +pub mod flight; pub mod forge; +pub mod galaxy_map; +pub mod inventory; +pub mod keys; +pub mod pause; +pub mod port; +pub mod saves; +pub mod sky; +pub mod system_map; pub mod title; +pub mod town; +#[cfg(test)] +mod zz_dump; use ratatui::Frame; use ratatui::crossterm::event::KeyEvent; @@ -15,8 +31,14 @@ pub enum Transition { None, /// Open a scene on top of this one. Popping it comes back here. Push(Box<dyn Scene>), + /// Open several scenes at once, each on top of the one before. + Open(Vec<Box<dyn Scene>>), /// Close this scene and return to the one beneath it. Pop, + /// Close this scene and open these in its place. + Replace(Vec<Box<dyn Scene>>), + /// Close every scene but the title screen, at the bottom of the stack. + Title, Quit, } @@ -33,10 +55,15 @@ pub trait Scene { } fn render(&self, frame: &mut Frame, ctx: &Ctx); + + /// This scene has come to the top of the stack: just opened, or + /// uncovered when the ones above it closed. What it shows may need + /// bringing up to date with what happened up there. + fn on_top(&mut self, _ctx: &Ctx) {} } #[cfg(test)] -mod tests { +pub(crate) mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::crossterm::event::{KeyCode, KeyModifiers}; @@ -53,13 +80,130 @@ mod tests { #[test] fn scenes_render_at_any_size() { let mut ctx = test_ctx(); + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + let mut pausing = derelict::Derelict::new(2); + pausing.key(esc, &mut ctx); let mut abandoning = derelict::Derelict::new(2); - abandoning.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut ctx); - let scenes: [Box<dyn Scene>; 4] = [ - Box::new(title::Title::new(&ctx)), + abandoning.key(esc, &mut ctx); + abandoning.key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE), &mut ctx); + let mut aiming = derelict::Derelict::new(2); + aiming.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), &mut ctx); + aiming.key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), &mut ctx); + let mut packing = derelict::Derelict::new(2); + packing.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), &mut ctx); + // Something of every kind, on you and in the lockers. + { + use crate::dungeon::armour; + use crate::dungeon::arms::Source; + use crate::items::{Food, Item, Salvage, Slot, Stack}; + let mut rng = crate::seed::rng(8); + let mut armor = |slot| { + let mut a = armour::find(&mut rng, Source::Armoury, false); + a.slot = slot; + a.durability = 1; + a + }; + let food = Food { + title: "ration brick".into(), + description: "dense and grey".into(), + nourishment: 10, + mass: 400, + }; + let scrap = Salvage { title: "coil of superconductor".into(), value: 30, mass: 6000 }; + let g = &mut ctx.hold.gear; + g.worn.insert(Slot::Body, armor(Slot::Body)); + g.add(Stack::one(Item::Armor(armor(Slot::Head)))); + g.add(Stack { item: Item::Food(food), count: 3 }); + g.add(Stack { item: Item::Medpatch, count: 2 }); + ctx.hold.lockers.push(Stack { item: Item::Salvage(scrap.clone()), count: 4 }); + ctx.hold.deck.push((crate::dungeon::Pos::new(0, 0), Stack::one(Item::Salvage(scrap)))); + } + let mut rummaging = derelict::Derelict::board(2, &mut ctx); + for code in [KeyCode::Char('i'), KeyCode::Char('j'), KeyCode::Enter] { + rummaging.key(KeyEvent::new(code, KeyModifiers::NONE), &mut ctx); + } + let home = ctx.system.home_place(); + let mut stowing = aboard::Aboard::landed(&ctx, home); + for code in [KeyCode::Char('i'), KeyCode::Char('l'), KeyCode::Char('/')] { + stowing.key(KeyEvent::new(code, KeyModifiers::NONE), &mut ctx); + } + let mut strolling = town::TownScene::new(&ctx, home); + strolling.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), &mut ctx); + let mut leaving = town::TownScene::new(&ctx, home); + leaving.key(esc, &mut ctx); + let mut resting = aboard::Aboard::landed(&ctx, home); + resting.key(esc, &mut ctx); + let mut flying = flight::FlightScene::new(&ctx); + flying.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut ctx); + let mut holding = flight::FlightScene::new(&ctx); + holding.key(esc, &mut ctx); + let mut docked = port::Port::new(&ctx, 0); + docked.key(esc, &mut ctx); + let belt = ctx.system.places.len() - 1; + let mut trading = town::TownScene::new(&ctx, home); + for _ in 0..12 { + trading.key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut ctx); + } + let wreck = ctx.system.wrecks_on(ctx.day).pop(); + let mut renaming = character::Sheet::new(); + renaming.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), &mut ctx); + let mut creating = character::Create::new(&ctx); + creating.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &mut ctx); + let dir = crate::save::scratch("render"); + ctx.saves = Some(dir.clone()); + ctx.slot = Some(crate::save::new_slot(&dir, &ctx.character, ctx.seed)); + ctx.record.slain.insert("rust-mite".into(), 2); + ctx.bestiary.insert(("rust-mite", crate::dungeon::arms::Dmg::Blunt)); + ctx.autosave(); + std::fs::write(dir.join("broken.json"), "{").unwrap(); + let playing = title::Title::new(&ctx); + let mut plotting = galaxy_map::GalaxyMap::new(&ctx); + plotting.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut ctx); + let mut far = galaxy_map::GalaxyMap::new(&ctx); + for _ in 0..6 { + far.key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut ctx); + } + let mut stranded = test_ctx(); + let dead = (0..stranded.galaxy.sites.len()) + .find(|&i| !stranded.galaxy.sites[i].kind.sells_fuel()) + .unwrap(); + stranded.enter_system(dead); + stranded.hold.fuel = 0; + stranded.hold.debt = 120; + let scenes: [Box<dyn Scene>; 33] = [ + Box::new(title::Title::new(&test_ctx())), + Box::new(playing), + Box::new(character::Create::new(&ctx)), + Box::new(creating), + Box::new(character::Sheet::new()), + Box::new(renaming), + Box::new(saves::Saves::new(&ctx)), Box::new(forge::Forge::new(&ctx)), Box::new(derelict::Derelict::new(1)), + Box::new(pausing), Box::new(abandoning), + Box::new(holding), + Box::new(docked), + Box::new(leaving), + Box::new(resting), + Box::new(aiming), + Box::new(packing), + Box::new(rummaging), + Box::new(stowing), + Box::new(strolling), + Box::new(flying), + Box::new(system_map::SystemMap::new(&ctx)), + Box::new(galaxy_map::GalaxyMap::new(&ctx)), + Box::new(plotting), + Box::new(far), + Box::new(port::Port::new(&ctx, ctx.system.home_place())), + Box::new(port::Port::new(&ctx, belt)), + Box::new(port::Port::new(&ctx, 0)), + Box::new(town::TownScene::new(&ctx, home)), + Box::new(trading), + Box::new(aboard::Aboard::landed(&ctx, home)), + Box::new(aboard::Aboard::resumed(&ctx, home)), + Box::new(aboard::Aboard::adrift("in orbit around nowhere", wreck)), ]; for (width, height) in [(160, 50), (80, 24), (40, 12), (5, 3), (1, 1)] { let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); @@ -69,6 +213,9 @@ mod tests { terminal.draw(|frame| scene.render(frame, &ctx)).unwrap(); } } + let map = galaxy_map::GalaxyMap::new(&stranded); + terminal.draw(|frame| map.render(frame, &stranded)).unwrap(); } + let _ = std::fs::remove_dir_all(dir); } } diff --git a/src/scenes/pause.rs b/src/scenes/pause.rs new file mode 100644 index 0000000..41cff43 --- /dev/null +++ b/src/scenes/pause.rs @@ -0,0 +1,160 @@ +//! The menu `esc` opens over a game screen: keep playing, do what `esc` +//! used to do there (abandon ship, back to your ship), or save and quit to +//! the title. It opens on keep playing, and `esc` again closes it: quitting +//! is always something you choose. +//! +//! Like the inventory, it's a panel a scene holds and draws over itself, +//! not a scene of its own: the scene beneath stays in view, and stops while +//! the menu's open. + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap}; + +use super::keys::{self, ACCENT, DIM}; + +/// What you chose. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Choice { + /// Save, and go back to the title screen. + Quit, + /// Whatever `esc` does here besides: abandon ship, leave the building. + Here, + /// Close the menu and carry on. + Resume, +} + +pub struct Pause { + choice: usize, + /// What else you can do here, if there's anything: "Abandon ship". + here: Option<&'static str>, + /// A word about where you are, or which way to go. + note: Option<String>, +} + +impl Pause { + pub fn new(here: Option<&'static str>, note: Option<String>) -> Pause { + Pause { choice: 0, here, note } + } + + fn choices(&self) -> Vec<(Choice, &'static str)> { + let mut choices = vec![(Choice::Resume, "Keep playing")]; + if let Some(here) = self.here { + choices.push((Choice::Here, here)); + } + choices.push((Choice::Quit, "Save and quit to the title")); + choices + } + + /// Takes a key. Says what you chose, if you chose anything. A key held + /// down doesn't count again: holding `esc` mustn't flicker the menu. + pub fn key(&mut self, key: KeyEvent) -> Option<Choice> { + if key.kind == KeyEventKind::Repeat { + return None; + } + let choices = self.choices(); + let last = choices.len() - 1; + match key.code { + KeyCode::Esc | KeyCode::Char('q') => Some(Choice::Resume), + KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => { + self.choice = (self.choice + 1).min(last); + None + } + KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => { + self.choice = self.choice.saturating_sub(1); + None + } + KeyCode::Char('g') | KeyCode::Home => { + self.choice = 0; + None + } + KeyCode::Char('G') | KeyCode::End => { + self.choice = last; + None + } + KeyCode::Enter | KeyCode::Char(' ' | 'l') => Some(choices[self.choice.min(last)].0), + KeyCode::Char(c @ '1'..='9') => { + choices.get(c as usize - '1' as usize).map(|&(choice, _)| choice) + } + _ => None, + } + } + + /// Draws the menu in the middle of `area`. + pub fn render(&self, frame: &mut Frame, area: Rect) { + let mut lines = Vec::new(); + if let Some(note) = &self.note { + lines.push(Line::from(note.clone()).fg(DIM)); + lines.push(Line::default()); + } + for (i, (_, what)) in self.choices().into_iter().enumerate() { + let chosen = i == self.choice; + let marker = if chosen { "› " } else { " " }; + let line = Line::from(vec![ + Span::raw(format!("{} ", i + 1)).fg(ACCENT), + Span::raw(marker).fg(ACCENT), + Span::raw(what), + ]); + lines.push(if chosen { + line.patch_style(Style::new().add_modifier(Modifier::REVERSED)) + } else { + line + }); + } + lines.push(Line::default()); + lines.push(keys::line(&[("↑↓ enter", "choose"), ("esc", "keep playing")])); + + let [popup] = Layout::horizontal([Constraint::Max(48)]).flex(Flex::Center).areas(area); + let width = usize::from(popup.width.saturating_sub(4)).max(1); + let rows: usize = lines.iter().map(|l| l.width().div_ceil(width).max(1)).sum(); + let height = u16::try_from(rows + 2).unwrap_or(u16::MAX); + let [popup] = + Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(popup); + let block = Block::bordered() + .border_style(Style::new().fg(ACCENT)) + .title(Line::from(" PAUSED ").fg(ACCENT).bold()) + .padding(Padding::horizontal(1)); + frame.render_widget(Clear, popup); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), popup); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + + fn press(pause: &mut Pause, code: KeyCode) -> Option<Choice> { + pause.key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + #[test] + fn esc_again_closes_the_menu_and_holding_it_does_nothing() { + let mut pause = Pause::new(Some("Abandon ship"), None); + let held = KeyEvent::new_with_kind(KeyCode::Esc, KeyModifiers::NONE, KeyEventKind::Repeat); + assert_eq!(pause.key(held), None); + assert_eq!(press(&mut pause, KeyCode::Esc), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('j')), None); + assert_eq!(press(&mut pause, KeyCode::Esc), Some(Choice::Resume), "wherever the cursor is"); + } + + #[test] + fn you_can_pick_what_else_esc_does_or_carry_on() { + let mut pause = Pause::new(Some("Abandon ship"), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Resume), "it opens on that"); + assert_eq!(press(&mut pause, KeyCode::Char('j')), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Here)); + assert_eq!(press(&mut pause, KeyCode::Char('G')), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Quit)); + assert_eq!(press(&mut pause, KeyCode::Char('1')), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('3')), Some(Choice::Quit)); + assert_eq!(press(&mut pause, KeyCode::Char('q')), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('x')), None, "anything else does nothing"); + let mut pause = Pause::new(None, None); + assert_eq!(press(&mut pause, KeyCode::Char('2')), Some(Choice::Quit)); + } +} diff --git a/src/scenes/port.rs b/src/scenes/port.rs new file mode 100644 index 0000000..5595822 --- /dev/null +++ b/src/scenes/port.rs @@ -0,0 +1,330 @@ +//! Docked at a station, or landed somewhere no one lives: what the place +//! is like, and at a station, a market for the ore in your hold and the +//! salvage you've brought back from wrecks. Worlds +//! where people live have whole cities instead (`scenes::town`), which you +//! walk out to through your ship; here there's no city, so landing brings +//! you straight here, and leaving takes off again. + +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, Wrap}; + +use super::character::Sheet; +use super::keys::{self, ACCENT, DIM}; +use super::pause::{Choice, Pause}; +use super::sky; +use super::{Scene, Transition}; +use crate::app::{Ctx, Hold}; +use crate::items; +use crate::names; +use crate::space::locale::Ore; +use crate::space::system::{PlaceKind, System}; +use crate::trade; + +/// The colour fuel is shown in, everywhere. +pub const FUEL: Color = Color::Rgb(120, 210, 255); + +pub struct Port { + title: String, + /// "a desert world, home of the Qaari". + subtitle: String, + description: Vec<String>, + colour: Color, + /// What each ore sells for here, if anyone's buying. + prices: Option<Vec<(&'static Ore, u32)>>, + /// What a jump's worth of fuel costs, at a station. + fuel_price: Option<u32>, + /// What happened at the market. + report: Option<String>, + place: usize, + /// Whether this is a dock, which you undock from, or ground you take + /// off from. + docked: bool, + /// The menu `esc` opens. + pause: Option<Pause>, +} + +impl Port { + pub fn new(ctx: &Ctx, place: usize) -> Port { + let s = &ctx.system; + let (title, subtitle, description, colour, people) = match s.places[place].kind { + PlaceKind::Planet(p) => { + let planet = &s.planets[p]; + let home = if s.is_homeworld(p) { ", homeworld of " } else { ", settled by " }; + let people = planet + .people + .map_or(", uninhabited".to_string(), |c| format!("{home}{}", c.name())); + ( + planet.name.clone(), + format!("{}{people}", names::a(planet.kind.name)), + planet.description.clone(), + planet.kind.rgb, + planet.people, + ) + } + PlaceKind::Moon(p, m) => { + let moon = &s.planets[p].moons[m]; + let people = moon + .people + .map_or(", uninhabited".to_string(), |c| format!(", settled by {}", c.name())); + let kind = moon.kind.name.trim_end_matches(" world"); + let what = format!("{} moon of {}{people}", names::a(kind), s.planets[p].name); + (moon.name.clone(), what, moon.description.clone(), moon.kind.rgb, moon.people) + } + PlaceKind::Station(i) => { + let station = &s.stations[i]; + let (_, colour) = sky::station_art(station.culture); + let what = format!("a {} station", station.culture.adjective()); + ( + station.name.clone(), + what, + station.description.clone(), + colour, + Some(station.culture), + ) + } + PlaceKind::Belt => ( + s.belt.name.clone(), + "an asteroid belt".to_string(), + Vec::new(), + (150, 138, 124), + None, + ), + }; + Port { + title, + subtitle, + description, + colour: sky::rgb(colour), + prices: people.map(|_| trade::ore_prices(s, place)), + fuel_price: s.sells_fuel_at(place).then(|| trade::fuel_price(s, place)), + report: None, + place, + docked: matches!(s.places[place].kind, PlaceKind::Station(_)), + pause: None, + } + } + + fn refuel(&mut self, hold: &mut Hold) { + let Some(price) = self.fuel_price else { + self.report = Some("No one here sells fuel.".to_string()); + return; + }; + self.report = Some(match trade::buy_fuel(hold, price) { + (0, _) if hold.fuel >= hold.fuel_capacity => "Your tank's already full.".to_string(), + (0, _) => "You can't afford any fuel.".to_string(), + (jumps, cost) => format!( + "Bought {jumps} jump{} of fuel for {cost} ¤. Tank: {}/{}.", + if jumps == 1 { "" } else { "s" }, + hold.fuel, + hold.fuel_capacity + ), + }); + } + + /// Sells all your ore, and all your salvage, on you and aboard. + fn sell(&mut self, hold: &mut Hold, system: &System) { + let Some(prices) = &self.prices else { + self.report = Some("No one lives here to buy anything.".to_string()); + return; + }; + let debt = hold.debt; + let ore = trade::sell_ore(hold, prices); + let salvage = trade::sell_all_salvage(hold, system, self.place); + let mut sold = Vec::new(); + if let Some((tons, earned)) = ore { + sold.push(format!("{tons} t of ore for {earned} ¤")); + } + if let Some((pieces, earned)) = salvage { + let s = if pieces == 1 { "" } else { "s" }; + sold.push(format!("{pieces} piece{s} of salvage for {earned} ¤")); + } + self.report = Some(if sold.is_empty() { + "You've no ore or salvage to sell.".to_string() + } else { + format!("Sold {}.{}", sold.join(" and "), trade::debt_note(debt - hold.debt, hold)) + }); + } +} + +impl Scene for Port { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + if let Some(pause) = &mut self.pause { + let Some(choice) = pause.key(key) else { return Transition::None }; + self.pause = None; + return match choice { + Choice::Quit => Transition::Title, + Choice::Here => Transition::Pop, + Choice::Resume => Transition::None, + }; + } + match key.code { + KeyCode::Esc => { + let (here, note) = if self.docked { + ("Undock", format!("Docked at {}.", self.title)) + } else { + ("Take off", format!("Landed on {}.", self.title)) + }; + self.pause = Some(Pause::new(Some(here), Some(note))); + Transition::None + } + KeyCode::Char('s' | 'S') => { + self.sell(&mut ctx.hold, &ctx.system); + Transition::None + } + KeyCode::Char('f' | 'F') => { + self.refuel(&mut ctx.hold); + Transition::None + } + KeyCode::Char('c' | 'C') => Transition::Push(Box::new(Sheet::new())), + // Only `l`, as it landed you: enter or space mashed through a + // message shouldn't take off. + KeyCode::Char('l' | 'L') => Transition::Pop, + _ => Transition::None, + } + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let area = frame.area(); + let [area] = Layout::horizontal([Constraint::Max(68)]).flex(Flex::Center).areas(area); + + let mut lines = vec![Line::from(self.subtitle.clone()).fg(DIM), Line::default()]; + for sentence in &self.description { + lines.push(Line::from(sentence.clone())); + lines.push(Line::default()); + } + + let hold = &ctx.hold; + match &self.prices { + Some(prices) => { + lines.push(Line::from("THE MARKET").fg(ACCENT).bold()); + for (ore, price) in prices { + let have = hold.ore.get(ore.name).copied().unwrap_or(0); + let have = if have > 0 { format!("you have {have} t") } else { String::new() }; + lines.push(Line::from(vec![ + Span::raw(" * ").fg(sky::rgb(ore.rgb)), + Span::raw(format!("{:<16}{:>4} ¤ a ton ", ore.name, price)), + Span::raw(have).fg(DIM), + ])); + } + } + None => lines.push(Line::from("No one lives here to trade with.").fg(DIM)), + } + if let Some(price) = self.fuel_price { + lines.push(Line::from(vec![ + Span::raw(" ◆ ").fg(FUEL), + Span::raw(format!("{:<16}{:>4} ¤ a jump ", "fuel", price)), + Span::raw(format!("tank {}/{}", hold.fuel, hold.fuel_capacity)).fg(DIM), + ])); + } + lines.push(Line::default()); + let salvage = if self.prices.is_some() { + trade::salvage_kept(hold, &ctx.system, self.place) + } else { + Vec::new() + }; + if !salvage.is_empty() { + let worth: u32 = salvage.iter().map(|(_, w)| w).sum(); + lines.push(Line::from(vec![ + Span::raw(" ( ").fg(Color::Rgb(150, 200, 222)), + Span::raw(format!("{:<16}{:>4} ¤ the lot", "your salvage", worth)), + ])); + } + lines.push(Line::from(format!( + "Your hold: {} t of ore, {} artifacts, {} ¤. You carry {} of {} kg.", + hold.ore_tons(), + hold.artifacts(), + hold.scrip, + items::kg(hold.gear.carried()), + items::kg(hold.gear.capacity) + ))); + if hold.debt > 0 { + lines.push(Line::from(format!("You owe {} ¤ for a tow.", hold.debt)).fg(DIM)); + } + if let Some(report) = &self.report { + lines.push(Line::from(report.clone()).fg(ACCENT)); + } + lines.push(Line::default()); + let mut hints = vec![]; + if self.prices.is_some() { + hints.push(("s", "sell ore and salvage")); + } + if self.fuel_price.is_some() { + hints.push(("f", "fill the tank")); + } + hints.push(("l", if self.docked { "undock" } else { "take off" })); + hints.extend([("c", "character"), ("esc", "menu")]); + lines.extend(keys::packed(&hints, area.width.saturating_sub(4))); + + let block = Block::bordered() + .title(format!(" {} ", self.title)) + .title_style(Style::new().fg(self.colour).bold()) + .border_style(Style::new().fg(self.colour)) + .padding(Padding::horizontal(1)); + // As tall as the text, wrapped, and no taller. + let width = usize::from(area.width.saturating_sub(4)).max(1); + let rows: usize = lines.iter().map(|l| l.width().div_ceil(width).max(1)).sum(); + let height = u16::try_from(rows + 3).unwrap_or(u16::MAX); + let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); + if let Some(pause) = &self.pause { + pause.render(frame, frame.area()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scenes::tests::test_ctx; + + #[test] + fn only_l_takes_off() { + use ratatui::crossterm::event::KeyModifiers; + let mut ctx = test_ctx(); + let mut port = Port::new(&ctx, 0); + let mut press = |code| port.key(KeyEvent::new(code, KeyModifiers::NONE), &mut ctx); + assert!(matches!(press(KeyCode::Enter), Transition::None)); + assert!(matches!(press(KeyCode::Char(' ')), Transition::None)); + assert!(matches!(press(KeyCode::Char('c')), Transition::Push(_))); + assert!(matches!(press(KeyCode::Char('l')), Transition::Pop)); + } + + #[test] + fn stations_buy_ore() { + let mut ctx = test_ctx(); + let station = + ctx.system.places.iter().position(|p| matches!(p.kind, PlaceKind::Station(_))).unwrap(); + let mut port = Port::new(&ctx, station); + let prices = port.prices.clone().expect("stations trade"); + ctx.hold.ore.insert("iron ore", 3); + ctx.hold.ore.insert("iridium", 2); + let salvage = crate::items::Salvage { title: "hull plate".into(), value: 20, mass: 9000 }; + ctx.hold + .lockers + .push(crate::items::Stack::one(crate::items::Item::Salvage(salvage.clone()))); + port.sell(&mut ctx.hold, &ctx.system); + let scrap = trade::salvage_offer(&ctx.system, station, &salvage); + assert!(ctx.hold.lockers.is_empty(), "the salvage goes too"); + let price = |name: &str| prices.iter().find(|(o, _)| o.name == name).unwrap().1; + assert_eq!(ctx.hold.scrip, 3 * price("iron ore") + 2 * price("iridium") + scrap); + assert_eq!(ctx.hold.ore_tons(), 0); + } + + #[test] + fn stations_sell_fuel() { + let mut ctx = test_ctx(); + let station = + ctx.system.places.iter().position(|p| matches!(p.kind, PlaceKind::Station(_))).unwrap(); + let mut port = Port::new(&ctx, station); + let price = port.fuel_price.expect("stations sell fuel"); + ctx.hold.fuel = 1; + ctx.hold.scrip = 10 * price; + port.key(KeyEvent::from(KeyCode::Char('f')), &mut ctx); + assert_eq!(ctx.hold.fuel, ctx.hold.fuel_capacity); + assert_eq!(ctx.hold.scrip, 7 * price); + } +} 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"); + } +} diff --git a/src/scenes/sky.rs b/src/scenes/sky.rs new file mode 100644 index 0000000..8248dd0 --- /dev/null +++ b/src/scenes/sky.rs @@ -0,0 +1,460 @@ +//! Drawing space: starfields, lit planets, rocks, stations, wrecks and your +//! ship, in terminal cells. +//! +//! A local map unit is one cell across and half a cell down (cells are +//! about twice as tall as wide), so a circle of radius r spans 2r columns +//! and r rows, and looks round. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier}; + +use crate::names::Culture; +use crate::seed; +use crate::space::Vec2; +use crate::space::flight::Ship; +use crate::space::system::Rgb; + +pub fn rgb((r, g, b): Rgb) -> Color { + Color::Rgb(r, g, b) +} + +/// `colour` at `k` times its brightness (k from 0 to about 1.3). +pub fn shade((r, g, b): Rgb, k: f64) -> Color { + let f = |c: u8| (f64::from(c) * k).round().clamp(0.0, 255.0) as u8; + Color::Rgb(f(r), f(g), f(b)) +} + +/// Between `a` (at k = 0) and `b` (at k = 1). +pub fn mix(a: Rgb, b: Rgb, k: f64) -> Rgb { + let k = k.clamp(0.0, 1.0); + let f = |x: u8, y: u8| (f64::from(x) * (1.0 - k) + f64::from(y) * k).round() as u8; + (f(a.0, b.0), f(a.1, b.1), f(a.2, b.2)) +} + +/// A number from 0 to 1 that's always the same for the same inputs. +pub fn hash01(seed: u64, x: i64, y: i64) -> f64 { + let h = seed::derive(seed, "", (x as u64) << 32 ^ (y as u64 & 0xffff_ffff)); + (h >> 11) as f64 / (1u64 << 53) as f64 +} + +/// Where on screen a map position lands, with the camera in the middle of +/// `area`. Fractional: the cell is the floor of each coordinate. +#[derive(Clone, Copy)] +pub struct View { + pub area: Rect, + pub camera: Vec2, +} + +impl View { + pub fn screen(&self, p: Vec2) -> (f64, f64) { + let a = self.area; + let col = f64::from(a.x) + f64::from(a.width) / 2.0 + (p.x - self.camera.x); + let row = f64::from(a.y) + f64::from(a.height) / 2.0 + (p.y - self.camera.y) / 2.0; + (col, row) + } + + /// The cell a map position falls in, if it's on screen. + pub fn cell(&self, p: Vec2) -> Option<(u16, u16)> { + let (col, row) = self.screen(p); + cell_in(self.area, col.floor(), row.floor()) + } + + pub fn put(&self, buf: &mut Buffer, p: Vec2, ch: char, fg: Color) { + if let Some(pos) = self.cell(p) { + buf[pos].set_char(ch).set_fg(fg); + } + } +} + +fn cell_in(area: Rect, col: f64, row: f64) -> Option<(u16, u16)> { + let inside = col >= f64::from(area.x) + && row >= f64::from(area.y) + && col < f64::from(area.right()) + && row < f64::from(area.bottom()); + inside.then_some((col as u16, row as u16)) +} + +/// Writes `text` centred on a screen column, clipped to `area`. +pub fn label(buf: &mut Buffer, area: Rect, col: f64, row: f64, text: &str, fg: Color) { + let width = text.chars().count() as f64; + let start = (col - width / 2.0).floor(); + for (i, ch) in text.chars().enumerate() { + if let Some(pos) = cell_in(area, start + i as f64, row.floor()) { + buf[pos].set_char(ch).set_fg(fg); + } + } +} + +/// Star layers: how fast each moves as the camera does, how rare its stars +/// are, glyph, and colour. Slower layers read as further away. +const STAR_LAYERS: [(f64, u32, char, Rgb); 3] = [ + (0.04, 90, '.', (70, 72, 92)), + (0.12, 170, '·', (130, 132, 150)), + (0.3, 420, '+', (210, 210, 225)), +]; + +pub fn starfield(buf: &mut Buffer, area: Rect, camera: Vec2, seed: u64) { + for (layer, &(parallax, rarity, glyph, colour)) in STAR_LAYERS.iter().enumerate() { + let layer_seed = seed::derive(seed, "stars", layer as u64); + let dx = (camera.x * parallax).floor() as i64; + let dy = (camera.y * parallax / 2.0).floor() as i64; + for row in area.top()..area.bottom() { + for col in area.left()..area.right() { + let h = hash01(layer_seed, i64::from(col) + dx, i64::from(row) + dy); + if h * f64::from(rarity) < 1.0 { + buf[(col, row)].set_char(glyph).set_fg(rgb(colour)); + } + } + } + } +} + +/// How to paint a sphere. +pub struct Surface { + pub rgb: Rgb, + /// Cloud bands, for giants. + pub banded: bool, + /// City lights on the night side. + pub lights: bool, + /// Glyphs from darkest to brightest. + pub ramp: &'static [char], + /// Varies the mottling and bands from one body to the next. + pub seed: u64, +} + +pub const PLANET_RAMP: &[char] = &['·', '░', '▒', '▓', '█']; +pub const ROCK_RAMP: &[char] = &['.', ':', '%', '#', '@']; + +/// A sphere centred at screen position (`col`, `row`), `radius` columns +/// across, lit from `sun` (a unit vector on the map, y down). Returns +/// whether it covered any cell. +pub fn sphere( + buf: &mut Buffer, + area: Rect, + (col, row): (f64, f64), + radius: f64, + sun: Vec2, + surface: &Surface, +) -> bool { + // Mostly from the side, a little from in front, so there's always a + // terminator to see. + let light = { + let (x, y, z) = (sun.x, sun.y, 0.45); + let len = (x * x + y * y + z * z).sqrt(); + (x / len, y / len, z / len) + }; + let top = (row - radius / 2.0).floor() as i64; + let bottom = (row + radius / 2.0).ceil() as i64; + let left = (col - radius).floor() as i64; + let right = (col + radius).ceil() as i64; + let mut drawn = false; + for r in top..=bottom { + for c in left..=right { + let nx = (c as f64 + 0.5 - col) / radius; + let ny = (r as f64 + 0.5 - row) * 2.0 / radius; + let d2 = nx * nx + ny * ny; + if d2 > 1.0 { + continue; + } + let Some(pos) = cell_in(area, c as f64, r as f64) else { + continue; + }; + drawn = true; + let nz = (1.0 - d2).sqrt(); + let lit = (nx * light.0 + ny * light.1 + nz * light.2).max(0.0); + let grain = |n: f64| { + let g = hash01(surface.seed, (nx * n).floor() as i64, (ny * n).floor() as i64); + 0.86 + 0.14 * g + }; + let mut tone = grain(5.0); + if surface.banded { + let phase = (surface.seed % 628) as f64 / 100.0; + tone = + 0.84 + 0.12 * (ny * 7.0 + phase).sin() + 0.06 * (ny * 19.0 + phase * 2.0).sin(); + } + let cell = &mut buf[pos]; + if lit < 0.04 { + let city = surface.lights && grain(13.0) > 0.99 - 0.08; + if city { + cell.set_char('·').set_fg(Color::Rgb(255, 196, 110)); + } else { + cell.set_char(surface.ramp[0]).set_fg(shade(surface.rgb, 0.16 * tone)); + } + continue; + } + let steps = surface.ramp.len() - 1; + let glyph = surface.ramp[1 + ((lit * steps as f64) as usize).min(steps - 1)]; + cell.set_char(glyph).set_fg(shade(surface.rgb, (0.2 + 0.95 * lit) * tone)); + } + } + drawn +} + +/// One glyph per eighth of a turn, for headings (0 is up, clockwise). +pub fn arrow(heading: f64) -> char { + const ARROWS: [char; 8] = ['↑', '↗', '→', '↘', '↓', '↙', '←', '↖']; + let eighth = (heading / std::f64::consts::TAU * 8.0).round() as i64; + ARROWS[eighth.rem_euclid(8) as usize] +} + +/// Each culture's station, drawn centred on its position. +pub fn station_art(culture: Culture) -> (&'static [&'static str], Rgb) { + match culture { + Culture::Qaari => (&[" .-=-. ", "=(( o ))=", " '-=-' "], (232, 190, 112)), + Culture::Cant => (&[" /\\ ", "=[|##|]=", " |##| ", " \\/ "], (204, 124, 92)), + Culture::Houses => (&[" _/^^\\_ ", " <|-<>-|>", " \\__/ "], (206, 186, 240)), + Culture::Drift => (&[" =o= =o= ", "<==[ ]==>", " =o= =o= "], (124, 212, 200)), + } +} + +pub fn draw_station(buf: &mut Buffer, view: &View, culture: Culture, ticks: u64) { + let (art, colour) = station_art(culture); + let (col, row) = view.screen(Vec2::ZERO); + let top = row - art.len() as f64 / 2.0; + for (i, line) in art.iter().enumerate() { + let width = line.chars().count() as f64; + let start = (col - width / 2.0).floor(); + for (j, ch) in line.chars().enumerate() { + if ch == ' ' { + continue; + } + let Some(pos) = cell_in(view.area, start + j as f64, (top + i as f64).floor()) else { + continue; + }; + // Running lights blink in turn. + let light = ch == 'o' && (ticks / 15 + j as u64).is_multiple_of(3); + let fg = if light { Color::Rgb(255, 240, 200) } else { rgb(colour) }; + buf[pos].set_char(ch).set_fg(fg); + if light { + buf[pos].modifier.insert(Modifier::BOLD); + } + } + } +} + +/// A wreck: a dark hull pointing along its heading, with a red distress +/// beacon blinking if `beacon` (there's still anything aboard, and the +/// beacon hasn't failed). +pub fn draw_wreck( + buf: &mut Buffer, + view: &View, + pos: Vec2, + heading: f64, + beacon: bool, + ticks: u64, +) { + let along = Vec2::from_heading(heading); + let hull = Color::Rgb(112, 118, 140); + view.put(buf, pos - along * 1.5, '=', hull); + view.put(buf, pos, '#', hull); + view.put(buf, pos + along * 1.5, arrow(heading), hull); + if beacon && ticks % 40 < 8 { + view.put(buf, pos, '#', Color::Rgb(255, 70, 60)); + } +} + +/// Your ship's outline, a notched dart: corners as (ahead, to the right) +/// of its position, in map units. About five and a half units long. +const HULL: [(f64, f64); 4] = [(2.75, 0.0), (-2.75, 2.1), (-0.75, 0.0), (-2.75, -2.1)]; + +/// Where the engine flame starts: the notch at the back of the hull. +const NOZZLE: f64 = -0.75; + +/// Braille dot bits by (column, row) within a cell. +const BRAILLE: [[u32; 4]; 2] = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]]; + +/// Your ship, turned to exactly its heading, with a flame out the back +/// while the engine is lit. Drawn in braille dots, which are half a unit +/// square (two across a cell, four down), so it turns smoothly rather than +/// snapping between eight arrows. +pub fn draw_ship(buf: &mut Buffer, view: &View, ship: &Ship, ticks: u64) { + let ahead = Vec2::from_heading(ship.heading); + let right = Vec2::new(-ahead.y, ahead.x); + let local = |p: Vec2| (p.dot(ahead), p.dot(right)); + let (col, row) = view.screen(ship.pos); + if ship.thrusting { + // A plume, widest just behind the nozzle, flickering between short + // and long ten times a second. Its first unit or two is mostly + // hidden under the hull, which reads better than filling the notch. + let length = 4.0 + 2.0 * hash01(0, (ticks / 3) as i64, 0); + let flame = [ + (NOZZLE, 0.5), + (NOZZLE - 1.5, 1.0), + (NOZZLE - length, 0.0), + (NOZZLE - 1.5, -1.0), + (NOZZLE, -0.5), + ]; + for ((c, r), ch) in braille(view, ship.pos, 7.0, |p| inside(&flame, local(p))) { + // Yellow near the ship, cooling to red-orange at the tip. + let middle = Vec2::new(f64::from(c) + 0.5 - col, (f64::from(r) + 0.5 - row) * 2.0); + let k = (NOZZLE - local(middle).0) / length; + let colour = mix((255, 224, 130), (240, 80, 30), (k - 0.3) / 0.7); + buf[(c, r)].set_char(ch).set_fg(rgb(colour)); + } + } + // The hull goes on top: where the two share a cell, the hull wins. + for (cell, ch) in braille(view, ship.pos, 3.5, |p| inside(&HULL, local(p))) { + buf[cell].set_char(ch).set_fg(Color::White); + } +} + +/// The braille characters for a shape within `reach` map units of `pos`, +/// and the cells they go in. `covers` says whether a point (in map units +/// from `pos`) is inside the shape, and a dot is lit when the shape covers +/// at least 40% of it, which keeps edges clean at every angle. +fn braille( + view: &View, + pos: Vec2, + reach: f64, + covers: impl Fn(Vec2) -> bool, +) -> Vec<((u16, u16), char)> { + // Samples per dot, each way. + const SAMPLES: u32 = 4; + let sub = |i: u32| (f64::from(i) + 0.5) / f64::from(SAMPLES); + let (col, row) = view.screen(pos); + let mut cells = Vec::new(); + for r in (row - reach / 2.0).floor() as i64..=(row + reach / 2.0).floor() as i64 { + for c in (col - reach).floor() as i64..=(col + reach).floor() as i64 { + let Some(cell) = cell_in(view.area, c as f64, r as f64) else { + continue; + }; + let mut bits = 0; + for (dx, column) in BRAILLE.iter().enumerate() { + for (dy, bit) in column.iter().enumerate() { + let mut hits = 0; + for sx in 0..SAMPLES { + for sy in 0..SAMPLES { + // In map units from `pos`: a column is one, a row two. + let x = c as f64 - col + (dx as f64 + sub(sx)) / 2.0; + let y = (r as f64 - row + (dy as f64 + sub(sy)) / 4.0) * 2.0; + hits += u32::from(covers(Vec2::new(x, y))); + } + } + if hits * 5 >= SAMPLES * SAMPLES * 2 { + bits |= bit; + } + } + } + if let Some(ch) = char::from_u32(0x2800 + bits).filter(|_| bits != 0) { + cells.push((cell, ch)); + } + } + } + cells +} + +/// Whether `point` is inside `polygon` (even-odd rule, so it may be concave). +fn inside(polygon: &[(f64, f64)], (x, y): (f64, f64)) -> bool { + let mut odd = false; + for (i, &(x1, y1)) in polygon.iter().enumerate() { + let (x2, y2) = polygon[(i + 1) % polygon.len()]; + if (y1 > y) != (y2 > y) && x < x1 + (y - y1) * (x2 - x1) / (y2 - y1) { + odd = !odd; + } + } + odd +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every lit braille dot in `buf`, in map units from the centre of + /// `view`, as (ahead, to the right) for a ship facing `heading`. + fn ship_dots(buf: &Buffer, view: &View, heading: f64) -> Vec<(f64, f64)> { + let ahead = Vec2::from_heading(heading); + let right = Vec2::new(-ahead.y, ahead.x); + let (col, row) = view.screen(view.camera); + let mut dots = Vec::new(); + for r in 0..view.area.height { + for c in 0..view.area.width { + let ch = buf[(c, r)].symbol().chars().next().unwrap_or(' '); + let bits = u32::from(ch).wrapping_sub(0x2800); + for (dx, column) in BRAILLE.iter().enumerate() { + for (dy, &bit) in column.iter().enumerate() { + if bits <= 0xff && bits & bit != 0 { + let x = f64::from(c) - col + (dx as f64 + 0.5) / 2.0; + let y = (f64::from(r) - row + (dy as f64 + 0.5) / 4.0) * 2.0; + let p = Vec2::new(x, y); + dots.push((p.dot(ahead), p.dot(right))); + } + } + } + } + } + dots + } + + fn ship(heading: f64, thrusting: bool) -> Ship { + Ship { pos: Vec2::ZERO, vel: Vec2::ZERO, heading, thrusting } + } + + #[test] + fn the_ship_points_its_nose_along_its_heading() { + let view = View { area: Rect::new(0, 0, 20, 10), camera: Vec2::ZERO }; + for heading in [0.0, 1.0, 1.9, 2.5, 3.6, 4.4, 5.5] { + let mut buf = Buffer::empty(view.area); + draw_ship(&mut buf, &view, &ship(heading, false), 0); + let dots = ship_dots(&buf, &view, heading); + assert!((24..=36).contains(&dots.len()), "{heading}: {} dots", dots.len()); + let front = dots.iter().filter(|d| d.0 > 1.25); + assert!(front.clone().count() >= 2, "{heading}: a nose"); + assert!(front.clone().all(|d| d.1.abs() < 1.25), "{heading}: a narrow nose"); + let wing = |side: f64| dots.iter().any(|d| d.0 < -1.5 && d.1 * side > 1.25); + assert!(wing(1.0) && wing(-1.0), "{heading}: wings behind, either side"); + } + } + + #[test] + fn a_lit_engine_trails_a_flame_out_the_back() { + let view = View { area: Rect::new(0, 0, 24, 12), camera: Vec2::ZERO }; + for heading in [0.0, 2.0, 4.0] { + // The flame's dots, as how far ahead of the ship each one is. + let flame = |thrusting: bool, ticks: u64| { + let mut buf = Buffer::empty(view.area); + draw_ship(&mut buf, &view, &ship(heading, thrusting), ticks); + for cell in buf.content.iter_mut().filter(|c| c.fg == Color::White) { + cell.reset(); + } + ship_dots(&buf, &view, heading) + }; + assert!(flame(false, 0).is_empty(), "{heading}: no flame with the engine off"); + let lit = flame(true, 0); + assert!(!lit.is_empty(), "{heading}: a flame with it on"); + assert!(lit.iter().all(|d| d.0 < -1.0), "{heading}: only behind: {lit:?}"); + let sizes: Vec<usize> = (0..60).step_by(3).map(|t| flame(true, t).len()).collect(); + assert!(sizes.iter().min() < sizes.iter().max(), "{heading}: flickering"); + } + } + + #[test] + fn arrows_point_where_headings_do() { + use std::f64::consts::TAU; + assert_eq!(arrow(0.0), '↑'); + assert_eq!(arrow(TAU / 4.0), '→'); + assert_eq!(arrow(TAU - 0.1), '↑'); + assert_eq!(arrow(-TAU / 8.0), '↖'); + } + + #[test] + fn spheres_are_round_and_lit_on_the_sunward_side() { + let area = Rect::new(0, 0, 40, 20); + let mut buf = Buffer::empty(area); + let surface = Surface { + rgb: (200, 200, 200), + banded: false, + lights: false, + ramp: PLANET_RAMP, + seed: 1, + }; + assert!(sphere(&mut buf, area, (20.0, 10.0), 8.0, Vec2::new(1.0, 0.0), &surface)); + let row: String = (0..40).map(|c| buf[(c, 10)].symbol().chars().next().unwrap()).collect(); + let covered = row.chars().filter(|&c| c != ' ').count(); + assert!((15..=17).contains(&covered), "8 units across each way: {row:?}"); + let column: usize = (0..20).filter(|&r| buf[(20, r)].symbol() != " ").count(); + assert!((7..=9).contains(&column), "and half that many rows tall"); + assert_eq!(buf[(26, 10)].symbol(), "█", "bright to the east, where the sun is"); + assert_eq!(buf[(13, 10)].symbol(), "·", "dark to the west"); + } +} diff --git a/src/scenes/system_map.rs b/src/scenes/system_map.rs new file mode 100644 index 0000000..65c6011 --- /dev/null +++ b/src/scenes/system_map.rs @@ -0,0 +1,830 @@ +//! The system map: the star, every orbit, where everything is today, and a +//! list of places to travel to with how long each trip takes. +//! +//! Distances from the star are drawn on a square-root scale, so the inner +//! planets aren't crushed into the middle and the outer ones still fit. +//! Moons are too close to their planets to see at any scale that shows the +//! whole system, so they're drawn just off their planets, in the right +//! direction. +//! +//! Choosing a place shows where it will be when you get there: trips aim +//! for that, not for where it is now, and a survey of what it's like. +//! +//! Every place is marked with what you'll find if you land or dock there: +//! a capital city (⌂), a station that only trades (¤), or neither (·). +//! +//! `g` swaps this for the star map, to jump to another system. + +use std::f64::consts::TAU; + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::symbols::Marker; +use ratatui::text::{Line, Span}; +use ratatui::widgets::canvas::{Canvas, Context, Points}; +use ratatui::widgets::{Block, Paragraph}; + +use super::galaxy_map::GalaxyMap; +use super::keys; +use super::sky; +use super::{Scene, Transition}; +use crate::app::Ctx; +use crate::names::Culture; +use crate::space::Vec2; +use crate::space::orbit; +use crate::space::survey::{self, FREEZING, Survey, Temperature}; +use crate::space::system::{Landing, PlaceKind, Rgb, System, Wreck}; +use crate::town; + +const PANEL_WIDTH: u16 = 46; +/// Rows for what's known about the chosen place. +const ABOUT_HEIGHT: u16 = 20; +/// The labels down the left of the survey: "temp", "air". +const LABEL_WIDTH: usize = 7; +const ACCENT: Color = Color::Rgb(240, 200, 110); +const DIM: Color = Color::Rgb(110, 114, 132); +const ORBIT: Color = Color::Rgb(58, 62, 80); +const CAPITAL: Color = Color::Rgb(130, 214, 140); +const MARKET: Color = Color::Rgb(110, 196, 226); +const WRECK: Color = Color::Rgb(255, 90, 80); +/// A beacon that's failing, or will have failed by the time you get there. +pub(crate) const FAINT_WRECK: Color = Color::Rgb(150, 76, 70); +/// A beacon with this few days left is failing. +pub(crate) const FAILING: f64 = 5.0; +const SUIT_UP: Color = Color::Rgb(240, 150, 90); + +pub struct SystemMap { + selected: usize, +} + +impl SystemMap { + pub fn new(ctx: &Ctx) -> SystemMap { + SystemMap { selected: ctx.location } + } + + /// Days to get from where you are to `place`. + fn trip(ctx: &Ctx, place: usize) -> f64 { + let s = &ctx.system; + let from = s.position(ctx.location, ctx.day); + orbit::plan_trip(from, ctx.day, |d| s.position(place, d)) + } +} + +impl Scene for SystemMap { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + let n = ctx.system.places.len(); + match key.code { + KeyCode::Up | KeyCode::Char('k' | 'w') => self.selected = (self.selected + n - 1) % n, + KeyCode::Down | KeyCode::Char('j' | 's') => self.selected = (self.selected + 1) % n, + KeyCode::Enter | KeyCode::Char('t') => { + if self.selected != ctx.location { + let days = Self::trip(ctx, self.selected); + ctx.travel(self.selected, days); + } + return Transition::Pop; + } + KeyCode::Char('.') => ctx.day += 1.0, + KeyCode::Char('g') => return Transition::Replace(vec![Box::new(GalaxyMap::new(ctx))]), + KeyCode::Esc | KeyCode::Char('m' | 'q') => return Transition::Pop, + _ => {} + } + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let area = frame.area(); + let (map, panel) = if area.width >= 100 { + let [map, panel] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) + .areas(area); + (Some(map), panel) + } else { + (None, area) + }; + if let Some(map) = map { + self.draw_orrery(frame, map, ctx); + } + self.draw_panel(frame, panel, ctx); + } +} + +/// Positions in AU to canvas points, on a square-root scale out to `extent`. +struct Scale { + radius: f64, + extent: f64, +} + +impl Scale { + fn point(&self, p: Vec2) -> (f64, f64) { + let d = p.length(); + if d == 0.0 { + return (0.0, 0.0); + } + let r = self.radius * (d / self.extent).sqrt(); + (p.x / d * r, p.y / d * r) + } +} + +impl SystemMap { + fn draw_orrery(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { + let s = &ctx.system; + let day = ctx.day; + // One unit per braille dot: two across a cell, four down, and dots + // are about square. + let half_w = f64::from(area.width); + let half_h = 2.0 * f64::from(area.height); + let scale = Scale { radius: half_w.min(half_h) * 0.92, extent: s.extent() }; + + let trip = (self.selected != ctx.location).then(|| Self::trip(ctx, self.selected)); + let canvas = Canvas::default() + .marker(Marker::Braille) + .background_color(Color::Reset) + .x_bounds([-half_w, half_w]) + .y_bounds([-half_h, half_h]) + .paint(|c| { + draw_orbits(c, s, &scale); + c.layer(); + if let Some(days) = trip { + let from = scale.point(s.position(ctx.location, day)); + let to = scale.point(s.position(self.selected, day + days)); + let steps = ((to.0 - from.0).hypot(to.1 - from.1) / 3.0) as usize; + let dots: Vec<(f64, f64)> = (0..=steps) + .map(|i| { + let t = i as f64 / steps.max(1) as f64; + (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t) + }) + .collect(); + c.draw(&Points { coords: &dots, color: ACCENT }); + c.print(to.0, to.1, Span::raw("◌").fg(ACCENT)); + } + self.draw_bodies(c, ctx, &scale); + }); + frame.render_widget(canvas, area); + } + + fn draw_bodies(&self, c: &mut Context, ctx: &Ctx, scale: &Scale) { + let s = &ctx.system; + let day = ctx.day; + c.print(0.0, 0.0, Span::raw("☼").fg(sky::rgb(s.star.rgb)).bold()); + let mut labels = Vec::new(); + for (i, place) in s.places.iter().enumerate() { + let here = i == ctx.location; + let chosen = i == self.selected; + let (x, y) = match place.kind { + PlaceKind::Planet(p) => { + let (x, y) = scale.point(s.planets[p].orbit.position(day)); + let glyph = if here { "@" } else { "●" }; + let colour = if here { Color::White } else { sky::rgb(s.planets[p].kind.rgb) }; + c.print(x, y, Span::raw(glyph).fg(colour).bold()); + if let Landing::Capital(_) = s.landing(i) { + c.print(x + 2.0, y, Span::raw("⌂").fg(CAPITAL)); + } + (x, y) + } + PlaceKind::Moon(p, m) => { + let (px, py) = scale.point(s.planets[p].orbit.position(day)); + let moon = &s.planets[p].moons[m]; + let dir = moon.orbit.position(day).normalised(); + let r = 4.0 + 2.5 * m as f64; + let (x, y) = (px + dir.x * r, py + dir.y * r); + let colour = + if here || chosen { Color::White } else { sky::rgb(moon.kind.rgb) }; + if here { + c.print(x, y, Span::raw("@").fg(Color::White).bold()); + } else if let Landing::Capital(_) = s.landing(i) { + c.print( + x, + y, + Span::raw("⌂").fg(if chosen { Color::White } else { CAPITAL }), + ); + } else { + c.draw(&Points { coords: &[(x, y)], color: colour }); + } + (x, y) + } + PlaceKind::Belt => { + let (x, y) = scale.point(s.belt.region.position(day)); + if here { + c.print(x, y, Span::raw("@").fg(Color::White).bold()); + } else if chosen { + c.print(x, y, Span::raw("+").fg(ACCENT)); + } + (x, y) + } + PlaceKind::Station(k) => { + let (x, y) = scale.point(s.position(i, day)); + let (_, colour) = sky::station_art(s.stations[k].culture); + let glyph = if here { "@" } else { "#" }; + c.print( + x, + y, + Span::raw(glyph).fg(if here { Color::White } else { sky::rgb(colour) }), + ); + (x, y) + } + }; + if chosen || here { + let style = + if chosen { Style::new().fg(ACCENT) } else { Style::new().fg(Color::White) }; + labels.push((x + 4.0, y, Span::styled(format!(" {}", place.name), style))); + } + } + // Names last, so nothing nearby is drawn over them. + for (x, y, label) in labels { + c.print(x, y, label); + } + } + + fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { + let s = &ctx.system; + let block = Block::bordered() + .title(format!(" the {} system · day {:.1} ", s.name, ctx.day)) + .title_bottom( + Line::from(format!( + " {} of {} · fuel {}/{} ", + capitalise(s.kind.name()), + s.culture.name(), + ctx.hold.fuel, + ctx.hold.fuel_capacity + )) + .fg(DIM), + ) + .title_style(Style::new().fg(ACCENT).bold()) + .border_style(Style::new().fg(DIM)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let hints = keys::packed( + &[ + ("↑↓", "choose"), + ("enter", "travel"), + (".", "wait a day"), + ("g", "star map"), + ("esc", "back"), + ], + inner.width, + ); + let [list, about, keys] = Layout::vertical([ + Constraint::Min(3), + Constraint::Length(ABOUT_HEIGHT), + Constraint::Length(1 + hints.len() as u16), + ]) + .areas(inner); + + // Scroll to keep the choice in view. + let rows = usize::from(list.height); + let first = self + .selected + .saturating_sub(rows.saturating_sub(1) / 2) + .min(s.places.len().saturating_sub(rows)); + let wrecks = ctx.wrecks(); + let lines: Vec<Line> = s + .places + .iter() + .enumerate() + .skip(first) + .take(rows) + .map(|(i, place)| { + let indent = match place.kind { + PlaceKind::Moon(..) | PlaceKind::Station(_) => " ", + _ => "", + }; + let (mark, colour) = mark(s.landing(i)); + let days = if i == ctx.location { 0.0 } else { Self::trip(ctx, i) }; + let trip = if i == ctx.location { + "you're here".to_string() + } else { + format!("{days:.1} d") + }; + // Bright if there's a wreck with time to spare; faint if all + // that's there are beacons on their last legs, which wait + // only if you go straight there. + let here = wrecks.iter().filter(|w| w.place == i); + let worth = here.clone().any(|w| w.leaves - ctx.day > FAILING); + let beacon = match (here.count(), worth) { + (0, _) => Span::raw(" "), + (_, true) => Span::raw("!").fg(WRECK), + (_, false) => Span::raw("!").fg(FAINT_WRECK), + }; + let name_width = usize::from(list.width).saturating_sub(16 + indent.len()); + let name: String = place.name.chars().take(name_width).collect(); + let mut spans = vec![ + Span::raw(format!(" {mark} ")).fg(colour), + Span::raw(format!("{indent}{name:<name_width$}")), + beacon, + Span::raw(format!("{trip:>12}")).fg(DIM), + ]; + if i == self.selected { + for span in &mut spans { + span.style = span.style.add_modifier(Modifier::REVERSED); + } + } + Line::from(spans) + }) + .collect(); + frame.render_widget(Paragraph::new(lines), list); + + frame.render_widget( + // Wrapped already, so that wrapped lines stay indented. + Paragraph::new(self.about(ctx, usize::from(about.width))), + about, + ); + let legend = Line::from(vec![ + Span::raw("⌂").fg(CAPITAL), + Span::raw(" capital ").fg(DIM), + Span::raw("¤").fg(MARKET), + Span::raw(" trade only ").fg(DIM), + Span::raw("·").fg(DIM), + Span::raw(" neither ").fg(DIM), + Span::raw("!").fg(WRECK), + Span::raw(" wreck").fg(DIM), + ]); + let mut lines = vec![legend]; + lines.extend(hints); + frame.render_widget(Paragraph::new(lines), keys); + } + + /// What's known about the chosen place: what it is, what you'll find if + /// you land, the trip there, and a survey of what it's like. + fn about(&self, ctx: &Ctx, width: usize) -> Vec<Line<'static>> { + let s = &ctx.system; + let i = self.selected; + let place = &s.places[i]; + let who = |people: Option<Culture>, home: bool| match people { + Some(c) if home && s.kind != crate::space::system::SystemKind::Seat => { + format!("chief world of {}", c.name()) + } + Some(c) if home => format!("homeworld of {}", c.name()), + Some(c) => format!("settled by {}", c.name()), + None => "uninhabited".to_string(), + }; + let (what, orbit_line) = match place.kind { + PlaceKind::Planet(p) => { + let planet = &s.planets[p]; + ( + format!("{} · {}", planet.kind.name, who(planet.people, p == s.home)), + format!( + "{:.2} AU from the star; a year of {:.0} days", + planet.orbit.a, planet.orbit.period + ), + ) + } + PlaceKind::Moon(p, m) => { + let moon = &s.planets[p].moons[m]; + ( + format!("{} · {}", moon.kind.name, who(moon.people, false)), + format!( + "a moon of {}, going round every {:.1} days", + s.planets[p].name, moon.orbit.period + ), + ) + } + PlaceKind::Belt => ( + "an asteroid belt: good mining".to_string(), + format!("from {:.2} to {:.2} AU from the star", s.belt.inner, s.belt.outer), + ), + PlaceKind::Station(k) => { + let st = &s.stations[k]; + let point = if st.leading { "L4" } else { "L5" }; + ( + format!("a {} station", st.culture.adjective()), + format!( + "at {}'s {point} point, sixty degrees {} it", + s.planets[st.planet].name, + if st.leading { "ahead of" } else { "behind" } + ), + ) + } + }; + let prose = |text: String, style: Style| wrap(Vec::new(), &[(text, style)], width); + let mut lines = vec![Line::from(place.name.clone()).fg(ACCENT).bold()]; + lines.extend(prose(what, Style::new())); + lines.extend(landing_line(s, i, width)); + if i != ctx.location { + let days = Self::trip(ctx, i); + let now = s.position(ctx.location, ctx.day); + let there = s.position(i, ctx.day + days); + lines.extend(prose( + format!( + "{:.2} AU away by the time you arrive: {:.1} days, on day {:.1}", + now.distance(there), + days, + ctx.day + days + ), + Style::new(), + )); + } + let arrival = if i == ctx.location { ctx.day } else { ctx.day + Self::trip(ctx, i) }; + for w in ctx.wrecks().iter().filter(|w| w.place == i) { + let found = if w.calling(ctx.day) { + format!( + "A distress beacon: {}, a {}. {}", + w.name, + w.description, + beacon_age(w, ctx.day) + ) + } else { + format!( + "A wreck: {}, a {}. Its beacon failed on your way here, but you came by \ + its last fix.", + w.name, w.description + ) + }; + let mut phrases = vec![(found, Style::new().fg(Color::Rgb(255, 120, 100)))]; + if let Some(warning) = beacon_warning(w, ctx.day, arrival) { + phrases.push((format!(" {warning}"), Style::new().fg(FAINT_WRECK))); + } + lines.extend(wrap(Vec::new(), &phrases, width)); + } + lines.push(Line::default()); + let station = matches!(place.kind, PlaceKind::Station(_)); + let giant = match place.kind { + PlaceKind::Planet(p) => s.planets[p].kind.giant, + _ => false, + }; + lines.extend(facts(&survey::survey(s, i, arrival), station, giant, width)); + lines.push(Line::default()); + lines.extend(prose(orbit_line, Style::new().fg(DIM))); + lines + } +} + +/// How long a wreck's beacon has been calling: "Calling for 12 days." +pub(crate) fn beacon_age(w: &Wreck, day: f64) -> String { + match (day - w.arrived).floor() { + ..1.0 => "It started calling today.".into(), + 1.0 => "Calling since yesterday.".into(), + n => format!("Calling for {n:.0} days."), + } +} + +/// Whether a beacon is failing, or will fail before you `arrive`. A wreck +/// whose beacon has failed already is one you came by its last fix: there's +/// nothing to warn of. +pub(crate) fn beacon_warning(w: &Wreck, day: f64, arrive: f64) -> Option<String> { + let left = w.leaves - day; + if left <= 0.0 { + None + } else if w.leaves <= arrive { + Some( + "It'll fail on the way, but go straight there and you'll find it by its last fix." + .into(), + ) + } else if left <= FAILING { + Some(match left.floor() { + ..1.0 => "Failing: hours left.".into(), + 1.0 => "Failing: a day left, maybe two.".into(), + n => format!("Failing: {n:.0} days left, maybe less."), + }) + } else { + None + } +} + +/// How a place is marked in the list: what you'll find if you land. +fn mark(landing: Landing) -> (&'static str, Color) { + match landing { + Landing::Capital(_) => ("⌂", CAPITAL), + Landing::Market(_) => ("¤", MARKET), + Landing::Aloft(_) | Landing::Wilds | Landing::Nowhere => ("·", DIM), + } +} + +/// What you'll find if you land or dock at a place, in a sentence. +fn landing_line(s: &System, place: usize, width: usize) -> Vec<Line<'static>> { + let landing = s.landing(place); + let (mark, colour) = mark(landing); + let (tag, rest) = match landing { + Landing::Capital(c) => ( + "Capital", + format!( + ": {}. Land and walk in to its broker, doctor, scholar, tavern and shipyard, \ + which sells fuel.", + town::name(town::seed_for(s.seed, place), c) + ), + ), + Landing::Market(_) => { + ("Trade only", ": dock to sell ore and buy fuel. No city, no shops.".to_string()) + } + Landing::Aloft(c) => ( + "Neither", + format!(": {} live up in its clouds, but there's no ground to land on.", c.name()), + ), + Landing::Wilds => { + ("Neither", ": no one lives here to trade with. Land and look around.".to_string()) + } + Landing::Nowhere if s.places[place].kind == PlaceKind::Belt => { + ("Neither", ": nowhere to land, only rocks to mine.".to_string()) + } + Landing::Nowhere => { + ("Neither", ": no one lives here, and there's no ground to land on.".to_string()) + } + }; + let head = vec![Span::raw(format!("{mark} ")).fg(colour)]; + let phrases = [(format!("{tag}{rest}"), Style::new())]; + let mut lines = wrap(head, &phrases, width); + // Colour the tag, which is always the first word or two. + if let Some(first) = lines.first_mut() { + let words = tag.split(' ').count(); + for span in first.spans.iter_mut().skip(1).step_by(2).take(words) { + span.style = Style::new().fg(colour).bold(); + } + } + lines +} + +/// The survey, as labelled rows. +fn facts(survey: &Survey, station: bool, giant: bool, width: usize) -> Vec<Line<'static>> { + let plain = Style::new(); + let dim = Style::new().fg(DIM); + let mut lines = Vec::new(); + if let Some(t) = survey.temperature { + lines.push(thermometer(t)); + let range = (t.hottest - t.coldest >= 2.0).then(|| { + format!("{} to {} °C", survey::celsius(t.coldest), survey::celsius(t.hottest)) + }); + let detail = match (giant, range) { + (true, Some(range)) => Some(format!("{range} at the cloud tops")), + (true, None) => Some("at the cloud tops".to_string()), + (false, Some(range)) => Some(format!("{range} over its year")), + (false, None) => None, + }; + if let Some(detail) = detail { + lines.extend(fact("", &[(detail, dim)], width)); + } + } + if let Some(day) = &survey.day { + let mut text = day.clone(); + if let Some(seasons) = survey.seasons { + text = format!("{text}; {seasons}"); + } + lines.extend(fact(if station { "spin" } else { "day" }, &[(text, plain)], width)); + } + if let Some(g) = survey.gravity { + let amount = if g < 0.1 { format!("{g:.2} g") } else { format!("{g:.1} g") }; + let feel = match g { + g if g < 0.3 => "featherlight", + g if g < 0.75 => "light", + g if g < 1.25 => "", + g if g < 1.8 => "heavy", + _ => "crushing", + }; + let mut parts = vec![(amount, plain)]; + if !feel.is_empty() { + parts.push((format!("· {feel}"), dim)); + } + if station { + parts.push(("· from its spin".to_string(), dim)); + } + lines.extend(fact("pull", &parts, width)); + } + let suit_up = ("suit up".to_string(), Style::new().fg(SUIT_UP)); + let air = match survey.air { + Some((what, true)) => { + vec![ + (what.to_string(), plain), + ("·".into(), dim), + ("breathable".into(), Style::new().fg(CAPITAL)), + ] + } + Some((what, false)) => vec![(what.to_string(), plain), ("·".into(), dim), suit_up], + None => vec![("none".to_string(), plain), ("·".into(), dim), suit_up], + }; + lines.extend(fact("air", &air, width)); + lines.extend(fact("sky", &[(survey.sky.clone(), plain)], width)); + if let Some(n) = survey.population { + let mut text = headcount(n); + if giant { + text.push_str(", in cloud cities"); + } + lines.extend(fact(if station { "aboard" } else { "people" }, &[(text, plain)], width)); + } + lines +} + +/// A labelled row of the survey. +fn fact(label: &str, phrases: &[(String, Style)], width: usize) -> Vec<Line<'static>> { + let head = vec![Span::raw(format!("{label:<LABEL_WIDTH$}")).fg(DIM)]; + wrap(head, phrases, width) +} + +/// Phrases joined by spaces, each in its own style, after `head` and +/// word-wrapped to `width`, with the overflow indented to line up under the +/// first line's text. Each word is its own span, after the head. +pub(crate) fn wrap( + head: Vec<Span<'static>>, + phrases: &[(String, Style)], + width: usize, +) -> Vec<Line<'static>> { + let indent: usize = head.iter().map(|s| s.content.chars().count()).sum(); + let room = width.saturating_sub(indent).max(10); + let mut rows: Vec<Vec<Span<'static>>> = vec![Vec::new()]; + let mut used = 0; + for (text, style) in phrases { + for word in text.split_whitespace() { + let w = word.chars().count(); + if used > 0 && used + 1 + w > room { + rows.push(Vec::new()); + used = 0; + } + let row = rows.last_mut().unwrap(); + if used > 0 { + row.push(Span::raw(" ")); + used += 1; + } + row.push(Span::styled(word.to_string(), *style)); + used += w; + } + } + let mut head = Some(head); + rows.into_iter() + .map(|spans| { + let mut line = head.take().unwrap_or_else(|| vec![Span::raw(" ".repeat(indent))]); + line.extend(spans); + Line::from(line) + }) + .collect() +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) +} + +/// Cells in the thermometer. +const THERMOMETER: usize = 14; +/// The thermometer's ends, in kelvin. It's marked on a log scale, so the +/// temperate middle isn't squeezed. +const COLDEST: f64 = 50.0; +const HOTTEST: f64 = 1500.0; + +/// A thermometer from deep cold to molten, filled up to the temperature +/// now and lit more faintly over the rest of its year's swing; then the +/// temperature and a word for it. +fn thermometer(t: Temperature) -> Line<'static> { + let cell = |kelvin: f64| { + let k = (kelvin / COLDEST).ln() / (HOTTEST / COLDEST).ln(); + (k.clamp(0.0, 1.0) * (THERMOMETER - 1) as f64).round() as usize + }; + let (low, high, now) = (cell(t.coldest), cell(t.hottest), cell(t.now)); + let mut spans = vec![Span::raw(format!("{:<LABEL_WIDTH$}", "temp")).fg(DIM)]; + for i in 0..THERMOMETER { + let kelvin = COLDEST * (HOTTEST / COLDEST).powf(i as f64 / (THERMOMETER - 1) as f64); + let colour = heat(kelvin); + spans.push(if i == now { + Span::raw("●").fg(Color::White).bold() + } else if i < now { + Span::raw("━").fg(sky::rgb(colour)) + } else if (low..=high).contains(&i) { + Span::raw("━").fg(sky::shade(colour, 0.6)) + } else { + Span::raw("─").fg(sky::shade(colour, 0.35)) + }); + } + let colour = sky::rgb(heat(t.now)); + spans.push(Span::raw(format!(" {} °C ", survey::celsius(t.now))).fg(colour).bold()); + spans.push(Span::raw(t.word()).fg(colour)); + Line::from(spans) +} + +/// The colour of a temperature: blue through ice-white and green to red. +fn heat(kelvin: f64) -> Rgb { + const STOPS: [(f64, Rgb); 7] = [ + (50.0, (96, 112, 236)), + (150.0, (120, 178, 255)), + (240.0, (176, 226, 244)), + (FREEZING + 15.0, (128, 218, 138)), + (320.0, (240, 210, 96)), + (450.0, (246, 142, 64)), + (900.0, (236, 72, 52)), + ]; + let k = kelvin.clamp(STOPS[0].0, STOPS[STOPS.len() - 1].0); + let i = STOPS.iter().rposition(|&(at, _)| at <= k).unwrap().min(STOPS.len() - 2); + let ((a, ca), (b, cb)) = (STOPS[i], STOPS[i + 1]); + let f = ((k / a).ln() / (b / a).ln()).clamp(0.0, 1.0); + let mix = |x: u8, y: u8| (f64::from(x) + (f64::from(y) - f64::from(x)) * f).round() as u8; + (mix(ca.0, cb.0), mix(ca.1, cb.1), mix(ca.2, cb.2)) +} + +/// "2.1 billion", "48 million", "12,000". +fn headcount(n: u64) -> String { + let short = |x: f64, unit: &str| { + let text = if x < 10.0 { format!("{x:.1}") } else { format!("{x:.0}") }; + format!("{} {unit}", text.trim_end_matches(".0")) + }; + match n { + n if n >= 1_000_000_000 => short(n as f64 / 1e9, "billion"), + n if n >= 1_000_000 => short(n as f64 / 1e6, "million"), + n => { + let digits = n.to_string(); + let mut out = String::new(); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out + } + } +} + +fn draw_orbits(c: &mut Context, s: &System, scale: &Scale) { + for planet in &s.planets { + let points: Vec<(f64, f64)> = planet.orbit.outline(240).map(|p| scale.point(p)).collect(); + c.draw(&Points { coords: &points, color: ORBIT }); + } + // The belt: a scatter of dots, the same every time. + let belt: Vec<(f64, f64)> = (0..700) + .map(|i| { + let angle = sky::hash01(s.seed, i, 1) * TAU; + let r = s.belt.inner + (s.belt.outer - s.belt.inner) * sky::hash01(s.seed, i, 2); + scale.point(Vec2::new(r * angle.cos(), r * angle.sin())) + }) + .collect(); + c.draw(&Points { coords: &belt, color: Color::Rgb(104, 96, 86) }); +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::scenes::tests::test_ctx; + + fn press(map: &mut SystemMap, ctx: &mut Ctx, code: KeyCode) -> Transition { + map.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + #[test] + fn travelling_takes_days_and_moves_you() { + let mut ctx = test_ctx(); + let mut map = SystemMap::new(&ctx); + let start = ctx.location; + press(&mut map, &mut ctx, KeyCode::Down); + let destination = map.selected; + let days = SystemMap::trip(&ctx, destination); + assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::Pop)); + assert_eq!(ctx.location, destination); + assert_ne!(ctx.location, start); + assert!((ctx.day - days).abs() < 1e-9 && days > 0.0); + assert_eq!(ctx.departed, Some(0.0)); + } + + #[test] + fn a_beacon_that_fails_on_the_way_still_leads_you_there() { + let ctx = test_ctx(); + let w = ctx.system.wrecks_on(0.0)[0].clone(); + let warning = beacon_warning(&w, 0.0, w.leaves + 1.0).unwrap(); + assert!(warning.contains("last fix"), "{warning}"); + assert_eq!(beacon_warning(&w, 0.0, 0.5), None, "plenty of time"); + let failed = w.leaves + 1.0; + assert_eq!(beacon_warning(&w, failed, failed), None, "you're there already"); + } + + #[test] + fn choosing_where_you_are_goes_nowhere() { + let mut ctx = test_ctx(); + let mut map = SystemMap::new(&ctx); + press(&mut map, &mut ctx, KeyCode::Enter); + assert_eq!(ctx.day, 0.0); + press(&mut map, &mut ctx, KeyCode::Char('.')); + assert_eq!(ctx.day, 1.0, "but you can wait"); + } + + #[test] + fn numbers_read_naturally() { + assert_eq!(headcount(2_100_000_000), "2.1 billion"); + assert_eq!(headcount(3_000_000_000), "3 billion"); + assert_eq!(headcount(48_000_000), "48 million"); + assert_eq!(headcount(12_000), "12,000"); + assert_eq!(headcount(870), "870"); + } + + #[test] + fn the_map_says_where_the_capitals_are() { + let ctx = test_ctx(); + let mut map = SystemMap::new(&ctx); + map.selected = ctx.system.home_place(); + let text: String = map + .about(&ctx, 44) + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.to_string())) + .collect(); + assert!(text.contains("⌂ Capital: "), "{text}"); + assert!(text.contains("°C"), "{text}"); + let belt = ctx.system.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap(); + assert_eq!(mark(ctx.system.landing(belt)).0, "·"); + } + + #[test] + fn the_list_wraps_round() { + let mut ctx = test_ctx(); + let mut map = SystemMap::new(&ctx); + map.selected = 0; + press(&mut map, &mut ctx, KeyCode::Up); + assert_eq!(map.selected, ctx.system.places.len() - 1); + } +} diff --git a/src/scenes/title.rs b/src/scenes/title.rs index 7c75888..898452e 100644 --- a/src/scenes/title.rs +++ b/src/scenes/title.rs @@ -1,4 +1,6 @@ -//! Title screen: a turning orrery over a drifting starfield. +//! Title screen: a turning orrery over a drifting starfield, and the way in: +//! carry on as the character you last played, make a new one, or load +//! another. //! //! Every universe seed gets its own orrery, and everything here moves as a //! function of `ctx.ticks`, so it doubles as a check that the tick loop runs. @@ -16,10 +18,15 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::widgets::canvas::{Canvas, Points}; +use super::character::{Create, Sheet}; use super::derelict::Derelict; +use super::flight; use super::forge::Forge; +use super::keys::ACCENT; +use super::saves::Saves; use super::{Scene, Transition}; use crate::app::{Ctx, TICKS_PER_SECOND}; +use crate::save; use crate::seed; const LOGO: [&str; 4] = [ @@ -64,8 +71,13 @@ struct Planet { } pub struct Title { + /// Whose orrery this is. Loading or making a character can change + /// universe under it. + universe: u64, stars: Vec<Star>, planets: Vec<Planet>, + /// Whether there are saved characters to load. + saved: bool, } impl Title { @@ -92,7 +104,8 @@ impl Title { } }) .collect(); - Title { stars, planets } + let saved = ctx.saves.as_deref().is_some_and(|dir| !save::list(dir).is_empty()); + Title { universe: ctx.seed, stars, planets, saved } } fn draw_stars(&self, buf: &mut Buffer, area: Rect, seconds: f64) { @@ -143,16 +156,43 @@ impl Title { } } +/// The menu: key, what it does. +fn menu(ctx: &Ctx, saved: bool) -> Vec<(&'static str, String)> { + let mut items = Vec::new(); + if ctx.slot.is_some() { + let c = &ctx.character; + items.push(("enter", format!("carry on as {}, of {}", c.name, c.ship_name()))); + items.push(("c", "your character".into())); + } + items.push(("n", "a new character".into())); + if saved { + items.push(("l", "load a character".into())); + } + if ctx.slot.is_some() { + items.push(("d", "board a derelict, straight away".into())); + } + items.push(("g", "the name forge".into())); + items.push(("q", "leave".into())); + items +} + impl Scene for Title { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + let playing = ctx.slot.is_some(); match key.code { + KeyCode::Enter if playing => Transition::Open(flight::resume(ctx)), + KeyCode::Enter => Transition::Push(Box::new(Create::new(ctx))), KeyCode::Char(c) => match c.to_ascii_lowercase() { - 'd' => { + 'f' if playing => Transition::Open(flight::resume(ctx)), + 'c' if playing => Transition::Push(Box::new(Sheet::new())), + 'n' => Transition::Push(Box::new(Create::new(ctx))), + 'l' if self.saved => Transition::Push(Box::new(Saves::new(ctx))), + 'd' if playing => { ctx.boarded += 1; let ship = seed::derive(ctx.seed, "derelict", ctx.boarded); - Transition::Push(Box::new(Derelict::new(ship))) + Transition::Push(Box::new(Derelict::board(ship, ctx))) } - 'n' => Transition::Push(Box::new(Forge::new(ctx))), + 'g' => Transition::Push(Box::new(Forge::new(ctx))), 'q' => Transition::Quit, _ => Transition::None, }, @@ -161,17 +201,26 @@ impl Scene for Title { } } + fn on_top(&mut self, ctx: &Ctx) { + if ctx.seed != self.universe { + *self = Title::new(ctx); + } else { + self.saved = ctx.saves.as_deref().is_some_and(|dir| !save::list(dir).is_empty()); + } + } + fn render(&self, frame: &mut Frame, ctx: &Ctx) { let seconds = ctx.ticks as f64 / f64::from(TICKS_PER_SECOND); let area = frame.area(); self.draw_stars(frame.buffer_mut(), area, seconds); + let items = menu(ctx, self.saved); let [sky, logo, _, menu, hold, footer] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(LOGO.len() as u16), Constraint::Length(1), - Constraint::Length(4), - Constraint::Length(1), + Constraint::Length(items.len() as u16), + Constraint::Length(2), Constraint::Length(1), ]) .areas(area); @@ -181,44 +230,95 @@ impl Scene for Title { let logo_lines: Vec<Line> = LOGO.iter().map(|l| Line::from(*l).bold()).collect(); frame.render_widget(Paragraph::new(logo_lines).centered().fg(Color::Yellow), logo); - let item = |key: &'static str, label: &'static str, built: bool| { - let style = if built { Style::new() } else { Style::new().fg(Color::DarkGray) }; - let mut spans = vec![Span::styled(key, style.bold()), Span::styled(label, style)]; - if !built { - spans.push(Span::styled(" (not yet built)", style.italic())); - } - Line::from(spans) - }; - let [menu] = Layout::horizontal([Constraint::Length(46)]).flex(Flex::Center).areas(menu); - frame.render_widget( - Paragraph::new(vec![ - item("[d] ", "board a derelict", true), - item("[n] ", "the name forge", true), - item("[f] ", "fly a star system", false), - item("[q] ", "leave", true), - ]), - menu, - ); - - let h = &ctx.hold; - if !h.artifacts.is_empty() || h.scrip > 0 { - let plural = if h.artifacts.len() == 1 { "" } else { "s" }; + // Keys in a column, in the colour key hints are everywhere. + let keys = items.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0) + 2; + let width = items.iter().map(|(_, l)| keys + l.chars().count()).max(); + let [menu] = Layout::horizontal([Constraint::Length(width.unwrap_or(0) as u16)]) + .flex(Flex::Center) + .areas(menu); + let lines: Vec<Line> = items + .into_iter() + .map(|(key, label)| { + Line::from(vec![ + Span::raw(format!("{key:<keys$}")).fg(ACCENT).bold(), + Span::raw(label), + ]) + }) + .collect(); + frame.render_widget(Paragraph::new(lines), menu); + + if ctx.slot.is_some() { + let h = &ctx.hold; + let plural = if h.artifacts() == 1 { "" } else { "s" }; + let place = match &ctx.crawl { + Some(c) => format!("aboard {}", c.name), + None => format!("at {}", ctx.system.places[ctx.location].name), + }; let text = format!( - "your hold: {} artifact{plural}, {} ¤ of salvage", - h.artifacts.len(), + "day {:.1} {place} in {} · {} artifact{plural}, {} t of ore, {} ¤", + ctx.day, + ctx.system.name, + h.artifacts(), + h.ore_tons(), h.scrip ); + let [_, hold] = Layout::vertical([Constraint::Length(1); 2]).areas(hold); frame.render_widget(Paragraph::new(text).centered().fg(Color::Yellow), hold); } - let keys = if ctx.keyboard.key_release { - "held keys detectable (kitty keyboard protocol)" - } else { - "held keys not detectable: flight will need a throttle" + let footer_text = match &ctx.save_error { + Some(problem) => Paragraph::new(problem.as_str()).centered().light_red(), + None => { + let keys = if ctx.keyboard.key_release { + "held keys detectable (kitty keyboard protocol)" + } else { + "held keys not detectable: flight will need a throttle" + }; + Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray() + } + }; + frame.render_widget(footer_text, footer); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::scenes::tests::test_ctx; + + fn press(title: &mut Title, ctx: &mut Ctx, code: KeyCode) -> Transition { + title.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + #[test] + fn without_a_character_you_make_one() { + let mut ctx = test_ctx(); + let mut title = Title::new(&ctx); + let keys: Vec<&str> = menu(&ctx, false).iter().map(|&(key, _)| key).collect(); + assert_eq!(keys, ["n", "g", "q"]); + assert!(matches!(press(&mut title, &mut ctx, KeyCode::Enter), Transition::Push(_))); + assert!(matches!(press(&mut title, &mut ctx, KeyCode::Char('d')), Transition::None)); + assert!(matches!(press(&mut title, &mut ctx, KeyCode::Char('l')), Transition::None)); + } + + #[test] + fn with_one_you_carry_on_where_they_were() { + let mut ctx = test_ctx(); + ctx.slot = Some("someone.json".into()); + let mut title = Title::new(&ctx); + let c = &ctx.character; + let carry_on = format!("carry on as {}, of {}", c.name, c.ship_name()); + assert_eq!(menu(&ctx, true)[0], ("enter", carry_on)); + let Transition::Open(scenes) = press(&mut title, &mut ctx, KeyCode::Enter) else { + panic!("enter carries on"); }; - frame.render_widget( - Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray(), - footer, - ); + assert_eq!(scenes.len(), 1, "flying, as they weren't landed"); + + // Whoever comes back to the title may be from another universe. + ctx.begin(99, ctx.character.clone()); + title.on_top(&ctx); + assert_eq!(title.universe, 99); } } diff --git a/src/scenes/town.rs b/src/scenes/town.rs new file mode 100644 index 0000000..abf4e05 --- /dev/null +++ b/src/scenes/town.rs @@ -0,0 +1,2509 @@ +//! Walking around a capital city, going into its buildings, and trading in +//! its shops. The city and the insides of its buildings are generated in +//! `town`; this moves you about, lets people wander and talk, and runs the +//! shops' counters. +//! +//! Out in the streets you see the city from high up: roofs, with signs on +//! the shops. Bump a door and the building's inside takes over the screen, +//! at its own, closer scale. `m` shows the whole city at once. +//! +//! People who wander step aside as you walk through them; `t` talks to +//! someone beside you. People who stay put (keepers, stallholders, guards, +//! anyone sitting down) are in the way, and bumping them talks to them, or +//! opens their shop. +//! +//! `i` opens the inventory anywhere. What you put down stays where it is +//! until you leave the city. + +use std::collections::{HashMap, HashSet}; + +use rand::RngExt; +use rand::seq::IndexedRandom; +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}; + +use super::character::Sheet; +use super::derelict::camera; +use super::inventory::{self, Beside, Command, Counter, Inventory, Reply, View}; +use super::keys::{self, ACCENT, DIM}; +use super::pause::{Choice, Pause}; +use super::sky::rgb; +use super::{Scene, Transition}; +use crate::app::{Ctx, Hold, Kept}; +use crate::dungeon::Pos; +use crate::dungeon::armour::Armor; +use crate::dungeon::arms::Weapon; +use crate::items::{self, Item, Stack}; +use crate::names::Culture; +use crate::seed::{self, Rng}; +use crate::space::survey; +use crate::space::system::{PlaceKind, Rgb, Terrain}; +use crate::town::{ + self, Interior, Kind, Land, Person, Pond, Role, Shop, ShopKind, Site, Thing, Tile, Town, words, +}; +use crate::trade; + +const PANEL_WIDTH: u16 = 32; +const LOG_HEIGHT: u16 = 6; +/// On average, how many ticks pass between one person's steps. +const WANDER_TICKS: u32 = 45; +/// The most steps one run takes. +const RUN_LIMIT: usize = 80; + +const SHIP_ART: [&str; 3] = [" /^\\ ", "<|o|>", " v v "]; +const SIGN: Rgb = (255, 226, 150); + +/// How a city looks: how its culture builds, a glyph and colour for each +/// kind of paving and wall, and the world it stands on. +pub(crate) struct Theme { + world: World, + street: (char, Rgb), + square: (char, Rgb), + wall: (char, Rgb), + city_wall: (char, Rgb), + /// Roofs come in a few colours, so neighbours stand apart. + roofs: [Rgb; 3], + floor: (char, Rgb), + rug: Rgb, + bed: Rgb, + centrepiece: (char, Rgb), + /// The pipes or scrap a culture leaves lying about, if it does. + decor: Option<(char, Rgb)>, + stall: (char, Rgb), + /// The altar, and the temple's mark on the city map. + altar: (char, Rgb), + folk: Rgb, +} + +/// How the ground looks, and what grows and stands on it. +struct World { + ground: (char, Rgb), + /// Yards, courtyards and parks. + garden: (char, Rgb), + tree: (char, Rgb), + /// Outside the walls: the two sorts of `Tile::Wild`. + wild: [(char, Rgb); 2], + pond: Pond, + /// The sea, and its lighter crests. + sea: [Rgb; 2], + /// Whether the second sort of wild thing burns: a forge world's vents. + vents: bool, +} + +const FROST: Rgb = (226, 236, 246); +const STRAW: Rgb = (182, 152, 96); + +/// `a` mixed with `k` of `b`. +fn mix((r, g, b): Rgb, (r2, g2, b2): Rgb, k: f64) -> Rgb { + let f = |a: u8, b: u8| (f64::from(a) + (f64::from(b) - f64::from(a)) * k).round() as u8; + (f(r, r2), f(g, g2), f(b, b2)) +} + +/// How a city's world looks: its kind of ground, frosted or parched by the +/// weather today, and tinted a little its own way. +fn world(t: &Town) -> World { + let land = t.land; + let mut w = match land.terrain { + Terrain::Desert => World { + ground: ('·', (204, 170, 112)), + garden: (',', (166, 156, 92)), + tree: ('♣', (128, 162, 78)), + wild: [('ψ', (118, 156, 86)), ('●', (170, 128, 88))], + pond: Pond::Water, + sea: [(0, 0, 0); 2], + vents: false, + }, + Terrain::Ice => World { + ground: ('·', (214, 228, 242)), + garden: ('"', (150, 204, 214)), + tree: ('♠', (130, 196, 222)), + wild: [('▲', (196, 226, 248)), ('●', (140, 150, 168))], + pond: Pond::Ice, + sea: [(0, 0, 0); 2], + vents: false, + }, + Terrain::Ocean => World { + ground: ('·', (104, 100, 112)), + garden: ('"', (86, 168, 116)), + tree: ('♣', (60, 150, 100)), + wild: [('●', (82, 80, 92)), ('§', (96, 136, 84))], + pond: Pond::Water, + sea: [(40, 92, 168), (110, 170, 220)], + vents: false, + }, + Terrain::Jungle => World { + ground: ('"', (58, 120, 62)), + garden: ('"', (74, 160, 72)), + tree: ('♣', (44, 140, 56)), + wild: [('♣', (38, 112, 48)), ('♠', (60, 132, 72))], + pond: Pond::Water, + sea: [(0, 0, 0); 2], + vents: false, + }, + Terrain::Barren | Terrain::Clouds => World { + ground: ('·', (134, 128, 122)), + garden: (';', (176, 102, 70)), + tree: ('♦', (156, 124, 214)), + wild: [('▲', (142, 134, 126)), ('♦', (112, 98, 152))], + pond: Pond::Dry, + sea: [(0, 0, 0); 2], + vents: false, + }, + Terrain::Forge => World { + ground: ('·', (92, 78, 76)), + garden: (',', (164, 118, 64)), + tree: ('τ', (206, 128, 92)), + wild: [('▲', (84, 74, 74)), ('^', (255, 130, 50))], + pond: Pond::Lava, + sea: [(0, 0, 0); 2], + vents: true, + }, + }; + w.pond = land.pond(); + let tint = t.tint(); + // How much frost and drought show on each: living things wither, the + // sea only scums with ice. + let paint = |c: Rgb, (frost, parch): (f64, f64)| { + let c = mix(c, FROST, frost * land.frost); + let c = mix(c, STRAW, parch * land.drought); + let k = |v: u8, i: usize| (f64::from(v) * tint[i]).round().clamp(0.0, 255.0) as u8; + (k(c.0, 0), k(c.1, 1), k(c.2, 2)) + }; + let (living, lying) = ((0.7, 0.6), (0.7, 0.15)); + let ground = if land.terrain == Terrain::Jungle { living } else { lying }; + w.ground.1 = paint(w.ground.1, ground); + w.garden.1 = paint(w.garden.1, living); + w.tree.1 = paint(w.tree.1, living); + for wild in &mut w.wild { + wild.1 = paint(wild.1, ground); + } + w.sea = w.sea.map(|c| paint(c, (0.3, 0.0))); + w +} + +pub(crate) fn theme(t: &Town) -> Theme { + let world = world(t); + match t.culture { + Culture::Qaari => Theme { + world, + street: ('.', (170, 140, 96)), + square: (':', (196, 166, 116)), + wall: ('#', (214, 176, 124)), + city_wall: ('█', (176, 136, 88)), + roofs: [(170, 128, 82), (206, 190, 160), (190, 120, 70)], + floor: ('.', (130, 110, 88)), + rug: (170, 70, 60), + bed: (200, 150, 90), + centrepiece: ('≈', (96, 164, 226)), + decor: None, + stall: ('▀', (220, 120, 80)), + altar: ('O', (96, 164, 226)), + folk: (226, 196, 150), + }, + Culture::Cant => Theme { + world, + street: ('·', (132, 138, 152)), + square: ('+', (150, 152, 164)), + wall: ('#', (176, 112, 90)), + city_wall: ('▓', (120, 90, 80)), + roofs: [(120, 88, 76), (100, 104, 116), (140, 96, 64)], + floor: ('.', (120, 120, 132)), + rug: (120, 60, 50), + bed: (150, 150, 160), + centrepiece: ('Ω', (255, 140, 60)), + decor: Some(('‡', (150, 152, 172))), + stall: ('▀', (170, 120, 200)), + altar: ('Ω', (255, 140, 60)), + folk: (200, 160, 140), + }, + Culture::Houses => Theme { + world, + street: ('·', (200, 196, 210)), + square: (':', (222, 218, 232)), + wall: ('#', (226, 222, 236)), + city_wall: ('█', (190, 186, 202)), + roofs: [(160, 96, 126), (176, 90, 70), (90, 110, 140)], + floor: ('.', (184, 172, 160)), + rug: (150, 50, 80), + bed: (190, 60, 90), + centrepiece: ('≈', (110, 170, 240)), + decor: None, + stall: ('▀', (200, 120, 200)), + altar: ('†', (240, 210, 120)), + folk: (220, 200, 240), + }, + Culture::Drift => Theme { + world, + street: ('-', (110, 150, 150)), + square: (':', (130, 190, 184)), + wall: ('=', (124, 212, 200)), + city_wall: ('▓', (80, 120, 130)), + roofs: [(60, 110, 120), (120, 120, 110), (170, 110, 70)], + floor: ('.', (120, 140, 140)), + rug: (60, 120, 150), + bed: (90, 170, 170), + centrepiece: ('¥', (230, 230, 160)), + decor: Some(('%', (150, 120, 90))), + stall: ('▀', (230, 150, 90)), + altar: ('¥', (230, 230, 160)), + folk: (160, 220, 214), + }, + } +} + +/// How a culture's shopkeepers greet you. +fn salutation(culture: Culture) -> &'static str { + match culture { + Culture::Qaari => "Water and shade, traveller.", + Culture::Cant => "Be welcome, unit. State your need.", + Culture::Houses => "Your grace honours my house.", + Culture::Drift => "Hey, spacer.", + } +} + +fn pitch(kind: ShopKind) -> &'static str { + match kind { + ShopKind::Broker => "I buy ore by the ton. Show me what you've dug up.", + ShopKind::Doctor => "Medpatches, fresh. They'll keep you breathing aboard those wrecks.", + ShopKind::Scholar => { + "Bring me the strange things you find. I'll tell you what they are, \ + and buy what's worth buying." + } + ShopKind::Tavern => "Buy a round and the room will talk. Rooms always talk.", + ShopKind::Shipyard => { + "Fuel for the jump, racks for more ore, a finer lens for your laser. \ + Or a bigger tank, if you mean to go far." + } + ShopKind::Armoury => { + "Arms and armour, bought, sold and mended. Show me what you've found, or try the rack." + } + } +} + +fn shelf(kind: ShopKind) -> (char, Rgb) { + match kind { + ShopKind::Broker => ('%', (170, 140, 110)), + ShopKind::Doctor => ('!', (240, 96, 96)), + ShopKind::Scholar => ('≡', (206, 186, 140)), + ShopKind::Tavern => ('o', (170, 116, 72)), + ShopKind::Shipyard => ('¤', (150, 172, 196)), + ShopKind::Armoury => (')', (186, 190, 204)), + } +} + +/// What a door says when it won't open. +fn barred(kind: Kind) -> &'static str { + match kind { + Kind::Manor => "A servant looks out through a grille at you, and shuts it again.", + Kind::Warehouse => "Locked, and chained for good measure.", + Kind::Tenement => "The street door's locked. A face at a window watches you.", + Kind::Workshop => "A slate on the door: closed for a commission.", + _ => "The door is barred. Someone's home, but not for you.", + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Deal { + SellOre, + /// Sell the salvage kept here, all of it. + SellSalvage(Kept), + SellAllSalvage, + BuyMedpatch, + /// Have the artifact kept here identified. + Identify(Kept), + SellArtifact(Kept), + BuyRound, + BuyPack, + BuyRacks, + BuyLens, + BuyFuel, + BuyTank, +} + +struct Offer { + text: String, + deal: Deal, +} + +struct Trading { + shop: usize, + choice: usize, + /// What the keeper said to your last request. + reply: Option<String>, +} + +pub struct TownScene { + town: Town, + /// The building you're in, if you're indoors. + inside: Option<Interior>, + place: usize, + /// "Qeth", the world the town is on. + world: String, + player: Pos, + /// The way you last stepped, which is who `t` talks to first. + facing: (i32, i32), + log: Vec<String>, + /// How many things have been said, ever: the log itself stops growing. + said: usize, + trading: Option<Trading>, + /// Looking at the whole city. + map: bool, + /// Buildings you've been into. + visited: HashSet<usize>, + rumours: Vec<String>, + heard: usize, + rng: Rng, + /// Open over the city when you're looking through what you carry. + inventory: Option<Inventory>, + /// The armourer whose rack is beside you in the inventory, when you're + /// trading with one. + shopping: Option<usize>, + /// What you've put down, and where: in the streets, or in a building. + dropped: Vec<(Spot, Stack)>, + /// The menu `esc` opens. + pause: Option<Pause>, +} + +/// Somewhere in the city: the building it's in, if it's indoors, and where +/// in it. +type Spot = (Option<usize>, Pos); + +impl TownScene { + /// Walks out of your ship onto the landing field outside the capital of + /// `place`, a planet or moon where people live. + pub fn new(ctx: &Ctx, place: usize) -> TownScene { + let s = &ctx.system; + let (world, people, description) = match s.places[place].kind { + PlaceKind::Planet(p) => { + let planet = &s.planets[p]; + (&planet.name, planet.people, &planet.description) + } + PlaceKind::Moon(p, m) => { + let moon = &s.planets[p].moons[m]; + (&moon.name, moon.people, &moon.description) + } + PlaceKind::Belt | PlaceKind::Station(_) => (&s.places[place].name, None, &Vec::new()), + }; + let culture = people.unwrap_or(s.culture); + let size = town::size_for(survey::survey(s, place, ctx.day).population); + let land = Land::of(s, place, ctx.day).unwrap_or(Land::new(Terrain::Barren, None)); + let town = town::generate(town::seed_for(s.seed, place), culture, size, land); + let mut log = vec![format!( + "You walk down the ramp onto the landing field outside {}, the capital of {world}.", + town.name + )]; + log.extend(description.iter().cloned()); + log.push("The gate is east. Bump a door to go in; m shows the whole city.".into()); + TownScene { + player: town.start, + facing: (1, 0), + place, + world: world.clone(), + said: log.len(), + log, + trading: None, + map: false, + visited: HashSet::new(), + rumours: trade::rumours( + s, + place, + ctx.day, + &ctx.stripped, + ctx.ticks, + trade::lane_rumours(&ctx.galaxy, ctx.star), + ), + heard: 0, + rng: seed::rng(seed::derive(s.seed, "townsfolk", ctx.ticks)), + inside: None, + town, + inventory: None, + shopping: None, + dropped: Vec::new(), + pause: None, + } + } + + /// Where you're standing. + fn here(&self) -> Spot { + (self.inside.as_ref().map(|i| i.building), self.player) + } + + fn floor(&self) -> Beside { + Beside::Floor(if self.inside.is_some() { "on the floor" } else { "on the ground" }) + } + + /// `g`: picks up what's where you stand, or lists it if there's more + /// than one thing. + fn pick_up(&mut self, ctx: &mut Ctx) { + match &inventory::lying(&self.dropped, self.here())[..] { + [] => self.say("There's nothing here to pick up.".into()), + [stack] => { + let text = self.carry_out(inventory::Command::Fetch(0, stack.count), ctx); + self.say(text); + } + _ => self.inventory = Some(Inventory::beside()), + } + } + + fn carry_out(&mut self, command: inventory::Command, ctx: &mut Ctx) -> String { + let (here, beside) = (self.here(), self.floor()); + let mut floor = inventory::gather(&mut self.dropped, here); + let text = + inventory::carry_out(command, &mut ctx.hold.gear, None, &mut floor, Some(beside)); + inventory::scatter(&mut self.dropped, here, floor); + text + } + + /// Steps up to a shop's counter. An armourer's rack opens beside what + /// you carry, to buy from and sell to; every other shop has a counter. + fn open_shop(&mut self, shop: usize) { + let s = &self.town.shops[shop]; + if s.kind == ShopKind::Armoury { + let mut inv = Inventory::beside(); + inv.note = Some(format!( + "{}: “{} {}”", + s.keeper, + salutation(self.town.culture), + pitch(s.kind) + )); + self.inventory = Some(inv); + self.shopping = Some(shop); + } else { + self.trading = Some(Trading { shop, choice: 0, reply: None }); + } + } + + /// What's on this city's armourer's rack this fortnight, as you left + /// it: the weapons, then the armour. + fn rack(&self, ctx: &Ctx) -> Vec<Stack> { + let fortnight = trade::fortnight(ctx.day); + let kept = ctx.racks.iter().find(|r| { + r.system == ctx.system.seed && r.place == self.place && r.fortnight == fortnight + }); + let (weapons, armour) = match kept { + Some(r) => (r.weapons.clone(), r.armour.clone()), + None => ( + trade::stock(&ctx.system, self.place, self.town.culture, fortnight), + trade::stock_armour(&ctx.system, self.place, self.town.culture, fortnight), + ), + }; + let weapons = weapons.into_iter().map(|w| Stack::one(Item::Weapon(w))); + weapons.chain(armour.into_iter().map(|a| Stack::one(Item::Armor(a)))).collect() + } + + fn inventory_key(&mut self, key: KeyEvent, ctx: &mut Ctx) { + let floor = inventory::lying(&self.dropped, self.here()); + let rack = if self.shopping.is_some() { self.rack(ctx) } else { Vec::new() }; + let view = match self.shopping { + Some(_) => shop_view(ctx, self.place, &rack), + None => view(ctx, self.floor(), &floor), + }; + let Some(inv) = &mut self.inventory else { return }; + match inv.key(key, &view) { + Reply::Stay => {} + Reply::Close => { + self.inventory = None; + self.shopping = None; + } + Reply::Sort(sort) => ctx.inventory_sort = sort, + Reply::Do(command) => { + let text = match command { + Command::Buy(i) => self.buy(i, ctx), + Command::Sell(spot) => self.sell(Some(spot), None, ctx), + Command::SellAboard(i) => self.sell(None, Some(i), ctx), + Command::Mend(spot) => self.mend(Some(spot), None, ctx), + Command::MendAboard(i) => self.mend(None, Some(i), ctx), + Command::MendAll => self.mend(None, None, ctx), + _ => self.carry_out(command, ctx), + }; + if let Some(inv) = &mut self.inventory { + inv.note = Some(text.clone()); + } + self.say(text); + } + } + } + + /// Buys the `i`th thing on the armourer's rack (as `rack` lists it), if + /// you can pay. + fn buy(&mut self, i: usize, ctx: &mut Ctx) -> String { + let terms = trade::armoury_terms(&ctx.system, self.place); + let rack = trade::rack(&mut ctx.racks, &ctx.system, self.place, self.town.culture, ctx.day); + let armour = i.checked_sub(rack.weapons.len()); + let item = match armour { + None => Item::Weapon(rack.weapons[i].clone()), + Some(j) => match rack.armour.get(j) { + Some(a) => Item::Armor(a.clone()), + None => return "That's gone already.".into(), + }, + }; + let price = terms.asking(&item).unwrap_or(0); + let h = &mut ctx.hold; + if h.scrip < price { + return format!("That's {price} ¤, and you've {} ¤. Come back richer.", h.scrip); + } + h.scrip -= price; + match armour { + None => drop(rack.weapons.remove(i)), + Some(j) => drop(rack.armour.remove(j)), + } + let said = match &item { + Item::Weapon(w) if w.kit => format!("You buy back {} for {price} ¤.", w.name()), + Item::Weapon(w) => format!("{} is yours, for {price} ¤.", capitalise(&the(w))), + Item::Armor(a) => format!("{} {} yours, for {price} ¤.", capitalise(&a.the()), a.is()), + _ => String::new(), + }; + if h.deliver(Stack::one(item)) { + said + } else { + format!("{said} Your pack's full, so it'll be sent to your ship.") + } + } + + /// Sells a weapon or a piece of armour of yours to the armourer: one + /// you carry or wear (`carried`) or one in your ship's lockers + /// (`aboard`). It goes on the rack, to be bought back until the rack's + /// restocked. + fn sell( + &mut self, + carried: Option<items::Spot>, + aboard: Option<usize>, + ctx: &mut Ctx, + ) -> String { + let terms = trade::armoury_terms(&ctx.system, self.place); + let h = &mut ctx.hold; + let dealt = |s: Option<Stack>| s.filter(|s| terms.offer(&s.item).is_some()); + let stack = match (carried, aboard) { + (Some(spot), _) => { + if dealt(h.gear.get(spot)).is_none() { + return "I only deal in arms and armour.".into(); + } + match h.gear.remove(spot, 1) { + Ok(stack) => stack, + Err(why) => return why, + } + } + (None, Some(i)) => { + if dealt(h.lockers.get(i).cloned()).is_none() { + return "You've nothing like that aboard.".into(); + } + h.take_kept(Kept::Lockers(i), 1) + } + (None, None) => return String::new(), + }; + let price = terms.offer(&stack.item).unwrap_or(0); + let what = match &stack.item { + Item::Weapon(w) => the(w), + Item::Armor(a) => a.the(), + _ => return String::new(), + }; + let paid = h.earn(price); + let said = + format!("{price} ¤ for {what}. It'll find a new owner.{}", trade::debt_note(paid, h)); + let rack = trade::rack(&mut ctx.racks, &ctx.system, self.place, self.town.culture, ctx.day); + match stack.item { + Item::Weapon(w) => rack.weapons.push(w), + Item::Armor(a) => rack.armour.push(a), + _ => {} + } + said + } + + /// Has the armourer mend a piece of your armour whole: one you carry or + /// wear (`carried`), one in your ship's lockers (`aboard`), or with + /// neither, every piece that needs it, all at once. + fn mend( + &mut self, + carried: Option<items::Spot>, + aboard: Option<usize>, + ctx: &mut Ctx, + ) -> String { + let terms = trade::armoury_terms(&ctx.system, self.place); + let h = &mut ctx.hold; + let mut pieces: Vec<&mut Armor> = match (carried, aboard) { + (Some(items::Spot::Worn(slot)), _) => h.gear.worn.get_mut(&slot).into_iter().collect(), + (Some(items::Spot::Pack(i)), _) => { + armour_in(h.gear.pack.get_mut(i)).into_iter().collect() + } + (Some(_), _) => Vec::new(), + (None, Some(i)) => armour_in(h.lockers.get_mut(i)).into_iter().collect(), + (None, None) => { + let aboard = h.lockers.iter_mut().filter_map(|s| armour_in(Some(s))); + h.gear.armour_mut().chain(aboard).filter(|a| terms.mending(a) > 0).collect() + } + }; + let all = carried.is_none() && aboard.is_none(); + let cost: u32 = pieces.iter().map(|a| terms.mending(a)).sum(); + match pieces.as_slice() { + [] if all => return "Nothing of yours needs mending.".into(), + [] => return "That's not armour.".into(), + [a] if cost == 0 => { + return format!("{} {} whole already.", capitalise(&a.yours()), a.is()); + } + _ => {} + } + if h.scrip < cost { + let what = if pieces.len() > 1 { format!("all {}", pieces.len()) } else { "it".into() }; + return format!("That's {cost} ¤ to mend {what}, and you've {} ¤.", h.scrip); + } + h.scrip -= cost; + for a in &mut pieces { + a.repair(); + } + match pieces.as_slice() { + [a] => format!("{} {} whole again, for {cost} ¤.", capitalise(&a.yours()), a.is()), + _ => format!("All {} pieces are whole again, for {cost} ¤.", pieces.len()), + } + } + + fn say(&mut self, text: String) { + self.log.push(text); + self.said += 1; + if self.log.len() > 50 { + self.log.remove(0); + } + } + + /// Where you are: the streets, or the inside of a building. + fn site(&self) -> &Site { + self.inside.as_ref().map_or(&self.town.site, |i| &i.site) + } + + fn site_mut(&mut self) -> &mut Site { + match &mut self.inside { + Some(inside) => &mut inside.site, + None => &mut self.town.site, + } + } + + /// One step, and what's lying where you end up. + fn step(&mut self, dx: i32, dy: i32) -> Transition { + let before = self.here(); + let transition = self.stride(dx, dy); + if self.here() != before + && let Some(text) = inventory::underfoot(&inventory::lying(&self.dropped, self.here())) + { + self.say(text); + } + transition + } + + fn stride(&mut self, dx: i32, dy: i32) -> Transition { + self.facing = (dx, dy); + let from = self.player; + let to = from.offset(dx, dy); + if let Some(who) = self.site().person_at(to) { + let person = &self.site().people[who]; + if let Role::Keeper(shop) = person.role { + self.open_shop(shop); + return Transition::None; + } + if person.stays { + let text = self.talk(person); + self.say(text); + return Transition::None; + } + // Someone walking about: they step aside for you, onto where + // you were, if they can. + if self.site().tile(to).walkable() { + self.player = to; + self.site_mut().make_way(who, from); + } + return Transition::None; + } + if self.inside.is_some() { self.step_inside(to) } else { self.step_outside(to) } + } + + fn step_outside(&mut self, to: Pos) -> Transition { + let t = &self.town; + if !t.site.contains(to) { + if t.tile(self.player) == Tile::Street { + self.say(words::beyond(t.land.terrain).into()); + } + return Transition::None; + } + match t.tile(to) { + Tile::Ship => return Transition::Pop, + Tile::Door { open } => { + let Some(b) = t.door_at(to) else { return Transition::None }; + if open { + self.go_in(b); + } else { + let text = barred(t.buildings[b].kind); + self.say(text.into()); + } + } + tile if tile.walkable() => self.player = to, + tile => { + let text = match tile { + Tile::Wild => Some(words::wild_about(t.land.terrain, t.wild_sort(to))), + _ => words::tile_about(tile, t.culture, t.land), + }; + if let Some(text) = text { + self.say(text.into()); + } + } + } + Transition::None + } + + fn step_inside(&mut self, to: Pos) -> Transition { + let Some(inside) = &self.inside else { return Transition::None }; + if let Some(shop) = inside.shop_at(to) { + self.open_shop(shop); + return Transition::None; + } + match inside.site.tile(to) { + Tile::Exit => self.go_out(), + tile if tile.walkable() => self.player = to, + Tile::Furniture(thing) => { + self.say(words::thing_about(thing, inside.culture).into()); + } + Tile::Shelf => self.say("The keeper's wares, out of reach behind the counter.".into()), + _ => {} + } + Transition::None + } + + fn go_in(&mut self, b: usize) { + let inside = self.town.enter(b); + self.player = inside.start; + self.visited.insert(b); + let building = &self.town.buildings[b]; + let mut text = format!("You step into {}.", building.name); + let culture = self.town.culture; + let named = |role: Role| { + inside.site.people.iter().find(|p| p.role == role).and_then(|p| p.name.clone()) + }; + match building.kind { + Kind::Shop(ShopKind::Tavern) => { + let keeper = &self.town.shops[building.shop.unwrap_or(0)].keeper; + text += &format!(" It's warm and loud. {keeper} is behind the bar."); + } + Kind::Shop(_) => { + let keeper = &self.town.shops[building.shop.unwrap_or(0)].keeper; + text += &format!(" {keeper} is behind the counter."); + } + Kind::Palace => { + if let Some(name) = named(Role::Ruler) { + let title = words::ruler_title(culture); + text += &format!(" At the far end, {name}, {title}, sits on the throne."); + } + } + Kind::Temple | Kind::Shrine => text += " It is hushed, and smells of old smoke.", + Kind::House | Kind::Tenement | Kind::Manor => { + let home = inside.site.people.len(); + text += match home { + 0 => " Nobody's home.", + 1 => " Someone looks up as you come in.", + _ => " The household looks up as you come in.", + }; + } + _ => {} + } + self.inside = Some(inside); + self.say(text); + } + + fn go_out(&mut self) { + let Some(inside) = self.inside.take() else { return }; + let b = &self.town.buildings[inside.building]; + self.player = b.front(); + self.facing = b.facing.step(); + // Someone may have wandered onto the doorstep. + if let Some(who) = self.town.site.person_at(self.player) { + let away = self.player.offset(self.facing.0, self.facing.1); + self.town.site.make_way(who, away); + } + let quarter = self.town.quarter_at(self.player).map_or("the street", |q| q.name.as_str()); + let text = format!("You step back out into {quarter}."); + self.say(text); + } + + /// What `person` says, with who they are. + fn talk(&self, person: &Person) -> String { + let culture = self.town.culture; + match (&person.name, person.role) { + (_, Role::Stallholder) => format!("A stallholder calls out: “{}”", person.says), + (Some(name), Role::Ruler) => { + format!("{name}, {}: “{}”", words::ruler_title(culture), person.says) + } + (Some(name), _) => format!("{name}: “{}”", person.says), + (None, role) => format!("{}: “{}”", words::role_name(role, culture), person.says), + } + } + + /// Talks to someone beside you: whoever you're facing, or else anyone. + fn talk_nearby(&mut self) { + let site = self.site(); + let ahead = self.player.offset(self.facing.0, self.facing.1); + let who = site + .person_at(ahead) + .or_else(|| self.player.neighbours().find_map(|p| site.person_at(p))); + let Some(who) = who else { + self.say("There's nobody close enough to talk to.".into()); + return; + }; + let person = &self.site().people[who]; + let (role, pos) = (person.role, person.pos); + if let Role::Keeper(shop) = role { + self.open_shop(shop); + return; + } + let directions = + if self.inside.is_none() && role == Role::Passerby && self.rng.random_bool(0.4) { + self.directions(pos) + } else { + None + }; + let text = directions.unwrap_or_else(|| self.talk(&self.site().people[who])); + self.say(text); + } + + /// A passer-by tells you the way to a shop, one you haven't been into + /// if there is one. + fn directions(&mut self, from: Pos) -> Option<String> { + let t = &self.town; + let unseen: Vec<ShopKind> = ShopKind::ALL + .into_iter() + .filter(|&k| { + t.nearest_shop(k, from).is_some_and(|s| !self.visited.contains(&s.building)) + }) + .collect(); + let kind = *unseen.choose(&mut self.rng).or_else(|| ShopKind::ALL.choose(&mut self.rng))?; + let shop = t.nearest_shop(kind, from)?; + let to = t.buildings[shop.building].front(); + let (dx, dy) = (to.x - from.x, to.y - from.y); + let way = compass(dx, dy); + let here = t.quarter_at(from).map(|q| q.name.as_str()); + let there = t.quarter_at(to).map_or("the city", |q| q.name.as_str()); + let far = f64::from(town::distance2(to, from)).sqrt(); + let text = if far < 14.0 { + format!("A passer-by: “{}? It's just {way} of here.”", capitalise(&shop.name)) + } else if here == Some(there) { + format!( + "A passer-by: “Looking for {}? Try {}, {way} of here.”", + kind.trade(), + shop.name + ) + } else { + format!( + "A passer-by: “Looking for {}? Try {}, {way} of here, in {there}.”", + kind.trade(), + shop.name + ) + }; + Some(text) + } + + /// Steps until something changes: a turning, a door, someone in the + /// way, something said. + fn run(&mut self, dx: i32, dy: i32) -> Transition { + let mut around = self.around(dx, dy); + for _ in 0..RUN_LIMIT { + let (before, indoors, said) = (self.player, self.inside.is_some(), self.said); + let transition = self.step(dx, dy); + if !matches!(transition, Transition::None) { + return transition; + } + let stopped = self.player == before + || self.inside.is_some() != indoors + || self.trading.is_some() + || self.said != said; + let now = self.around(dx, dy); + if stopped || now != around { + break; + } + around = now; + } + Transition::None + } + + /// Which cells beside you are open, as you run: one and two to either + /// side for a straight run, all eight round you for a diagonal one. + fn around(&self, dx: i32, dy: i32) -> u8 { + let site = self.site(); + let open = |p: Pos| site.tile(p).walkable() || matches!(site.tile(p), Tile::Door { .. }); + let cells: Vec<Pos> = if dx != 0 && dy != 0 { + self.player.neighbours().collect() + } else { + let (sx, sy) = (-dy, dx); + [1, 2] + .into_iter() + .flat_map(|k| { + [self.player.offset(sx * k, sy * k), self.player.offset(-sx * k, -sy * k)] + }) + .collect() + }; + cells.into_iter().enumerate().fold(0, |bits, (i, p)| bits | (u8::from(open(p)) << i)) + } + + /// Esc: out to the street if you're indoors, or back to your ship. + fn leave(&mut self) -> Transition { + if self.inside.is_some() { + self.go_out(); + Transition::None + } else { + Transition::Pop + } + } + + fn offers(&self, shop: &Shop, ctx: &Ctx) -> Vec<Offer> { + let s = &ctx.system; + let h = &ctx.hold; + let mut offers = Vec::new(); + let mut offer = |text: String, deal| offers.push(Offer { text, deal }); + match shop.kind { + ShopKind::Broker => { + let tons = h.ore_tons(); + if tons > 0 { + let prices = trade::ore_prices(s, self.place); + let worth = ore_worth(h, &prices); + offer(format!("Sell all your ore, {tons} t, for {worth} ¤"), Deal::SellOre); + } + let salvage = trade::salvage_kept(h, s, self.place); + for &(at, worth) in &salvage { + let Some(stack) = h.kept().find(|&(k, _)| k == at).map(|(_, s)| s) else { + continue; + }; + let text = format!("Sell {}: {worth} ¤{}", stack.name(), at.note()); + offer(text, Deal::SellSalvage(at)); + } + if salvage.len() > 1 { + let worth: u32 = salvage.iter().map(|(_, w)| w).sum(); + offer(format!("Sell all your salvage for {worth} ¤"), Deal::SellAllSalvage); + } + } + ShopKind::Doctor => { + let price = trade::medpatch_price(s, self.place); + offer(format!("Buy a medpatch: {price} ¤"), Deal::BuyMedpatch); + } + ShopKind::Scholar => { + let fee = trade::identify_fee(s, self.place); + for (at, stack) in h.kept() { + let Item::Artifact(a) = &stack.item else { continue }; + let note = at.note(); + if a.identified { + let pay = trade::artifact_offer(s, self.place, a); + offer( + format!("Sell {}: {pay} ¤{note}", a.true_name), + Deal::SellArtifact(at), + ); + } else { + offer( + format!("Have {} studied: {fee} ¤{note}", a.unidentified), + Deal::Identify(at), + ); + } + } + } + ShopKind::Tavern => { + offer(format!("Buy a round, and listen: {} ¤", trade::ROUND_PRICE), Deal::BuyRound); + } + // The armourer's rack opens in the inventory instead (`open_shop`). + ShopKind::Armoury => {} + ShopKind::Shipyard => { + let room = h.fuel_capacity.saturating_sub(h.fuel); + if room > 0 { + let price = trade::fuel_price(s, self.place); + let s = if room == 1 { "" } else { "s" }; + offer( + format!("Fill your tank, {room} jump{s} of fuel: {} ¤", room * price), + Deal::BuyFuel, + ); + } + if let Some(cost) = trade::rack_cost(h) { + let more = h.ore_capacity + trade::RACK_TONS; + offer( + format!("Cargo racks, to hold {more} t of ore: {cost} ¤"), + Deal::BuyRacks, + ); + } + if let Some(cost) = trade::lens_cost(ctx.lenses) { + offer(format!("A finer laser lens, to cut faster: {cost} ¤"), Deal::BuyLens); + } + if let Some(cost) = trade::tank_cost(h) { + let more = h.fuel_capacity + trade::TANK_JUMPS; + offer(format!("An extra fuel tank, for {more} jumps: {cost} ¤"), Deal::BuyTank); + } + if let Some((name, more, cost)) = items::next_pack(h.gear.capacity) { + let kg = items::kg(h.gear.capacity + more); + offer(format!("A {name}, to carry {kg} kg: {cost} ¤"), Deal::BuyPack); + } + } + } + offers + } + + /// Does the deal if you can, and says how it went. + fn deal(&mut self, deal: Deal, ctx: &mut Ctx) -> String { + let s = &ctx.system; + let h = &mut ctx.hold; + let place = self.place; + let pay = |h: &mut Hold, cost: u32| { + let ok = h.scrip >= cost; + if ok { + h.scrip -= cost; + } + ok + }; + let broke = || "You can't afford that.".to_string(); + match deal { + Deal::SellOre => { + let debt = h.debt; + match trade::sell_ore(h, &trade::ore_prices(s, place)) { + Some((tons, earned)) => format!( + "Sold {tons} t of ore for {earned} ¤. A pleasure.{}", + trade::debt_note(debt - h.debt, h) + ), + None => "You've no ore to sell.".into(), + } + } + Deal::SellSalvage(at) => { + let debt = h.debt; + match trade::sell_salvage(h, s, place, at) { + Some((what, earned)) => format!( + "{earned} ¤ for {what}. Good scrap.{}", + trade::debt_note(debt - h.debt, h) + ), + None => "You've no salvage to sell.".into(), + } + } + Deal::SellAllSalvage => { + let debt = h.debt; + match trade::sell_all_salvage(h, s, place) { + Some((pieces, earned)) => format!( + "{earned} ¤ for the lot, {pieces} pieces. Good scrap.{}", + trade::debt_note(debt - h.debt, h) + ), + None => "You've no salvage to sell.".into(), + } + } + Deal::BuyMedpatch => { + if h.medpatches() >= trade::MEDPATCH_LIMIT { + format!( + "You've {} already. I won't sell you more than {}.", + h.medpatches(), + trade::MEDPATCH_LIMIT + ) + } else if pay(h, trade::medpatch_price(s, place)) { + if h.deliver(Stack::one(Item::Medpatch)) { + format!("Here. Don't waste it. (You have {}.)", h.medpatches()) + } else { + "Here. Your pack's full, so I'll have it sent to your ship.".into() + } + } else { + broke() + } + } + Deal::Identify(at) => { + if !matches!(h.kept_mut(at), Some(Stack { item: Item::Artifact(_), .. })) { + "You've nothing like that to show me.".into() + } else if pay(h, trade::identify_fee(s, place)) { + let Some(Stack { item: Item::Artifact(a), .. }) = h.kept_mut(at) else { + return String::new(); + }; + a.identified = true; + let offer = trade::artifact_offer(s, place, a); + format!( + "Ah. Your {} is {}. I'd give you {offer} ¤ for it.", + a.unidentified.trim_start_matches("a ").trim_start_matches("an "), + a.true_name + ) + } else { + broke() + } + } + Deal::SellArtifact(at) => { + if !matches!(h.kept_mut(at), Some(Stack { item: Item::Artifact(_), .. })) { + return "You've nothing like that to sell me.".into(); + } + let Item::Artifact(a) = h.take_kept(at, 1).item else { return String::new() }; + let offer = trade::artifact_offer(s, place, &a); + let paid = h.earn(offer); + format!( + "{offer} ¤, as promised. {} will be well kept.{}", + capitalise(&a.true_name), + trade::debt_note(paid, h) + ) + } + Deal::BuyPack => match items::next_pack(h.gear.capacity) { + Some((name, more, cost)) if pay(h, cost) => { + h.gear.capacity += more; + format!( + "One {name}, fitted to you. You can carry {} kg now.", + items::kg(h.gear.capacity) + ) + } + Some(_) => broke(), + None => "That's the biggest pack there is. Any more and you'd need a mule.".into(), + }, + Deal::BuyRound => { + if pay(h, trade::ROUND_PRICE) { + let rumour = &self.rumours[self.heard % self.rumours.len()]; + self.heard += 1; + format!("The room warms to you. Someone leans over: “{rumour}”") + } else { + broke() + } + } + Deal::BuyRacks => match trade::rack_cost(h) { + Some(cost) if pay(h, cost) => { + h.ore_capacity += trade::RACK_TONS; + format!("Racks fitted. Your hold takes {} t of ore now.", h.ore_capacity) + } + Some(_) => broke(), + None => "Your hold's as big as it gets.".into(), + }, + Deal::BuyFuel => match trade::buy_fuel(h, trade::fuel_price(s, place)) { + (0, _) if h.fuel >= h.fuel_capacity => "Your tank's full already.".into(), + (0, _) => broke(), + (jumps, cost) => format!( + "{jumps} jump{} of fuel for {cost} ¤, pumped aboard. Your tank holds {}/{}.", + if jumps == 1 { "" } else { "s" }, + h.fuel, + h.fuel_capacity + ), + }, + Deal::BuyTank => match trade::tank_cost(h) { + Some(cost) if pay(h, cost) => { + h.fuel_capacity += trade::TANK_JUMPS; + format!( + "The tank's welded in. You can carry {} jumps of fuel now; it's empty, mind.", + h.fuel_capacity + ) + } + Some(_) => broke(), + None => "There's no room aboard for another tank.".into(), + }, + Deal::BuyLens => match trade::lens_cost(ctx.lenses) { + Some(cost) if pay(h, cost) => { + ctx.lenses += 1; + "The new lens is ground and fitted. Your laser will cut faster.".into() + } + Some(_) => broke(), + None => "That's the finest lens there is.".into(), + }, + } + } + + fn trading_key(&mut self, key: KeyEvent, ctx: &mut Ctx) { + let Some(trading) = &self.trading else { return }; + let shop = &self.town.shops[trading.shop]; + let offers = self.offers(shop, ctx); + let choice = trading.choice.min(offers.len().saturating_sub(1)); + let take = match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.trading = None; + return; + } + KeyCode::Up | KeyCode::Char('k') => { + self.set_choice(choice.saturating_sub(1)); + None + } + KeyCode::Down | KeyCode::Char('j') => { + self.set_choice((choice + 1).min(offers.len().saturating_sub(1))); + None + } + KeyCode::Enter | KeyCode::Char(' ') => Some(choice), + KeyCode::Char(c @ '1'..='9') => Some(c as usize - '1' as usize), + _ => None, + }; + if let Some(i) = take + && let Some(offer) = offers.get(i) + { + let reply = self.deal(offer.deal, ctx); + if let Some(trading) = &mut self.trading { + trading.reply = Some(reply); + trading.choice = i; + } + } + } + + fn set_choice(&mut self, choice: usize) { + if let Some(trading) = &mut self.trading { + trading.choice = choice; + } + } +} + +/// "north-east", for something `dx` across and `dy` down from you. +fn compass(dx: i32, dy: i32) -> &'static str { + const WAYS: [&str; 8] = + ["east", "north-east", "north", "north-west", "west", "south-west", "south", "south-east"]; + // Rows are twice as tall as columns are wide. + let angle = f64::from(-2 * dy).atan2(f64::from(dx)); + let sector = (angle / std::f64::consts::FRAC_PI_4).round() as i32; + WAYS[sector.rem_euclid(8) as usize] +} + +fn ore_worth(hold: &Hold, prices: &[(&'static crate::space::locale::Ore, u32)]) -> u32 { + prices.iter().map(|(ore, price)| hold.ore.get(ore.name).copied().unwrap_or(0) * price).sum() +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) +} + +impl Scene for TownScene { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + if self.trading.is_some() { + self.trading_key(key, ctx); + return Transition::None; + } + if self.inventory.is_some() { + self.inventory_key(key, ctx); + return Transition::None; + } + if let Some(pause) = &mut self.pause { + let Some(choice) = pause.key(key) else { return Transition::None }; + self.pause = None; + return match choice { + Choice::Quit => Transition::Title, + Choice::Here => self.leave(), + Choice::Resume => Transition::None, + }; + } + if self.map { + if matches!(key.code, KeyCode::Esc | KeyCode::Tab | KeyCode::Char('m' | 'M' | 'q')) { + self.map = false; + } + return Transition::None; + } + match key.code { + KeyCode::Char('i') => { + self.inventory = Some(Inventory::new()); + return Transition::None; + } + KeyCode::Char('g' | ',') => { + self.pick_up(ctx); + return Transition::None; + } + KeyCode::Char('c' | 'C') => return Transition::Push(Box::new(Sheet::new())), + KeyCode::Char('m' | 'M') | KeyCode::Tab => { + self.map = true; + return Transition::None; + } + KeyCode::Char('t' | 'T') => { + self.talk_nearby(); + return Transition::None; + } + KeyCode::Esc => { + let here = if self.inside.is_some() { + "Back out to the street" + } else { + "Back to your ship" + }; + let note = + format!("In {}. Quit, and you'll come back aboard your ship.", self.town.name); + self.pause = Some(Pause::new(Some(here), Some(note))); + return Transition::None; + } + _ => {} + } + match keys::movement(key) { + Some((dx, dy, true)) => self.run(dx, dy), + Some((dx, dy, false)) => self.step(dx, dy), + None => Transition::None, + } + } + + fn tick(&mut self, _ctx: &mut Ctx) -> Transition { + let player = self.player; + let site = match &mut self.inside { + Some(inside) => &mut inside.site, + None => &mut self.town.site, + }; + for who in 0..site.people.len() { + if self.rng.random_range(0..WANDER_TICKS) == 0 { + site.wander(who, &mut self.rng, player); + } + } + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let [main, panel] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) + .areas(frame.area()); + let [map, log] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(LOG_HEIGHT)]).areas(main); + self.draw_map(frame.buffer_mut(), map, ctx.ticks); + self.draw_log(frame, log); + self.draw_panel(frame, panel, ctx); + if self.map { + self.draw_city(frame, map); + } + if let Some(trading) = &self.trading { + self.draw_counter(frame, map, trading, ctx); + } + if let Some(inv) = &self.inventory { + let floor = inventory::lying(&self.dropped, self.here()); + let rack = if self.shopping.is_some() { self.rack(ctx) } else { Vec::new() }; + let view = match self.shopping { + Some(_) => shop_view(ctx, self.place, &rack), + None => view(ctx, self.floor(), &floor), + }; + inv.render(frame, frame.area(), &view); + } + if let Some(pause) = &self.pause { + pause.render(frame, frame.area()); + } + } +} + +/// What the inventory's shown: what you carry, and what's at your feet. +fn view<'a>(ctx: &'a Ctx, beside: Beside, floor: &'a [Stack]) -> View<'a> { + View { + gear: &ctx.hold.gear, + beside: (!floor.is_empty()).then_some((beside, floor)), + known: &ctx.bestiary, + hurt: false, + shop: None, + sort: ctx.inventory_sort, + } +} + +/// What the inventory's shown at an armourer's: what you carry and what's +/// in your lockers, beside the `rack`, with the prices at `place`. +fn shop_view<'a>(ctx: &'a Ctx, place: usize, rack: &'a [Stack]) -> View<'a> { + View { + gear: &ctx.hold.gear, + beside: Some((Beside::Rack, rack)), + known: &ctx.bestiary, + hurt: false, + shop: Some(Counter { + terms: trade::armoury_terms(&ctx.system, place), + aboard: &ctx.hold.lockers, + scrip: ctx.hold.scrip, + }), + sort: ctx.inventory_sort, + } +} + +/// The armour in a stack, if that's what it is. +fn armour_in(stack: Option<&mut Stack>) -> Option<&mut Armor> { + match stack { + Some(Stack { item: Item::Armor(a), .. }) => Some(a), + _ => None, + } +} + +/// "the keen cleaver", "Khaseem's Mercy", "your cutting torch". +fn the(w: &Weapon) -> String { + if w.named || w.kit { w.name() } else { format!("the {}", w.title) } +} + +/// How someone looks. +fn person_look(role: Role, theme: &Theme) -> (char, Rgb, bool) { + match role { + Role::Keeper(_) => ('&', (255, 214, 92), true), + Role::Ruler => ('&', (255, 236, 130), true), + Role::Stallholder => ('&', (255, 214, 140), false), + Role::Guard => ('&', (214, 100, 90), false), + Role::Priest => ('&', (226, 226, 255), false), + Role::Child => ('&', (250, 240, 200), false), + _ => ('&', theme.folk, false), + } +} + +/// A fire's colour, flickering cell by cell. +fn flicker(p: Pos, ticks: u64) -> Rgb { + const FLAMES: [Rgb; 3] = [(255, 150, 60), (255, 110, 40), (255, 190, 90)]; + let k = (ticks / 5) as i64 + i64::from(p.x) * 7 + i64::from(p.y) * 13; + FLAMES[k.rem_euclid(3) as usize] +} + +pub(crate) fn thing_look(thing: Thing, theme: &Theme, p: Pos, ticks: u64) -> (char, Rgb) { + const WOOD: Rgb = (156, 114, 72); + const DARK_WOOD: Rgb = (124, 88, 56); + const IRON: Rgb = (150, 156, 172); + const STONE: Rgb = (180, 176, 170); + match thing { + Thing::Table => ('╤', WOOD), + Thing::Chair => ('╥', DARK_WOOD), + Thing::Bed => ('Θ', theme.bed), + Thing::Hearth | Thing::Forge => ('^', flicker(p, ticks)), + Thing::Stove => ('◘', IRON), + Thing::Shelf => ('≡', (176, 150, 110)), + Thing::Chest => ('▪', (190, 146, 70)), + Thing::Crate => ('■', (170, 140, 96)), + Thing::Barrel => ('ö', (150, 104, 64)), + Thing::Altar => theme.altar, + Thing::Pew | Thing::Bench => ('═', WOOD), + Thing::Pillar => ('●', STONE), + Thing::Statue => ('ß', STONE), + Thing::Throne => ('Ψ', (240, 200, 90)), + Thing::Plant => ('♣', (90, 170, 80)), + Thing::Bath => { + let shimmer = (ticks / 9) as i64 + i64::from(p.x) * 3 + i64::from(p.y) * 5; + ('≈', if shimmer.rem_euclid(4) == 0 { (150, 210, 240) } else { (90, 160, 220) }) + } + Thing::Anvil => ('π', IRON), + Thing::Loom => ('╫', WOOD), + Thing::Workbench => ('╦', WOOD), + Thing::Rack => ('/', IRON), + Thing::Lamp => ('¡', flicker(p, ticks)), + Thing::Engine => ('¤', (150, 172, 196)), + } +} + +/// A colour made `k` times as bright. +fn shade((r, g, b): Rgb, k: f64) -> Rgb { + let f = |c: u8| (f64::from(c) * k).round().clamp(0.0, 255.0) as u8; + (f(r), f(g), f(b)) +} + +/// How a building's roof looks at `p`: its colour, and the shop's sign or +/// the landmark's mark painted on it. +fn roof_look(t: &Town, b: usize, p: Pos, theme: &Theme) -> (char, Rgb, bool) { + let building = &t.buildings[b]; + let h = seed::derive(b as u64, "roof", 0); + let colour = + shade(theme.roofs[(h % 3) as usize], 0.85 + 0.25 * ((h >> 8) % 100) as f64 / 100.0); + let centre = building.centre(); + let grand = matches!(building.kind, Kind::Palace | Kind::Temple | Kind::Manor); + if let Some(shop) = building.shop { + let sign = t.shops[shop].kind.sign(); + let start = centre.x - sign.len() as i32 / 2; + if p.y == centre.y && p.x >= start && p.x < start + sign.len() as i32 { + let ch = sign.as_bytes()[(p.x - start) as usize] as char; + return (ch, SIGN, true); + } + } + if p == centre { + match building.kind { + Kind::Palace => return ('Ψ', SIGN, true), + Kind::Temple => return (theme.altar.0, SIGN, true), + _ => {} + } + } + (if grand { '▓' } else { '▒' }, colour, false) +} + +/// How a pond looks today. +fn pond_look(theme: &Theme, p: Pos, ticks: u64) -> (char, Rgb) { + match theme.world.pond { + Pond::Water => ('≈', (80, 140, 210)), + Pond::Ice => ('░', (172, 210, 240)), + Pond::Dry => ('~', (150, 126, 94)), + Pond::Lava => ('≈', flicker(p, ticks)), + } +} + +/// How a cell of the streets looks, leaving out people. +pub(crate) fn street_look(t: &Town, p: Pos, theme: &Theme, ticks: u64) -> (char, Rgb, bool) { + let tile = t.tile(p); + let (glyph, colour) = match tile { + Tile::Ground => theme.world.ground, + Tile::Pad => ('·', (78, 82, 96)), + Tile::Ship => { + let (dx, dy) = (p.x - t.ship.x, p.y - t.ship.y); + let ch = SHIP_ART[dy as usize].as_bytes()[dx as usize] as char; + (ch, (206, 212, 228)) + } + Tile::CityWall => theme.city_wall, + Tile::Gate | Tile::Street => theme.street, + Tile::Square => theme.square, + Tile::Garden => theme.world.garden, + Tile::Water => pond_look(theme, p, ticks), + Tile::Sea => { + let swell = (ticks / 12) as i64 + i64::from(p.x) * 5 + i64::from(p.y) * 11; + let crest = swell.rem_euclid(7) == 0; + ('≈', theme.world.sea[usize::from(crest)]) + } + Tile::Decor => theme.decor.unwrap_or(theme.world.tree), + Tile::Tree => theme.world.tree, + Tile::Wild => match t.wild_sort(p) { + 1 if theme.world.vents => ('^', flicker(p, ticks)), + sort => theme.world.wild[sort], + }, + Tile::Centrepiece => theme.centrepiece, + Tile::Stall => theme.stall, + Tile::Statue => ('ß', (200, 196, 190)), + Tile::Well => ('o', (110, 170, 230)), + Tile::Lamp => ('¡', flicker(p, ticks)), + Tile::Roof => match t.building_at(p) { + Some(b) => return roof_look(t, b, p, theme), + None => ('▒', theme.roofs[0]), + }, + Tile::Door { open: true } => ('+', (236, 196, 120)), + Tile::Door { open: false } => ('+', (110, 88, 66)), + Tile::Wall | Tile::Floor | Tile::Doorway | Tile::Exit | Tile::Counter | Tile::Shelf => { + theme.floor + } + Tile::Rug => ('░', theme.rug), + Tile::Furniture(thing) => thing_look(thing, theme, p, ticks), + }; + (glyph, colour, false) +} + +/// How a cell inside a building looks, leaving out people. +pub(crate) fn inside_look( + t: &Town, + inside: &Interior, + p: Pos, + theme: &Theme, + ticks: u64, +) -> (char, Rgb, bool) { + let floor = match inside.kind { + Kind::Palace | Kind::Temple | Kind::Manor => ('·', (178, 172, 188)), + Kind::Shop(ShopKind::Tavern) | Kind::Workshop => ('.', (146, 112, 80)), + Kind::Warehouse | Kind::Guardhouse => ('.', (110, 108, 104)), + _ => theme.floor, + }; + let (glyph, colour) = match inside.site.tile(p) { + Tile::Wall => theme.wall, + Tile::Floor => floor, + Tile::Doorway => ('\'', theme.wall.1), + Tile::Exit => ('+', (236, 196, 120)), + Tile::Counter => ('=', (190, 156, 112)), + Tile::Shelf => { + let shop = inside.shop.map(|s| t.shops[s].kind); + shop.map_or(('"', (150, 140, 120)), shelf) + } + Tile::Rug => ('░', theme.rug), + Tile::Furniture(thing) => thing_look(thing, theme, p, ticks), + _ => return street_look(t, p, theme, ticks), + }; + (glyph, colour, false) +} + +impl TownScene { + fn draw_map(&self, buf: &mut Buffer, area: Rect, ticks: u64) { + let site = self.site(); + let left = camera(self.player.x, site.width, i32::from(area.width)); + let top = camera(self.player.y, site.height, i32::from(area.height)); + let theme = theme(&self.town); + let folk: HashMap<Pos, &Person> = site.people.iter().map(|p| (p.pos, p)).collect(); + let building = self.inside.as_ref().map(|i| i.building); + // What's been put down, the last thing on top. + let things: HashMap<Pos, &Stack> = self + .dropped + .iter() + .filter(|((b, _), _)| *b == building) + .map(|((_, p), s)| (*p, s)) + .collect(); + for sy in 0..area.height { + for sx in 0..area.width { + let p = Pos::new(left + i32::from(sx), top + i32::from(sy)); + let (glyph, colour, bold) = if !site.contains(p) { + (' ', (0, 0, 0), false) + } else if p == self.player { + ('@', (255, 255, 255), true) + } else if let Some(person) = folk.get(&p) { + person_look(person.role, &theme) + } else if let Some(stack) = things.get(&p) { + let (glyph, colour) = inventory::look(&stack.item); + (glyph, colour, false) + } else if let Some(inside) = &self.inside { + inside_look(&self.town, inside, p, &theme, ticks) + } else { + street_look(&self.town, p, &theme, ticks) + }; + let cell = &mut buf[(area.x + sx, area.y + sy)]; + cell.set_char(glyph).set_fg(rgb(colour)); + if bold { + cell.modifier.insert(Modifier::BOLD); + } + } + } + } + + /// The whole city, shrunk to fit: each cell of it stands for a square + /// of streets, with the shops and landmarks marked. + fn draw_city(&self, frame: &mut Frame, area: Rect) { + let t = &self.town; + let theme = theme(t); + let mark = |glyph: char, what: &'static str| { + vec![ + Span::raw(format!(" {glyph}")).fg(rgb(SIGN)).bold(), + Span::raw(format!(" {what}")).fg(DIM), + ] + }; + let mut legend = vec![Span::raw(" @").white().bold(), Span::raw(" you").fg(DIM)]; + // As many as fit along the bottom, whole. + let room = usize::from(area.width.saturating_sub(4)); + for (glyph, what) in [ + ('%', "ore"), + ('!', "doctor"), + ('≡', "scholar"), + ('o', "tavern"), + ('¤', "shipyard"), + (')', "arms"), + ('Ψ', "palace"), + (theme.altar.0, "temple"), + ] { + let used: usize = legend.iter().map(Span::width).sum(); + if used + 3 + what.chars().count() > room { + break; + } + legend.extend(mark(glyph, what)); + } + legend.push(Span::raw(" ").fg(DIM)); + let block = Block::bordered() + .border_style(Style::new().fg(ACCENT)) + .title(Line::from(format!(" {} ", t.name)).bold().fg(ACCENT)) + .title(keys::line(&[("m esc", "close ")]).right_aligned()) + .title_bottom(Line::from(legend)); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + let (aw, ah) = (i32::from(inner.width), i32::from(inner.height)); + if aw <= 0 || ah <= 0 { + return; + } + let scale = ((t.width() + aw - 1) / aw).max((t.height() + ah - 1) / ah).max(1); + let (mw, mh) = ((t.width() + scale - 1) / scale, (t.height() + scale - 1) / scale); + let (ox, oy) = (i32::from(inner.x) + (aw - mw) / 2, i32::from(inner.y) + (ah - mh) / 2); + let buf = frame.buffer_mut(); + let mut put = |x: i32, y: i32, glyph: char, colour: Rgb, bold: bool| { + let (Ok(x), Ok(y)) = (u16::try_from(x), u16::try_from(y)) else { return }; + if !inner.contains((x, y).into()) { + return; + } + let cell = &mut buf[(x, y)]; + cell.set_char(glyph).set_fg(rgb(colour)); + if bold { + cell.modifier.insert(Modifier::BOLD); + } + }; + for cy in 0..mh { + for cx in 0..mw { + let (glyph, colour) = self.summary(cx * scale, cy * scale, scale, &theme); + put(ox + cx, oy + cy, glyph, colour, false); + } + } + // Shops and landmarks, your ship, and you, where they'll go on the map. + let mut marks: Vec<(i32, i32, char, Rgb)> = Vec::new(); + for b in &t.buildings { + let glyph = match b.kind { + Kind::Shop(kind) => shelf(kind).0, + Kind::Palace => 'Ψ', + Kind::Temple => theme.altar.0, + _ => continue, + }; + marks.push((ox + b.door.x / scale, oy + b.door.y / scale, glyph, SIGN)); + } + marks.push((ox + t.ship.x / scale, oy + t.ship.y / scale, '▲', (206, 212, 228))); + let you = self.inside.as_ref().map_or(self.player, |i| t.buildings[i.building].door); + marks.push((ox + you.x / scale, oy + you.y / scale, '@', (255, 255, 255))); + // Quarter names near the middle of each quarter, nudged up or down + // off the marks and each other, or left off if there's no room. + let mut taken: Vec<(i32, i32, i32)> = Vec::new(); + for q in &t.quarters { + let len = q.name.chars().count() as i32; + let x0 = ox + q.centre.x / scale - len / 2; + if x0 < i32::from(inner.x) || x0 + len > i32::from(inner.x) + aw { + continue; + } + let free = |y: i32| { + y >= i32::from(inner.y) + && y < i32::from(inner.y) + ah + && !taken.iter().any(|&(ty, a, b)| ty == y && x0 <= b + 1 && x0 + len >= a - 1) + && !marks + .iter() + .any(|&(mx, my, ..)| my == y && (x0 - 1..=x0 + len).contains(&mx)) + }; + let y = oy + q.centre.y / scale; + let Some(y) = [0, -1, 1, -2, 2].into_iter().map(|dy| y + dy).find(|&y| free(y)) else { + continue; + }; + taken.push((y, x0, x0 + len - 1)); + for (i, ch) in q.name.chars().enumerate() { + put(x0 + i as i32, y, ch, (200, 204, 220), false); + } + } + for (x, y, glyph, colour) in marks { + put(x, y, glyph, colour, true); + } + } + + /// One glyph for the `scale`-square of streets from `(x, y)`. + fn summary(&self, x: i32, y: i32, scale: i32, theme: &Theme) -> (char, Rgb) { + let t = &self.town; + // Walls count most, so the city's outline stays unbroken. + let mut weights = [0; 7]; + for dy in 0..scale { + for dx in 0..scale { + let class = match t.tile(Pos::new(x + dx, y + dy)) { + _ if !t.site.contains(Pos::new(x + dx, y + dy)) => continue, + Tile::CityWall => (0, 8), + Tile::Street | Tile::Gate | Tile::Lamp => (1, 2), + Tile::Square | Tile::Stall | Tile::Centrepiece | Tile::Well | Tile::Statue => { + (2, 2) + } + Tile::Roof | Tile::Door { .. } => (3, 1), + Tile::Garden | Tile::Decor | Tile::Tree => (4, 1), + Tile::Water => (5, 3), + Tile::Sea => (6, 2), + _ => continue, + }; + weights[class.0] += class.1; + } + } + let best = (0..weights.len()).max_by_key(|&i| (weights[i], usize::MAX - i)).unwrap_or(0); + if weights[best] == 0 { + return (theme.world.ground.0, shade(theme.world.ground.1, 0.6)); + } + match best { + 0 => theme.city_wall, + 1 => theme.street, + 2 => theme.square, + 3 => ('▒', theme.roofs[0]), + 4 => theme.world.garden, + 5 => pond_look(theme, Pos::new(x, y), 0), + _ => ('≈', theme.world.sea[0]), + } + } + + fn draw_log(&self, frame: &mut Frame, area: Rect) { + let shown = usize::from(area.height.saturating_sub(1)); + let first = self.log.len().saturating_sub(shown); + let lines: Vec<Line> = self.log[first..] + .iter() + .enumerate() + .map(|(i, text)| { + let age = self.log.len() - first - 1 - i; + let line = Line::from(text.as_str()); + match age { + 0 => line.white(), + 1 | 2 => line.gray(), + _ => line.dark_gray(), + } + }) + .collect(); + let block = Block::new().borders(Borders::TOP).border_style(Style::new().dark_gray()); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); + } + + /// Where you are, for the panel: the quarter, or the building and room. + fn whereabouts(&self) -> Vec<String> { + let t = &self.town; + match &self.inside { + Some(inside) => { + let mut lines = vec![capitalise(&t.buildings[inside.building].name)]; + if inside.room_count() > 1 + && let Some(purpose) = inside.purpose_at(self.player) + { + lines.push(format!(" {}", words::room_name(purpose, t.culture))); + } + let (dx, dy) = (inside.exit.x - self.player.x, inside.exit.y - self.player.y); + if dx.abs() + dy.abs() > 1 { + lines.push(format!(" the way out is {}", compass(dx, dy))); + } + lines + } + None => { + let p = self.player; + let place = match t.tile(p) { + Tile::Pad => "the landing field".to_string(), + Tile::Ground => "outside the walls".to_string(), + _ if t.square.y == p.y && t.tile(p) == Tile::Square => { + "the market square".into() + } + _ => t.quarter_at(p).map_or("the streets".into(), |q| capitalise(&q.name)), + }; + vec![capitalise(&place)] + } + } + } + + fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { + let t = &self.town; + let block = Block::bordered() + .border_style(Style::new().dark_gray()) + .title(Line::from(format!(" {} ", t.name)).bold().fg(ACCENT)) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + let hints = keys::packed( + &[ + ("arrows hjkl yubn", "walk"), + ("shift", "run"), + ("g", "pick up"), + ("i", "inventory"), + ("c", "character"), + ("t", "talk"), + ("m", "city map"), + ("bump", "go in, trade, talk"), + ("esc", "menu: leave or quit"), + ], + inner.width, + ); + let [info, _, help] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Length(hints.len() as u16), + ]) + .areas(inner); + + let h = &ctx.hold; + let label = |text: &'static str| Span::raw(text).fg(DIM); + let mut lines = vec![ + Line::from(format!("capital of {}", self.world)).fg(DIM), + Line::from(format!("a city of {}", t.culture.name())).fg(DIM), + Line::default(), + ]; + for line in self.whereabouts() { + lines.push(Line::from(line).fg(ACCENT)); + } + lines.extend([ + Line::default(), + Line::from(vec![label("money "), Span::raw(format!("{} ¤", h.scrip))]), + Line::from(vec![ + label("ore "), + Span::raw(format!("{}/{} t", h.ore_tons(), h.ore_capacity)), + ]), + Line::from(vec![ + label("fuel "), + Span::raw(format!("{}/{} jumps", h.fuel, h.fuel_capacity)), + ]), + Line::from(vec![label("lenses "), Span::raw(ctx.lenses.to_string())]), + Line::from(vec![ + label("carrying "), + Span::raw(format!( + "{}/{} kg", + items::kg(h.gear.carried()), + items::kg(h.gear.capacity) + )), + ]), + ]); + if h.debt > 0 { + lines.push(Line::from(vec![label("owed "), Span::raw(format!("{} ¤", h.debt))])); + } + // What you might sell here, on you or aboard. + for (at, stack) in h.kept() { + let colour = match &stack.item { + Item::Artifact(a) if a.identified => ACCENT, + Item::Artifact(_) => Color::Yellow, + Item::Salvage(_) => Color::Rgb(150, 200, 222), + _ => continue, + }; + let (glyph, _) = inventory::look(&stack.item); + let text = format!("{}{}", stack.name(), at.note()); + for (i, row) in + hanging(&text, usize::from(info.width).saturating_sub(2)).into_iter().enumerate() + { + let bullet = if i == 0 { format!("{glyph} ") } else { " ".into() }; + lines.push(Line::from(format!("{bullet}{row}")).fg(colour)); + } + } + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info); + + frame.render_widget(Paragraph::new(hints), help); + } + + /// The shop counter: the keeper, what they'll do for you, and what they + /// said last. + fn draw_counter(&self, frame: &mut Frame, area: Rect, trading: &Trading, ctx: &Ctx) { + let shop = &self.town.shops[trading.shop]; + let offers = self.offers(shop, ctx); + let h = &ctx.hold; + let mut lines = vec![ + Line::from(format!("{} says:", shop.keeper)).fg(DIM), + Line::from(format!("“{} {}”", salutation(self.town.culture), pitch(shop.kind))), + Line::default(), + ]; + match shop.kind { + ShopKind::Broker => { + for (ore, price) in trade::ore_prices(&ctx.system, self.place) { + let have = h.ore.get(ore.name).copied().unwrap_or(0); + let have = if have > 0 { format!("you have {have} t") } else { String::new() }; + lines.push(Line::from(vec![ + Span::raw(" * ").fg(rgb(ore.rgb)), + Span::raw(format!("{:<16}{:>4} ¤ a ton ", ore.name, price)), + Span::raw(have).fg(DIM), + ])); + } + if h.ore_tons() == 0 && offers.is_empty() { + lines.push(Line::from("You've no ore or salvage to sell.").fg(DIM)); + } + } + ShopKind::Doctor => lines.push( + Line::from(format!( + "You have {} medpatches, on you and aboard. I'll sell you up to {}.", + h.medpatches(), + trade::MEDPATCH_LIMIT + )) + .fg(DIM), + ), + ShopKind::Scholar if offers.is_empty() => { + lines.push(Line::from("You've no artifacts to show.").fg(DIM)); + } + ShopKind::Shipyard => { + lines.push( + Line::from(format!( + "Fuel is {} ¤ a jump. Your tank holds {} of {}.", + trade::fuel_price(&ctx.system, self.place), + h.fuel, + h.fuel_capacity + )) + .fg(DIM), + ); + if offers.is_empty() { + lines.push(Line::from("There's nothing more I can fit to your ship.").fg(DIM)); + } + } + _ => {} + } + if !offers.is_empty() { + lines.push(Line::default()); + } + let choice = trading.choice.min(offers.len().saturating_sub(1)); + for (i, offer) in offers.iter().enumerate() { + let number = if i < 9 { format!("{} ", i + 1) } else { " ".into() }; + let mut line = + Line::from(vec![Span::raw(number).fg(ACCENT), Span::raw(offer.text.clone())]); + if i == choice { + line = line.reversed(); + } + lines.push(line); + } + if let Some(reply) = &trading.reply { + lines.push(Line::default()); + lines.push(Line::from(reply.clone()).fg(ACCENT)); + } + lines.push(Line::default()); + lines.push(keys::line(&[("1-9 ↑↓ enter", "choose"), ("esc", "step away")])); + + let [area] = Layout::horizontal([Constraint::Max(66)]).flex(Flex::Center).areas(area); + let width = usize::from(area.width.saturating_sub(4)).max(1); + let rows: usize = lines.iter().map(|l| l.width().div_ceil(width).max(1)).sum(); + let height = u16::try_from(rows + 3).unwrap_or(u16::MAX); + let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area); + let block = Block::bordered() + .title(Line::from(format!(" {} ", shop.name)).bold().fg(ACCENT)) + .border_style(Style::new().fg(ACCENT)) + .padding(Padding::horizontal(1)); + frame.render_widget(Clear, area); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); + } +} + +/// Splits `text` into rows at most `width` wide, breaking between words, so +/// a list item's later rows can be indented under its first. +fn hanging(text: &str, width: usize) -> Vec<String> { + let mut rows: Vec<String> = Vec::new(); + for word in text.split_whitespace() { + match rows.last_mut() { + Some(row) if row.chars().count() + 1 + word.chars().count() <= width => { + row.push(' '); + row.push_str(word); + } + _ => rows.push(word.to_string()), + } + } + rows +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::names; + use crate::scenes::tests::test_ctx; + + fn press(scene: &mut TownScene, ctx: &mut Ctx, code: KeyCode) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + fn key_towards(dx: i32, dy: i32) -> KeyCode { + match (dx, dy) { + (-1, 0) => KeyCode::Left, + (1, 0) => KeyCode::Right, + (0, -1) => KeyCode::Up, + (0, 1) => KeyCode::Down, + (-1, -1) => KeyCode::Home, + (1, -1) => KeyCode::PageUp, + (-1, 1) => KeyCode::End, + _ => KeyCode::PageDown, + } + } + + /// The capital of the homeworld. + fn landed() -> (TownScene, Ctx) { + let ctx = test_ctx(); + let scene = TownScene::new(&ctx, ctx.system.home_place()); + (scene, ctx) + } + + /// Standing on the doorstep of building `b`, and stepping in. + fn walk_in(scene: &mut TownScene, ctx: &mut Ctx, b: usize) { + let building = &scene.town.buildings[b]; + scene.player = building.front(); + let (dx, dy) = building.facing.step(); + // Clear the doorstep of anyone wandering by. + scene.town.site.people.retain(|p| p.pos != scene.player); + press(scene, ctx, key_towards(-dx, -dy)); + } + + /// A town on the homeworld, standing at the counter of its `kind` of shop. + fn at_counter(kind: ShopKind) -> (TownScene, Ctx) { + let (mut scene, ctx) = landed(); + let shop = scene.town.shops.iter().position(|s| s.kind == kind).unwrap(); + scene.trading = Some(Trading { shop, choice: 0, reply: None }); + (scene, ctx) + } + + fn reply(scene: &TownScene) -> &str { + scene.trading.as_ref().unwrap().reply.as_deref().unwrap() + } + + #[test] + fn an_armourer_buys_and_sells_and_keeps_the_rack_for_the_fortnight() { + let (mut scene, mut ctx) = landed(); + let shop = scene.town.shops.iter().position(|s| s.kind == ShopKind::Armoury).unwrap(); + scene.open_shop(shop); + ctx.hold.gear = items::Gear::default(); + ctx.hold.lockers.clear(); + let terms = trade::armoury_terms(&ctx.system, scene.place); + let rack = scene.rack(&ctx); + let n = rack.len(); + + ctx.hold.scrip = 0; + assert!(scene.buy(0, &mut ctx).contains("Come back richer")); + let price = terms.asking(&rack[0].item).unwrap(); + ctx.hold.scrip = price + 5; + let said = scene.buy(0, &mut ctx); + assert!(said.ends_with(&format!("is yours, for {price} ¤.")), "{said}"); + assert_eq!((ctx.hold.scrip, scene.rack(&ctx).len()), (5, n - 1)); + assert_eq!(ctx.hold.gear.pack, [rack[0].clone()]); + + // Your own torch goes only with something else to hit things with. + let torch = Item::Weapon(ctx.hold.gear.melee.clone()); + if matches!(&rack[0].item, Item::Weapon(w) if w.is_gun()) { + let said = scene.sell(Some(items::Spot::Melee), None, &mut ctx); + assert!(said.contains("nothing to hit things with"), "{said}"); + assert_eq!(ctx.hold.scrip, 5); + let sold = scene.sell(Some(items::Spot::Pack(0)), None, &mut ctx); + assert!(sold.contains("It'll find a new owner"), "{sold}"); + } else { + let said = scene.sell(Some(items::Spot::Melee), None, &mut ctx); + assert!(said.contains("your cutting torch"), "{said}"); + assert_eq!(Item::Weapon(ctx.hold.gear.melee.clone()), rack[0].item, "readied instead"); + let back = scene.rack(&ctx).iter().position(|s| s.item == torch).unwrap(); + ctx.hold.scrip += 100; + let said = scene.buy(back, &mut ctx); + assert!(said.starts_with("You buy back your cutting torch"), "{said}"); + } + + // And from your ship's lockers. + ctx.hold.lockers.push(rack[1].clone()); + let scrip = ctx.hold.scrip; + scene.sell(None, Some(0), &mut ctx); + assert!(ctx.hold.lockers.is_empty()); + assert_eq!(ctx.hold.scrip, scrip + terms.offer(&rack[1].item).unwrap()); + let now = scene.rack(&ctx); + let copies = now.iter().filter(|s| **s == rack[1]).count(); + assert_eq!(copies, 2, "what you sell goes on the rack"); + + // Come back later in the fortnight and it's as you left it; the + // next, it's restocked. + let again = TownScene::new(&ctx, ctx.system.home_place()); + assert_eq!(again.rack(&ctx), now); + ctx.day += trade::RESTOCK_DAYS; + assert_ne!(again.rack(&ctx), now); + } + + #[test] + fn an_armourer_sells_armour_and_mends_yours() { + let (mut scene, mut ctx) = landed(); + let shop = scene.town.shops.iter().position(|s| s.kind == ShopKind::Armoury).unwrap(); + scene.open_shop(shop); + ctx.hold.gear = items::Gear::default(); + ctx.hold.lockers.clear(); + let terms = trade::armoury_terms(&ctx.system, scene.place); + let rack = scene.rack(&ctx); + let i = + rack.iter().position(|s| matches!(s.item, Item::Armor(_))).expect("armour on the rack"); + let Item::Armor(piece) = rack[i].item.clone() else { unreachable!() }; + assert!(rack[..i].iter().all(|s| matches!(s.item, Item::Weapon(_))), "weapons first"); + + ctx.hold.scrip = 1000; + let said = scene.buy(i, &mut ctx); + assert!(said.contains("yours, for"), "{said}"); + assert_eq!(ctx.hold.scrip, 1000 - terms.asking(&rack[i].item).unwrap()); + assert_eq!(scene.rack(&ctx).len(), rack.len() - 1); + ctx.hold.gear.wear(0).unwrap(); + + // Worn down, it's mended for what the wear's worth. + let slot = piece.slot; + ctx.hold.gear.worn.get_mut(&slot).unwrap().durability = 0; + let cost = terms.mending(&ctx.hold.gear.worn[&slot]); + assert!(cost > 0); + let scrip = ctx.hold.scrip; + let said = scene.mend(Some(items::Spot::Worn(slot)), None, &mut ctx); + assert!(said.contains("whole again"), "{said}"); + assert_eq!(ctx.hold.scrip, scrip - cost); + assert_eq!(ctx.hold.gear.worn[&slot], piece); + let said = scene.mend(Some(items::Spot::Worn(slot)), None, &mut ctx); + assert!(said.contains("whole already"), "{said}"); + + // All at once: on you and aboard. + ctx.hold.gear.worn.get_mut(&slot).unwrap().durability = 1; + let mut aboard = piece.clone(); + aboard.durability = 2; + ctx.hold.lockers.push(Stack::one(Item::Armor(aboard))); + ctx.hold.scrip = 0; + let said = scene.mend(None, None, &mut ctx); + assert!(said.contains("to mend all 2"), "{said}"); + ctx.hold.scrip = 1000; + let said = scene.mend(None, None, &mut ctx); + assert!(said.starts_with("All 2 pieces are whole again"), "{said}"); + assert_eq!(ctx.hold.lockers[0].item, Item::Armor(piece.clone())); + assert_eq!(scene.mend(None, None, &mut ctx), "Nothing of yours needs mending."); + + // And sold back, off your back. + let said = scene.sell(Some(items::Spot::Worn(slot)), None, &mut ctx); + assert!(said.contains("It'll find a new owner"), "{said}"); + assert!(ctx.hold.gear.worn.is_empty()); + assert!(scene.rack(&ctx).contains(&Stack::one(Item::Armor(piece)))); + } + + #[test] + fn walking_up_to_a_counter_opens_the_shop() { + for kind in ShopKind::ALL { + let (mut scene, mut ctx) = landed(); + let shop = scene.town.shops.iter().position(|s| s.kind == kind).unwrap(); + let b = scene.town.shops[shop].building; + walk_in(&mut scene, &mut ctx, b); + let inside = scene.inside.as_ref().expect("the door opens"); + assert_eq!(inside.building, b); + assert_eq!(scene.player, inside.start); + // Somewhere you can reach that's next to the counter. + let seen = inside.site.reachable(inside.start); + let (stand, counter) = inside + .site + .positions() + .filter(|&p| seen[(p.y * inside.site.width + p.x) as usize]) + .find_map(|p| { + p.neighbours().find(|&n| inside.shop_at(n) == Some(shop)).map(|n| (p, n)) + }) + .unwrap_or_else(|| panic!("can't reach the {kind:?}'s counter")); + scene.inside.as_mut().unwrap().site.people.retain(|p| p.pos != stand || p.stays); + scene.player = stand; + press(&mut scene, &mut ctx, key_towards(counter.x - stand.x, counter.y - stand.y)); + if kind == ShopKind::Armoury { + assert_eq!(scene.shopping, Some(shop), "an armourer shows you the rack"); + assert!(scene.inventory.is_some() && scene.trading.is_none()); + } else { + assert_eq!(scene.trading.as_ref().map(|t| t.shop), Some(shop)); + } + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(scene.trading.is_none(), "esc steps away from the counter"); + assert!(scene.inventory.is_none() && scene.shopping.is_none()); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Esc), Transition::None)); + assert!(scene.pause.is_some(), "then opens the menu"); + press(&mut scene, &mut ctx, KeyCode::Char('2')); + assert!(scene.inside.is_none(), "which takes you out to the street"); + assert_eq!(scene.player, scene.town.buildings[b].front()); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!( + matches!(press(&mut scene, &mut ctx, KeyCode::Char('2')), Transition::Pop), + "then off" + ); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Esc), Transition::None)); + assert!(scene.pause.is_none(), "esc again closes the menu"); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!( + matches!(press(&mut scene, &mut ctx, KeyCode::Char('3')), Transition::Title), + "or save and quit" + ); + } + } + + #[test] + fn the_way_in_is_the_way_out() { + let (mut scene, mut ctx) = landed(); + let b = (0..scene.town.buildings.len()) + .find(|&b| { + !scene.town.buildings[b].barred && scene.town.buildings[b].kind == Kind::House + }) + .unwrap(); + walk_in(&mut scene, &mut ctx, b); + let inside = scene.inside.as_ref().unwrap(); + let (exit, start) = (inside.exit, inside.start); + scene.inside.as_mut().unwrap().site.people.retain(|p| p.pos != exit); + assert_eq!(scene.player, start); + press(&mut scene, &mut ctx, key_towards(exit.x - start.x, exit.y - start.y)); + assert!(scene.inside.is_none()); + assert_eq!(scene.player, scene.town.buildings[b].front()); + } + + #[test] + fn barred_doors_stay_shut() { + let (mut scene, mut ctx) = landed(); + let b = scene.town.buildings.iter().position(|b| b.barred).unwrap(); + walk_in(&mut scene, &mut ctx, b); + assert!(scene.inside.is_none()); + assert_eq!(scene.player, scene.town.buildings[b].front()); + } + + #[test] + fn walls_stop_you_and_streets_dont() { + let (mut scene, mut ctx) = landed(); + let start = scene.player; + press(&mut scene, &mut ctx, KeyCode::Right); + assert_eq!(scene.player, start.offset(1, 0)); + // The ship is west of where you start: walking into it leaves. + scene.player = start; + let mut left = Transition::None; + for _ in 0..3 { + left = press(&mut scene, &mut ctx, KeyCode::Left); + } + assert!(matches!(left, Transition::Pop)); + } + + #[test] + fn you_walk_through_people_who_are_wandering() { + let (mut scene, mut ctx) = landed(); + // Out in the streets, three cells of street in a row. + let site = &scene.town.site; + let street = |p: Pos| site.tile(p) == Tile::Street && site.free(p); + let from = site + .positions() + .find(|&p| street(p) && street(p.offset(1, 0)) && street(p.offset(2, 0))) + .expect("a street"); + scene.player = from; + let to = from.offset(1, 0); + scene.town.site.people.push(Person { + pos: to, + role: Role::Passerby, + name: None, + says: "Hello.", + stays: false, + }); + let who = scene.town.site.people.len() - 1; + press(&mut scene, &mut ctx, KeyCode::Right); + assert_eq!(scene.player, to, "you walk on"); + assert_ne!(scene.town.site.people[who].pos, to, "and they step aside"); + // `t` talks to them anyway. + let said = scene.said; + press(&mut scene, &mut ctx, KeyCode::Char('t')); + assert_eq!(scene.said, said + 1); + + // Someone who stays put is in the way, and bumping them talks. + scene.town.site.people[who].pos = to.offset(1, 0); + scene.town.site.people[who].stays = true; + press(&mut scene, &mut ctx, KeyCode::Right); + assert_eq!(scene.player, to); + assert!(scene.log.last().unwrap().contains("Hello.")); + } + + #[test] + fn running_goes_until_something_changes() { + let (mut scene, mut ctx) = landed(); + let start = scene.player; + scene.town.site.people.clear(); + scene.key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), &mut ctx); + let ran = scene.player.x - start.x; + assert!(ran > 3, "ran {ran}"); + assert_eq!(scene.player.y, start.y); + let at = scene.player; + scene.key(KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT), &mut ctx); + assert!(scene.player.x > at.x, "and on again"); + } + + #[test] + fn passers_by_point_the_way() { + let (mut scene, _) = landed(); + let at = scene.town.square; + for _ in 0..20 { + let text = scene.directions(at).unwrap(); + assert!(text.contains(" of here"), "{text}"); + } + assert_eq!(compass(10, 0), "east"); + assert_eq!(compass(0, -5), "north"); + assert_eq!(compass(-6, 3), "south-west"); + } + + #[test] + fn insides_and_the_city_map_render_at_any_size() { + let (mut scene, mut ctx) = landed(); + let mut scenes = Vec::new(); + for kind in [Kind::Palace, Kind::Temple, Kind::Shop(ShopKind::Tavern)] { + let b = scene.town.buildings.iter().position(|b| b.kind == kind).unwrap(); + walk_in(&mut scene, &mut ctx, b); + assert!(scene.inside.is_some()); + scenes.push(scene.town.enter(b)); + scene.go_out(); + } + press(&mut scene, &mut ctx, KeyCode::Char('m')); + assert!(scene.map); + for (width, height) in [(200, 60), (80, 24), (30, 10), (3, 2)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| scene.render(frame, &ctx)).unwrap(); + } + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(!scene.map); + for inside in scenes { + scene.player = inside.start; + scene.inside = Some(inside); + for (width, height) in [(200, 60), (80, 24), (30, 10), (3, 2)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| scene.render(frame, &ctx)).unwrap(); + } + } + } + + #[test] + fn the_broker_buys_your_ore() { + let (mut scene, mut ctx) = at_counter(ShopKind::Broker); + ctx.hold.ore.insert("nickel", 4); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert!(reply(&scene).starts_with("Sold 4 t")); + assert_eq!(ctx.hold.ore_tons(), 0); + assert!(ctx.hold.scrip > 0); + } + + #[test] + fn the_doctor_sells_medpatches_you_can_afford() { + let (mut scene, mut ctx) = at_counter(ShopKind::Doctor); + press(&mut scene, &mut ctx, KeyCode::Enter); + assert_eq!(reply(&scene), "You can't afford that."); + ctx.hold.scrip = 100; + press(&mut scene, &mut ctx, KeyCode::Enter); + assert_eq!(ctx.hold.gear.medpatches(), 1); + assert_eq!(ctx.hold.scrip, 100 - trade::medpatch_price(&ctx.system, scene.place)); + // What won't fit on you goes to your ship. + ctx.hold.gear.capacity = ctx.hold.gear.carried(); + press(&mut scene, &mut ctx, KeyCode::Enter); + assert!(reply(&scene).contains("sent to your ship"), "{}", reply(&scene)); + assert_eq!((ctx.hold.gear.medpatches(), ctx.hold.medpatches()), (1, 2)); + ctx.hold.lockers.push(Stack { item: Item::Medpatch, count: trade::MEDPATCH_LIMIT }); + let scrip = ctx.hold.scrip; + press(&mut scene, &mut ctx, KeyCode::Enter); + assert_eq!(ctx.hold.scrip, scrip, "no more than the limit"); + } + + #[test] + fn long_names_wrap_between_words() { + assert_eq!( + hanging("the Voidsteel Crown of Drakion", 24), + ["the Voidsteel Crown of", "Drakion"] + ); + assert_eq!(hanging("a disc", 24), ["a disc"]); + } + + #[test] + fn the_scholar_names_artifacts_then_buys_them() { + let (mut scene, mut ctx) = at_counter(ShopKind::Scholar); + let artifact = names::artifact(&mut seed::rng(9)); + let true_name = artifact.true_name.clone(); + ctx.hold.lockers.push(Stack::one(Item::Artifact(artifact))); + ctx.hold.scrip = 30; + assert!(screen(&scene, &ctx).contains("(in your ship)")); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert!(matches!(&ctx.hold.lockers[0].item, Item::Artifact(a) if a.identified)); + assert!(reply(&scene).contains(&true_name)); + let before = ctx.hold.scrip; + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert!(ctx.hold.lockers.is_empty()); + assert!(ctx.hold.scrip > before); + } + + fn screen(scene: &TownScene, ctx: &Ctx) -> String { + let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); + terminal.draw(|frame| scene.render(frame, ctx)).unwrap(); + terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect() + } + + fn salvage(title: &str, value: u32) -> Item { + Item::Salvage(items::Salvage { title: title.into(), value, mass: 1000 }) + } + + #[test] + fn the_broker_buys_salvage_on_you_or_aboard() { + let (mut scene, mut ctx) = at_counter(ShopKind::Broker); + ctx.hold.gear.add(Stack { item: salvage("coil of wire", 10), count: 3 }); + ctx.hold.lockers.push(Stack::one(salvage("hull plate", 20))); + assert!(screen(&scene, &ctx).contains("Sell all your salvage")); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert!(reply(&scene).contains("3 coils of wire"), "{}", reply(&scene)); + assert!(ctx.hold.gear.pack.iter().all(|s| !matches!(s.item, Item::Salvage(_)))); + let paid = ctx.hold.scrip; + assert!(paid >= 3 * 7, "about what it's worth"); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert!(ctx.hold.lockers.is_empty()); + assert!(ctx.hold.scrip > paid); + } + + #[test] + fn the_shipyard_sells_bigger_packs() { + let (mut scene, mut ctx) = at_counter(ShopKind::Shipyard); + ctx.hold.scrip = 5000; + ctx.hold.ore_capacity = 1000; + ctx.lenses = 10; + ctx.hold.fuel_capacity = trade::MAX_FUEL; + ctx.hold.fuel = trade::MAX_FUEL; + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert_eq!(ctx.hold.gear.capacity, items::BASE_CAPACITY + items::PACKS[0].1); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert_eq!(ctx.hold.gear.capacity, items::max_capacity()); + assert!(screen(&scene, &ctx).contains("nothing more")); + } + + #[test] + fn what_you_drop_in_the_city_stays_put_until_you_leave() { + let (mut scene, mut ctx) = landed(); + ctx.hold.gear.add(Stack::one(salvage("coil of wire", 10))); + let start = scene.player; + press(&mut scene, &mut ctx, KeyCode::Char('i')); + for c in "/wire".chars() { + press(&mut scene, &mut ctx, KeyCode::Char(c)); + } + press(&mut scene, &mut ctx, KeyCode::Enter); + press(&mut scene, &mut ctx, KeyCode::Char('d')); + assert_eq!(inventory::lying(&scene.dropped, (None, start)).len(), 1); + press(&mut scene, &mut ctx, KeyCode::Char('q')); + assert!(scene.inventory.is_none()); + press(&mut scene, &mut ctx, KeyCode::Char('g')); + assert!(scene.dropped.is_empty()); + assert!(ctx.hold.gear.pack.iter().any(|s| matches!(s.item, Item::Salvage(_)))); + } + + #[test] + fn the_tavern_trades_rumours_for_rounds() { + let (mut scene, mut ctx) = at_counter(ShopKind::Tavern); + ctx.hold.scrip = 10; + press(&mut scene, &mut ctx, KeyCode::Char('1')); + let first = reply(&scene).to_string(); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert_ne!(reply(&scene), first, "a new rumour each round"); + assert_eq!(ctx.hold.scrip, 10 - 2 * trade::ROUND_PRICE); + } + + #[test] + fn the_shipyard_fits_racks_and_lenses() { + let (mut scene, mut ctx) = at_counter(ShopKind::Shipyard); + ctx.hold.scrip = 2000; + // A full tank: no fuel on offer, so racks are first. + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert_eq!(ctx.hold.ore_capacity, Hold::BASE_ORE_CAPACITY + trade::RACK_TONS); + press(&mut scene, &mut ctx, KeyCode::Char('2')); + assert_eq!(ctx.lenses, 1); + press(&mut scene, &mut ctx, KeyCode::Char('3')); + assert_eq!(ctx.hold.fuel_capacity, Hold::BASE_FUEL + trade::TANK_JUMPS); + } + + #[test] + fn the_shipyard_sells_fuel() { + let (mut scene, mut ctx) = at_counter(ShopKind::Shipyard); + ctx.hold.fuel = 0; + ctx.hold.scrip = 2000; + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert_eq!(ctx.hold.fuel, ctx.hold.fuel_capacity); + assert!(reply(&scene).contains("4 jumps of fuel"), "{}", reply(&scene)); + } +} diff --git a/src/scenes/zz_dump.rs b/src/scenes/zz_dump.rs new file mode 100644 index 0000000..241f342 --- /dev/null +++ b/src/scenes/zz_dump.rs @@ -0,0 +1,62 @@ +//! Temporary: prints screens for a UI review. +use super::tests::test_ctx; +use super::*; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +fn show(name: &str, scene: &dyn Scene, ctx: &Ctx, w: u16, h: u16) { + let mut t = Terminal::new(TestBackend::new(w, h)).unwrap(); + t.draw(|f| scene.render(f, ctx)).unwrap(); + let b = t.backend().buffer(); + let cells: Vec<&str> = b.content.iter().map(|c| c.symbol()).collect(); + println!("===== {name} ({w}x{h})"); + for row in cells.chunks(w as usize) { + println!("{}", row.concat().trim_end()); + } +} +fn k(s: &mut dyn Scene, ctx: &mut Ctx, c: KeyCode) { + s.key(KeyEvent::new(c, KeyModifiers::NONE), ctx); +} + +#[test] +fn zz_dump() { + let mut ctx = test_ctx(); + let dir = crate::save::scratch("dump"); + ctx.saves = Some(dir.clone()); + ctx.slot = Some(crate::save::new_slot(&dir, &ctx.character, ctx.seed)); + ctx.autosave(); + let (w, h) = (120, 40); + show("title", &title::Title::new(&ctx), &ctx, w, h); + show("create", &character::Create::new(&ctx), &ctx, w, h); + show("sheet", &character::Sheet::new(), &ctx, w, h); + show("saves", &saves::Saves::new(&ctx), &ctx, w, h); + show("forge", &forge::Forge::new(&ctx), &ctx, w, h); + let home = ctx.system.home_place(); + let mut a = aboard::Aboard::landed(&ctx, home); + show("aboard", &a, &ctx, w, h); + k(&mut a, &mut ctx, KeyCode::Esc); + show("aboard pause", &a, &ctx, w, h); + let mut a = aboard::Aboard::landed(&ctx, home); + k(&mut a, &mut ctx, KeyCode::Char('i')); + show("aboard inv", &a, &ctx, w, h); + let mut t = town::TownScene::new(&ctx, home); + show("town", &t, &ctx, w, h); + k(&mut t, &mut ctx, KeyCode::Char('m')); + show("town map", &t, &ctx, w, h); + let mut f = flight::FlightScene::new(&ctx); + show("flight", &f, &ctx, w, h); + show("flight narrow", &f, &ctx, 80, 24); + show("aboard 80", &aboard::Aboard::landed(&ctx, home), &ctx, 80, 24); + show("derelict 80", &derelict::Derelict::new(2), &ctx, 80, 24); + k(&mut f, &mut ctx, KeyCode::Esc); + show("flight pause", &f, &ctx, w, h); + show("system map", &system_map::SystemMap::new(&ctx), &ctx, w, h); + show("galaxy map", &galaxy_map::GalaxyMap::new(&ctx), &ctx, w, h); + show("port station", &port::Port::new(&ctx, 0), &ctx, w, h); + let mut d = derelict::Derelict::board(2, &mut ctx); + show("derelict", &d, &ctx, w, h); + k(&mut d, &mut ctx, KeyCode::Char('f')); + show("derelict aim", &d, &ctx, w, h); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/src/space/flight.rs b/src/space/flight.rs new file mode 100644 index 0000000..08f678d --- /dev/null +++ b/src/space/flight.rs @@ -0,0 +1,434 @@ +//! The rules of flight around one place: steering, thrust and drag, the +//! mining laser, breaking rocks, and scooping up the ore they drop. Nothing +//! here draws or reads keys; the scene turns keys into `Controls` and draws +//! the result. +//! +//! The feel is arcade rather than physics: you turn on the spot, thrust +//! pushes along your heading, a little drag stops you drifting forever, and +//! speed is capped. To stop, turn to face backwards (`Controls::retro` does +//! that for you) and thrust. Nothing pulls on you; planets and stations are +//! places to land or dock, not wells to fall into. + +use std::f64::consts::TAU; + +use rand::RngExt; + +use super::locale::{DOCK_RADIUS, Locale, Ore, Rock, Size}; +use super::{Vec2, turn_between}; +use crate::seed::{self, Rng}; + +/// Units per second per second at full thrust. +pub const THRUST: f64 = 26.0; +pub const MAX_SPEED: f64 = 32.0; +/// The fraction of your speed lost each second. +const DRAG: f64 = 0.12; +/// Radians per second. +pub const TURN_RATE: f64 = 3.6; +pub const LASER_RANGE: f64 = 24.0; +/// Seconds before uncollected ore drifts off for good. +pub const FLOTSAM_LIFE: f64 = 45.0; +const PICKUP_RANGE: f64 = 2.5; +/// Land, dock or board only when slower than this. +pub const LANDING_SPEED: f64 = 8.0; +/// Board a wreck only when this close. +pub const BOARDING_RANGE: f64 = 4.0; +pub const SPARK_LIFE: f64 = 0.45; +/// How hard the edge of the map pushes you back, per unit you're past it. +const EDGE_PUSH: f64 = 3.0; + +#[derive(Clone, Copy, Debug, Default)] +pub struct Controls { + /// 0 (engines off) to 1 (full). + pub thrust: f64, + /// -1 (anticlockwise) to 1 (clockwise), as a fraction of `TURN_RATE`. + pub turn: f64, + /// Turn to face against your direction of travel, ready to brake. + pub retro: bool, + pub firing: bool, +} + +pub struct Ship { + pub pos: Vec2, + pub vel: Vec2, + /// 0 is up the screen; see `Vec2::from_heading`. + pub heading: f64, + pub thrusting: bool, +} + +/// Ore floating free after a rock breaks, waiting to be scooped up. +pub struct Flotsam { + pub pos: Vec2, + pub vel: Vec2, + pub ore: &'static Ore, + pub age: f64, +} + +/// A spark of engine exhaust. +pub struct Spark { + pub pos: Vec2, + pub vel: Vec2, + pub age: f64, +} + +/// The mining laser, as fired this step. +pub struct Beam { + pub from: Vec2, + pub to: Vec2, + /// Whether it ended on something rather than at full range. + pub hit: bool, +} + +#[derive(Debug, PartialEq)] +pub enum Event { + Shattered { + size: Size, + ore: Option<&'static str>, + }, + Collected(&'static Ore), + /// Flew over ore with no room for it. + HoldFull, +} + +pub struct Flight { + pub locale: Locale, + /// Seconds since you arrived. + pub time: f64, + pub ship: Ship, + pub flotsam: Vec<Flotsam>, + pub sparks: Vec<Spark>, + pub beam: Option<Beam>, + /// How fast the laser cuts: 1 as fitted, more with better lenses. + pub laser_power: f64, + rng: Rng, +} + +impl Flight { + /// Arrives at `locale`, facing whatever's in the middle of it. + pub fn new(locale: Locale, seed: u64) -> Flight { + let pos = locale.arrival; + let heading = if pos.length() > 0.0 { (-pos).heading() } else { 0.0 }; + Flight { + locale, + time: 0.0, + ship: Ship { pos, vel: Vec2::ZERO, heading, thrusting: false }, + flotsam: Vec::new(), + sparks: Vec::new(), + beam: None, + laser_power: 1.0, + rng: seed::rng(seed::derive(seed, "flight", 0)), + } + } + + pub fn speed(&self) -> f64 { + self.ship.vel.length() + } + + /// Whether you're over the planet or moon in the middle of the map. + pub fn over_body(&self) -> bool { + self.locale.body.as_ref().is_some_and(|b| self.ship.pos.length() <= b.radius) + } + + /// Whether you're close enough to the station to dock. + pub fn at_dock(&self) -> bool { + self.locale.dock.is_some() && self.ship.pos.length() <= DOCK_RADIUS + } + + /// The wreck close enough to board, if any, as an index into + /// `locale.wrecks`. + pub fn wreck_alongside(&self) -> Option<usize> { + self.locale.wrecks.iter().position(|w| w.pos.distance(self.ship.pos) <= BOARDING_RANGE) + } + + /// Whether you're beyond the edge of the map, being pushed back. + pub fn past_edge(&self) -> bool { + let e = self.locale.extent; + self.ship.pos.x.abs() > e || self.ship.pos.y.abs() > e + } + + /// Whether you're pointing (near enough) against your direction of travel. + pub fn facing_retro(&self) -> bool { + let backwards = (-self.ship.vel).heading(); + self.speed() < 0.5 || turn_between(self.ship.heading, backwards).abs() < 0.05 + } + + /// Advances the world by `dt` seconds. `room` is how many more tons of + /// ore the hold can take. + pub fn step(&mut self, dt: f64, controls: Controls, room: u32) -> Vec<Event> { + let mut events = Vec::new(); + self.time += dt; + let extent = self.locale.extent; + for rock in &mut self.locale.rocks { + rock.step(dt, extent); + } + self.steer(dt, controls); + self.fire(dt, controls.firing, &mut events); + + for spark in &mut self.sparks { + spark.pos += spark.vel * dt; + spark.age += dt; + } + self.sparks.retain(|s| s.age < SPARK_LIFE); + + let mut collected = 0; + for f in &mut self.flotsam { + f.pos += f.vel * dt; + f.vel = f.vel * (1.0 - 0.3 * dt); + f.age += dt; + if f.pos.distance(self.ship.pos) <= PICKUP_RANGE { + if collected < room { + collected += 1; + f.age = f64::INFINITY; + events.push(Event::Collected(f.ore)); + } else if !events.contains(&Event::HoldFull) { + events.push(Event::HoldFull); + } + } + } + self.flotsam.retain(|f| f.age < FLOTSAM_LIFE); + events + } + + fn steer(&mut self, dt: f64, controls: Controls) { + let ship = &mut self.ship; + ship.heading += controls.turn.clamp(-1.0, 1.0) * TURN_RATE * dt; + if controls.retro && ship.vel.length() > 0.5 { + let off = turn_between(ship.heading, (-ship.vel).heading()); + let most = TURN_RATE * dt; + ship.heading += off.clamp(-most, most); + } + ship.heading = ship.heading.rem_euclid(TAU); + + let ahead = Vec2::from_heading(ship.heading); + let thrust = controls.thrust.clamp(0.0, 1.0); + ship.thrusting = thrust > 0.0; + if ship.thrusting { + ship.vel += ahead * (THRUST * thrust * dt); + // Exhaust: a spark out the back, thrown off a little to one side. + let side = Vec2::new(-ahead.y, ahead.x) * self.rng.random_range(-2.5..2.5); + self.sparks.push(Spark { + pos: ship.pos - ahead, + vel: ship.vel - ahead * self.rng.random_range(10.0..16.0) + side, + age: 0.0, + }); + } + // The edge of the map: a spring that pushes you back in. + let e = self.locale.extent; + let over = |v: f64| (v.abs() - e).max(0.0) * v.signum(); + ship.vel -= Vec2::new(over(ship.pos.x), over(ship.pos.y)) * (EDGE_PUSH * dt); + + ship.vel = ship.vel * (1.0 - DRAG * dt); + if ship.vel.length() > MAX_SPEED { + ship.vel = ship.vel.normalised() * MAX_SPEED; + } + ship.pos += ship.vel * dt; + } + + /// Casts the laser along your heading, wearing down the first rock it + /// meets. The planet or station in the middle stops it too, unharmed. + fn fire(&mut self, dt: f64, firing: bool, events: &mut Vec<Event>) { + self.beam = None; + if !firing { + return; + } + let ahead = Vec2::from_heading(self.ship.heading); + let from = self.ship.pos + ahead; + let mut nearest: Option<(f64, Option<usize>)> = None; + let mut consider = |centre: Vec2, r: f64, what: Option<usize>| { + if let Some(d) = ray_hits_circle(from, ahead, centre, r) + && d <= LASER_RANGE + && nearest.is_none_or(|(n, _)| d < n) + { + nearest = Some((d, what)); + } + }; + for (i, rock) in self.locale.rocks.iter().enumerate() { + consider(rock.pos, rock.size.radius(), Some(i)); + } + if let Some(r) = self.locale.solid_radius() { + consider(Vec2::ZERO, r, None); + } + let reach = nearest.map_or(LASER_RANGE, |(d, _)| d); + self.beam = Some(Beam { from, to: from + ahead * reach, hit: nearest.is_some() }); + if let Some((_, Some(i))) = nearest { + self.locale.rocks[i].integrity -= dt * self.laser_power; + if self.locale.rocks[i].integrity <= 0.0 { + self.shatter(i, events); + } + } + } + + /// Breaks a rock into two smaller ones drifting apart, and spills its + /// ore, if it had any. + fn shatter(&mut self, i: usize, events: &mut Vec<Event>) { + let rock = self.locale.rocks.swap_remove(i); + let rng = &mut self.rng; + let vel = rock.velocity(); + if let Some(size) = rock.size.smaller() { + let apart = Vec2::from_heading(rng.random_range(0.0..TAU)); + for side in [-1.0, 1.0] { + let pos = rock.pos + apart * (side * rock.size.radius() * 0.6); + let child = if rock.circling == 0.0 { + let kick = apart * (side * rng.random_range(0.5..1.5)); + Rock::new(pos, rock.vel + kick, 0.0, size, rock.ore) + } else { + // Keep circling at about the parent's distance. + let circling = rock.circling * rng.random_range(0.9..1.1); + Rock::new(pos, Vec2::ZERO, circling, size, rock.ore) + }; + self.locale.rocks.push(child); + } + } + if let Some(ore) = rock.ore { + let chunks = match rock.size { + Size::Small => 1, + Size::Medium => 2, + Size::Large => 3, + }; + for _ in 0..chunks { + let drift = + Vec2::from_heading(rng.random_range(0.0..TAU)) * rng.random_range(1.0..4.0); + self.flotsam.push(Flotsam { pos: rock.pos, vel: vel + drift, ore, age: 0.0 }); + } + } + events.push(Event::Shattered { size: rock.size, ore: rock.ore.map(|o| o.name) }); + } +} + +/// How far along a ray (from `from`, along unit vector `dir`) it first +/// touches a circle, if it does. Zero if it starts inside. +fn ray_hits_circle(from: Vec2, dir: Vec2, centre: Vec2, r: f64) -> Option<f64> { + let to_centre = centre - from; + let along = to_centre.dot(dir); + let miss_sq = to_centre.dot(to_centre) - along * along; + if along < -r || miss_sq > r * r { + return None; + } + Some((along - (r * r - miss_sq).sqrt()).max(0.0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::locale::{self, ORES}; + use crate::space::system::{self, PlaceKind}; + + const DT: f64 = 1.0 / 30.0; + + /// Flying in the belt of system 1, with the rocks cleared away. + fn flight() -> Flight { + let s = system::generate(1); + let belt = s.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap(); + let mut locale = locale::generate(&s, belt, 0.0, None, &Default::default()); + locale.rocks.clear(); + Flight::new(locale, 1) + } + + fn run(f: &mut Flight, seconds: f64, controls: Controls) -> Vec<Event> { + let mut events = Vec::new(); + for _ in 0..(seconds / DT).round() as usize { + events.extend(f.step(DT, controls, 99)); + } + events + } + + /// A rock that sits still at `pos`. + fn rock_at(pos: Vec2, size: Size) -> Rock { + Rock::new(pos, Vec2::ZERO, 0.0, size, Some(&ORES[0])) + } + + #[test] + fn thrust_pushes_along_the_heading_up_to_a_limit() { + let mut f = flight(); + f.ship.heading = TAU / 4.0; + run(&mut f, 1.0, Controls { thrust: 1.0, ..Controls::default() }); + assert!(f.ship.vel.x > 15.0 && f.ship.vel.y.abs() < 1e-6, "heading right goes right"); + run(&mut f, 3.0, Controls { thrust: 1.0, ..Controls::default() }); + assert!((f.speed() - MAX_SPEED).abs() < 1e-6, "speed is capped"); + } + + #[test] + fn drag_slows_you_when_coasting() { + let mut f = flight(); + f.ship.vel = Vec2::new(20.0, 0.0); + run(&mut f, 3.0, Controls::default()); + assert!(f.speed() < 15.0 && f.speed() > 5.0, "coasting decays, slowly: {}", f.speed()); + } + + #[test] + fn retro_turns_you_to_face_backwards() { + let mut f = flight(); + f.ship.vel = Vec2::new(0.0, -10.0); + f.ship.heading = 0.0; + assert!(!f.facing_retro()); + run(&mut f, 1.5, Controls { retro: true, ..Controls::default() }); + assert!(f.facing_retro(), "heading {} after retro", f.ship.heading); + } + + #[test] + fn the_edge_pushes_you_back() { + let mut f = flight(); + f.ship.pos = Vec2::new(f.locale.extent - 5.0, 0.0); + f.ship.vel = Vec2::new(MAX_SPEED, 0.0); + run(&mut f, 5.0, Controls::default()); + assert!(f.ship.vel.x < 0.0, "turned round"); + assert!(f.ship.pos.x < f.locale.extent + 30.0); + } + + #[test] + fn mining_breaks_rocks_and_ore_can_be_scooped_up() { + let mut f = flight(); + f.ship.pos = Vec2::new(50.0, 0.0); + f.ship.heading = 0.0; + f.locale.rocks = vec![rock_at(Vec2::new(50.0, -10.0), Size::Medium)]; + // Long enough to break the rock, not long enough to break its pieces. + let events = run(&mut f, 1.1, Controls { firing: true, ..Controls::default() }); + assert!(events.contains(&Event::Shattered { size: Size::Medium, ore: Some("iron ore") })); + assert_eq!(f.locale.rocks.len(), 2, "a medium rock breaks in two"); + assert!(f.locale.rocks.iter().all(|r| r.size == Size::Small)); + assert_eq!(f.flotsam.len(), 2, "a medium rock spills two chunks"); + + f.ship.pos = f.flotsam[0].pos; + let events = f.step(DT, Controls::default(), 1); + assert!(events.iter().any(|e| matches!(e, Event::Collected(_)))); + } + + #[test] + fn a_full_hold_leaves_ore_floating() { + let mut f = flight(); + f.flotsam = vec![Flotsam { pos: f.ship.pos, vel: Vec2::ZERO, ore: &ORES[0], age: 0.0 }]; + let events = f.step(DT, Controls::default(), 0); + assert_eq!(events, vec![Event::HoldFull]); + assert_eq!(f.flotsam.len(), 1); + } + + #[test] + fn the_laser_stops_at_the_first_rock() { + let mut f = flight(); + f.ship.pos = Vec2::new(50.0, 0.0); + f.ship.heading = 0.0; + f.locale.rocks = vec![ + rock_at(Vec2::new(50.0, -15.0), Size::Small), + rock_at(Vec2::new(50.0, -6.0), Size::Small), + ]; + f.step(DT, Controls { firing: true, ..Controls::default() }, 0); + let beam = f.beam.as_ref().unwrap(); + assert!(beam.hit && beam.to.y > -6.0 && beam.to.y < -5.0, "beam ends at {:?}", beam.to); + assert!(f.locale.rocks[1].integrity < f.locale.rocks[0].integrity); + } + + #[test] + fn planets_stop_the_laser_and_can_be_landed_on() { + let s = system::generate(1); + let mut f = + Flight::new(locale::generate(&s, s.home_place(), 0.0, None, &Default::default()), 1); + f.locale.rocks.clear(); + let radius = f.locale.body.as_ref().unwrap().radius; + assert!(!f.over_body()); + f.step(DT, Controls { firing: true, ..Controls::default() }, 0); + let beam = f.beam.as_ref().unwrap(); + assert!(beam.hit, "you arrive facing the planet, in range"); + assert!((beam.to.length() - radius).abs() < 1e-6); + f.ship.pos = Vec2::new(0.0, radius - 1.0); + assert!(f.over_body()); + } +} diff --git a/src/space/galaxy.rs b/src/space/galaxy.rs new file mode 100644 index 0000000..bb06667 --- /dev/null +++ b/src/space/galaxy.rs @@ -0,0 +1,573 @@ +//! The galaxy: about ninety star systems, the lanes between them, and the +//! four peoples' territories. +//! +//! You can't fly between systems; you jump, and only along a lane. Lanes +//! join near neighbours: every pair in the relative neighbourhood graph +//! (no third star closer to both than they are to each other), which keeps +//! the map connected and its lanes from crossing, plus some of the extra +//! pairs of the Gabriel graph, for a few loops. To reach a far system you +//! jump through the ones between. +//! +//! Stars are scattered across a lopsided disc, denser in some places than +//! others, with a few empty voids that the lanes have to bridge. +//! +//! Each people has a seat: the system the universe started you in is one, +//! for whoever holds it, and the other three are spread out from it. +//! Territory grows from each seat along the lanes, each people at its own +//! pace, so territories come out in ragged blobs of different sizes. Every +//! system ends up held by someone. +//! +//! What a system is like (its kind) depends on where it lies: settled +//! systems around the seats, frontier and desolate ones further out, and +//! old battlefields along the borders, where the neighbours settle too. +//! +//! A galaxy only knows what it takes to draw the map. Each system is +//! generated whole when you need it, from its `Site`. + +use std::cmp::Reverse; +use std::collections::{BTreeSet, BinaryHeap}; +use std::f64::consts::TAU; + +use rand::RngExt; +use rand::seq::{IndexedRandom, SliceRandom}; + +use super::Vec2; +use super::system::{self, Profile, StarKind, System, SystemKind}; +use crate::names::Culture; +use crate::seed::{self, Rng}; + +/// How many systems a galaxy has, if they all fit. +pub const SYSTEMS: usize = 90; +/// Half the galaxy's width and height, in light-years. +pub const HALF_WIDTH: f64 = 100.0; +pub const HALF_HEIGHT: f64 = 62.0; +/// No two stars are closer than this, in light-years. +const SPACING: f64 = 9.0; +/// Lanes per system, at most, counting only the extra loops. +const MAX_LANES: usize = 4; + +/// A star system as the map knows it, before it's generated. +pub struct Site { + pub name: String, + /// In light-years from the middle of the galaxy, y up. + pub pos: Vec2, + /// Who holds it. + pub culture: Culture, + pub kind: SystemKind, + pub star: &'static StarKind, + /// The systems a lane joins it to. + pub lanes: Vec<usize>, + /// For a system on a border: the people across it. + pub neighbour: Option<Culture>, + seed: u64, +} + +pub struct Galaxy { + pub sites: Vec<Site>, + /// The system the universe started everyone in, before there was a + /// galaxy. Saves from then are there. + pub home: usize, + /// Each people's seat, in `Culture::ALL` order. + seats: [usize; 4], +} + +impl Galaxy { + /// Generates a system in full. + pub fn system(&self, site: usize) -> System { + let s = &self.sites[site]; + if site == self.home { + return system::generate(s.seed); + } + let profile = Profile { + name: s.name.clone(), + culture: s.culture, + star: s.star, + kind: s.kind, + neighbour: s.neighbour, + }; + system::generate_as(s.seed, &profile) + } + + /// The seat of a people. + pub fn seat(&self, culture: Culture) -> usize { + self.seats[culture as usize] + } + + pub fn is_seat(&self, site: usize) -> bool { + self.seats.contains(&site) + } + + /// Light-years between two systems. + pub fn distance(&self, a: usize, b: usize) -> f64 { + self.sites[a].pos.distance(self.sites[b].pos) + } + + /// Days a jump takes from one system to another, along a lane. + pub fn jump_days(&self, a: usize, b: usize) -> f64 { + 1.0 + self.distance(a, b) / 5.0 + } + + /// Days to jump along a route from `from`. + pub fn route_days(&self, from: usize, route: &[usize]) -> f64 { + let mut at = from; + let mut days = 0.0; + for &next in route { + days += self.jump_days(at, next); + at = next; + } + days + } + + /// The systems you know the way to: those you've been to, and those a + /// lane joins to them. + pub fn charted(&self, visited: &BTreeSet<usize>) -> Vec<bool> { + let mut charted = vec![false; self.sites.len()]; + for &v in visited.iter().filter(|&&v| v < self.sites.len()) { + charted[v] = true; + for &n in &self.sites[v].lanes { + charted[n] = true; + } + } + charted + } + + /// The fewest jumps from `from` to `to`, going only through systems + /// that are `passable`, and the shortest such way. Doesn't include + /// `from`, ends with `to`, and is empty if they're the same. + pub fn route( + &self, + from: usize, + to: usize, + passable: impl Fn(usize) -> bool, + ) -> Option<Vec<usize>> { + self.search(from, passable, |s| s == to) + } + + /// The fewest jumps to the nearest system that is `wanted`, through any + /// system at all. + pub fn nearest(&self, from: usize, wanted: impl Fn(usize) -> bool) -> Option<Vec<usize>> { + self.search(from, |_| true, wanted) + } + + fn search( + &self, + from: usize, + passable: impl Fn(usize) -> bool, + goal: impl Fn(usize) -> bool, + ) -> Option<Vec<usize>> { + let n = self.sites.len(); + // Jumps first, then distance, in hundredths of a light-year. + let cost = |jumps: u64, ly: f64| jumps * 1_000_000_000 + (ly * 100.0) as u64; + let mut best = vec![u64::MAX; n]; + let mut came_from = vec![usize::MAX; n]; + let mut queue = BinaryHeap::new(); + best[from] = 0; + queue.push(Reverse((0, 0u64, 0.0f64.to_bits(), from))); + while let Some(Reverse((c, jumps, ly, at))) = queue.pop() { + if c > best[at] { + continue; + } + if goal(at) { + let mut route = vec![]; + let mut step = at; + while step != from { + route.push(step); + step = came_from[step]; + } + route.reverse(); + return Some(route); + } + for &next in &self.sites[at].lanes { + if !passable(next) { + continue; + } + let ly = f64::from_bits(ly) + self.distance(at, next); + let c = cost(jumps + 1, ly); + if c < best[next] { + best[next] = c; + came_from[next] = at; + queue.push(Reverse((c, jumps + 1, ly.to_bits(), next))); + } + } + } + None + } + + /// The corners of a box round every star, in light-years. + pub fn bounds(&self) -> (Vec2, Vec2) { + let mut low = Vec2::new(f64::MAX, f64::MAX); + let mut high = Vec2::new(f64::MIN, f64::MIN); + for s in &self.sites { + low = Vec2::new(low.x.min(s.pos.x), low.y.min(s.pos.y)); + high = Vec2::new(high.x.max(s.pos.x), high.y.max(s.pos.y)); + } + (low, high) + } +} + +pub fn generate(universe: u64) -> Galaxy { + let home_seed = seed::derive(universe, "system", 0); + let home_system = system::generate(home_seed); + let mut rng = seed::rng(seed::derive(universe, "galaxy", 0)); + + let points = scatter(&mut rng); + let n = points.len(); + let lanes = join(&points, &mut rng); + + // The seats, as far apart as can be without crowding the rim. + let seat_sites = place_seats(&points, &mut rng); + let mut cultures = Culture::ALL; + cultures.shuffle(&mut rng); + // Whoever holds the original system has their seat there. + let mut seats = [0; 4]; + for (&site, &culture) in seat_sites.iter().zip(&cultures) { + seats[culture as usize] = site; + } + let home = seats[home_system.culture as usize]; + + let owner = grow(&lanes, &points, &seats, universe, &mut rng); + + // What each system is, given where it lies. + let hops_from_seat = |i: usize| { + let seat = seats[owner[i] as usize]; + if i == seat { + 0 + } else if lanes[i].contains(&seat) { + 1 + } else { + 2 + } + }; + let mut names: BTreeSet<String> = BTreeSet::new(); + names.insert(home_system.name.clone()); + let mut sites = Vec::with_capacity(n); + for i in 0..n { + let culture = owner[i]; + let mut across: Vec<Culture> = + lanes[i].iter().map(|&j| owner[j]).filter(|&c| c != culture).collect(); + across.sort_by_key(|&c| c as usize); + // The people most of its foreign lanes lead to. + let neighbour = Culture::ALL + .into_iter() + .filter(|c| across.contains(c)) + .max_by_key(|&c| across.iter().filter(|&&x| x == c).count()); + let kind = if seats.contains(&i) { + SystemKind::Seat + } else { + pick_kind(hops_from_seat(i), neighbour.is_some(), lanes[i].len() == 1, &mut rng) + }; + let (name, star, seed) = if i == home { + (home_system.name.clone(), home_system.star, home_seed) + } else { + let name = loop { + let name = culture.word(&mut rng); + if names.insert(name.clone()) { + break name; + } + }; + let star = system::random_star(kind, &mut rng); + (name, star, seed::derive(universe, "system", i as u64 + 1)) + }; + sites.push(Site { + name, + pos: points[i], + culture, + kind, + star, + lanes: lanes[i].clone(), + neighbour, + seed, + }); + } + Galaxy { sites, home, seats } +} + +/// Where the stars are: scattered over a lopsided disc, clumped here and +/// thin there, with a few empty voids. +fn scatter(rng: &mut Rng) -> Vec<Vec2> { + let lobes = [ + (rng.random_range(0.06..0.14), rng.random_range(0.0..TAU)), + (rng.random_range(0.03..0.08), rng.random_range(0.0..TAU)), + ]; + let inside = |p: Vec2| { + let (x, y) = (p.x / HALF_WIDTH, p.y / HALF_HEIGHT); + let angle = y.atan2(x); + let edge = 1.0 + + lobes[0].0 * (2.0 * angle + lobes[0].1).sin() + + lobes[1].0 * (3.0 * angle + lobes[1].1).sin(); + x.hypot(y) < edge + }; + let voids: Vec<(Vec2, f64)> = (0..rng.random_range(2..=3)) + .map(|_| { + let angle = rng.random_range(0.0..TAU); + let r = rng.random_range(0.2..0.7); + let at = Vec2::new(angle.cos() * r * HALF_WIDTH, angle.sin() * r * HALF_HEIGHT); + (at, rng.random_range(9.0..15.0)) + }) + .collect(); + let phase = (rng.random_range(0.0..TAU), rng.random_range(0.0..TAU)); + let clumping = |p: Vec2| { + let wave = (p.x / 23.0 + phase.0).sin() * (p.y / 17.0 + phase.1).sin(); + 0.45 + 0.55 * (0.5 + 0.5 * wave) + }; + let mut points: Vec<Vec2> = Vec::new(); + for _ in 0..200_000 { + if points.len() == SYSTEMS { + break; + } + let p = Vec2::new( + rng.random_range(-1.2 * HALF_WIDTH..1.2 * HALF_WIDTH), + rng.random_range(-1.2 * HALF_HEIGHT..1.2 * HALF_HEIGHT), + ); + if !inside(p) + || !rng.random_bool(clumping(p)) + || voids.iter().any(|&(c, r)| p.distance(c) < r) + || points.iter().any(|q| q.distance(p) < SPACING) + { + continue; + } + points.push(p); + } + points +} + +/// The lanes: every pair in the relative neighbourhood graph, and some of +/// the Gabriel graph's extra pairs, shortest first. Each system's list is +/// sorted, so it's the same every time. +fn join(points: &[Vec2], rng: &mut Rng) -> Vec<Vec<usize>> { + let n = points.len(); + let d = |i: usize, j: usize| points[i].distance(points[j]); + let mut lanes = vec![Vec::new(); n]; + let mut extras = Vec::new(); + for i in 0..n { + for j in i + 1..n { + let dij = d(i, j); + let others = (0..n).filter(|&k| k != i && k != j); + if !others.clone().any(|k| d(i, k).max(d(j, k)) < dij) { + lanes[i].push(j); + lanes[j].push(i); + } else if !others.into_iter().any(|k| d(i, k).powi(2) + d(j, k).powi(2) < dij * dij) { + extras.push((dij, i, j)); + } + } + } + extras.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (_, i, j) in extras { + if lanes[i].len() < MAX_LANES && lanes[j].len() < MAX_LANES && rng.random_bool(0.45) { + lanes[i].push(j); + lanes[j].push(i); + } + } + for l in &mut lanes { + l.sort_unstable(); + } + lanes +} + +/// Four seats spread as far apart as they'll go, none of them out on the +/// very rim. +fn place_seats(points: &[Vec2], rng: &mut Rng) -> [usize; 4] { + let rim = |p: Vec2| (p.x / HALF_WIDTH).hypot(p.y / HALF_HEIGHT); + let mut pool: Vec<usize> = (0..points.len()).filter(|&i| rim(points[i]) < 0.7).collect(); + if pool.len() < 4 { + pool = (0..points.len()).collect(); + } + let mut best = [0, 1, 2, 3]; + let mut best_gap = f64::MIN; + for _ in 0..400 { + pool.shuffle(rng); + let pick = [pool[0], pool[1], pool[2], pool[3]]; + let mut gap = f64::MAX; + for a in 0..4 { + for b in a + 1..4 { + gap = gap.min(points[pick[a]].distance(points[pick[b]])); + } + } + if gap > best_gap { + best_gap = gap; + best = pick; + } + } + best +} + +/// Who holds each system: each people spreads out from its seat along the +/// lanes, at a pace of its own, and claims whatever it reaches first. +fn grow( + lanes: &[Vec<usize>], + points: &[Vec2], + seats: &[usize; 4], + universe: u64, + rng: &mut Rng, +) -> Vec<Culture> { + let pace: Vec<f64> = (0..4).map(|_| rng.random_range(0.75..1.3)).collect(); + let mut owner: Vec<Option<Culture>> = vec![None; points.len()]; + let mut queue = BinaryHeap::new(); + for c in Culture::ALL { + queue.push(Reverse((0u64, seats[c as usize], c as usize))); + } + while let Some(Reverse((cost, at, c))) = queue.pop() { + if owner[at].is_some() { + continue; + } + owner[at] = Some(Culture::ALL[c]); + for &next in &lanes[at] { + if owner[next].is_some() { + continue; + } + // Some lanes are harder going for some peoples than others. + let rough = seed::derive(universe, "border", (at * points.len() + next) as u64 * 4); + let rough = 0.65 + 0.8 * ((rough >> 11) as f64 / (1u64 << 53) as f64); + let step = points[at].distance(points[next]) * pace[c] * rough; + queue.push(Reverse((cost + (step * 1000.0) as u64, next, c))); + } + } + // The lanes join everything, so everything is reached; but just in case. + owner.into_iter().map(|o| o.unwrap_or(Culture::Drift)).collect() +} + +/// A kind for a system `hops` from its own seat (two meaning two or more), +/// maybe on a border, maybe at the end of a lane to nowhere else. +fn pick_kind(hops: u32, border: bool, dead_end: bool, rng: &mut Rng) -> SystemKind { + use SystemKind::*; + let mut weights: Vec<(SystemKind, u32)> = if hops <= 1 { + vec![(Settled, 70), (Frontier, 8), (Rubble, 16), (Desolate, 4), (Graveyard, 2)] + } else if border { + vec![(Settled, 22), (Frontier, 22), (Rubble, 12), (Desolate, 20), (Graveyard, 24)] + } else { + vec![(Settled, 40), (Frontier, 26), (Rubble, 16), (Desolate, 14), (Graveyard, 4)] + }; + if dead_end { + for (kind, w) in &mut weights { + if matches!(kind, Frontier | Desolate) { + *w *= 2; + } + } + } + weights.choose_weighted(rng, |&(_, w)| w).unwrap().0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::system::PlaceKind; + + #[test] + fn galaxies_are_whole() { + for universe in 0..12 { + let g = generate(universe); + let n = g.sites.len(); + assert!(n >= SYSTEMS - 5, "universe {universe}: only {n} systems"); + // Every system can be reached from every other. + for to in 0..n { + assert!(g.route(g.home, to, |_| true).is_some(), "universe {universe}: {to}"); + } + // Lanes go both ways, and no star sits on top of another. + for (i, s) in g.sites.iter().enumerate() { + assert!(!s.lanes.is_empty()); + for &j in &s.lanes { + assert!(g.sites[j].lanes.contains(&i)); + } + for j in i + 1..n { + assert!(g.distance(i, j) >= SPACING); + } + } + let names: BTreeSet<&str> = g.sites.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names.len(), n, "universe {universe}: names repeat"); + } + } + + #[test] + fn each_people_holds_one_patch_round_its_seat() { + for universe in 0..12 { + let g = generate(universe); + for c in Culture::ALL { + let seat = g.seat(c); + assert_eq!(g.sites[seat].culture, c); + assert_eq!(g.sites[seat].kind, SystemKind::Seat); + // Every system they hold is reachable without leaving + // their space. + for (i, s) in g.sites.iter().enumerate().filter(|(_, s)| s.culture == c) { + let inside = g.route(seat, i, |j| g.sites[j].culture == c); + assert!(inside.is_some(), "universe {universe}: {} cut off", s.name); + } + let held = g.sites.iter().filter(|s| s.culture == c).count(); + assert!(held >= 6, "universe {universe}: {} hold only {held}", c.name()); + } + } + } + + #[test] + fn the_original_system_is_its_peoples_seat() { + for universe in [0, 7, 1312] { + let g = generate(universe); + let old = crate::space::home_system(universe); + let s = g.system(g.home); + assert_eq!(s.name, old.name); + assert_eq!(s.places.len(), old.places.len()); + assert_eq!(g.seat(old.culture), g.home); + assert_eq!(g.sites[g.home].name, old.name); + } + } + + #[test] + fn systems_are_what_the_map_says() { + let g = generate(3); + for (i, site) in g.sites.iter().enumerate() { + let s = g.system(i); + assert_eq!( + (s.name.as_str(), s.culture, s.kind), + (site.name.as_str(), site.culture, site.kind) + ); + assert_eq!(s.star.name, site.star.name); + assert_eq!(s.sells_fuel(), site.kind.sells_fuel(), "{}: {:?}", s.name, s.kind); + assert!(matches!( + s.places[s.edge_place()].kind, + PlaceKind::Planet(_) | PlaceKind::Belt + )); + } + } + + #[test] + fn there_is_variety() { + let mut seen = BTreeSet::new(); + for universe in 0..4 { + for s in generate(universe).sites { + seen.insert(format!("{:?}", s.kind)); + } + } + assert_eq!(seen.len(), SystemKind::ALL.len(), "{seen:?}"); + } + + #[test] + fn routes_take_the_fewest_jumps_through_what_you_allow() { + let g = generate(5); + let far = (0..g.sites.len()) + .max_by(|&a, &b| g.distance(g.home, a).total_cmp(&g.distance(g.home, b))); + let far = far.unwrap(); + let route = g.route(g.home, far, |_| true).unwrap(); + assert!(route.len() >= 3, "the far side is several jumps away"); + assert_eq!(*route.last().unwrap(), far); + for pair in + std::iter::once(g.home).chain(route.iter().copied()).collect::<Vec<_>>().windows(2) + { + assert!(g.sites[pair[0]].lanes.contains(&pair[1]), "jumps follow lanes"); + } + assert_eq!(g.route(g.home, g.home, |_| true), Some(vec![])); + assert_eq!(g.route(g.home, far, |s| s == g.home), None, "blocked"); + let mut visited = BTreeSet::new(); + visited.insert(g.home); + let charted = g.charted(&visited); + assert_eq!(charted.iter().filter(|&&c| c).count(), 1 + g.sites[g.home].lanes.len()); + } + + #[test] + fn same_universe_same_galaxy() { + let a = generate(42); + let b = generate(42); + let key = |g: &Galaxy| { + g.sites.iter().map(|s| (s.name.clone(), s.lanes.clone(), s.kind)).collect::<Vec<_>>() + }; + assert_eq!(key(&a), key(&b)); + } +} diff --git a/src/space/locale.rs b/src/space/locale.rs new file mode 100644 index 0000000..d6339b9 --- /dev/null +++ b/src/space/locale.rs @@ -0,0 +1,406 @@ +//! The local map of one place: what you fly around in once you arrive. +//! +//! A planet's orbit has the planet filling the middle of the map, a few +//! rocks circling it, and its moons hanging far off in the sky. A moon is +//! the same, smaller, with its planet looming behind. The belt is all rocks. +//! A station sits among the Trojan asteroids that collect at its Lagrange +//! point. +//! +//! The layout (which way things lie) is fixed for each place. The rocks are +//! regenerated for each day you visit, so a mined-out place fills up again +//! if you come back another day. The wrecks are whichever are calling that +//! day, and any that were calling when you set out for the place, less +//! those you've stripped: a hulk you've emptied is still there while you +//! are, and gone when you come back. Each wreck keeps its own spot for as +//! long as it lasts. The star lights everything from the direction it +//! really is in on that date. + +use std::collections::HashSet; +use std::f64::consts::TAU; + +use rand::RngExt; +use rand::seq::IndexedRandom; + +use super::Vec2; +use super::system::{BodyKind, PlaceKind, Rgb, System, Wreck}; +use crate::names::{self, Culture}; +use crate::seed::{self, Rng}; + +#[derive(Debug, PartialEq)] +pub struct Ore { + pub name: &'static str, + /// Scrip per ton, before local prices. + pub value: u32, + pub rgb: Rgb, + weight: u32, +} + +#[rustfmt::skip] +pub const ORES: &[Ore] = &[ + Ore { name: "iron ore", value: 4, rgb: (178, 112, 88), weight: 10 }, + Ore { name: "nickel", value: 6, rgb: (176, 182, 160), weight: 7 }, + Ore { name: "iridium", value: 14, rgb: (200, 214, 236), weight: 4 }, + Ore { name: "saltglass", value: 22, rgb: (240, 222, 196), weight: 2 }, + Ore { name: "vesper crystal", value: 35, rgb: (196, 124, 255), weight: 1 }, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Size { + Small, + Medium, + Large, +} + +impl Size { + pub fn radius(self) -> f64 { + match self { + Size::Small => 0.8, + Size::Medium => 1.6, + Size::Large => 2.6, + } + } + + /// Seconds of laser fire to break it. + pub fn toughness(self) -> f64 { + match self { + Size::Small => 0.4, + Size::Medium => 1.0, + Size::Large => 2.0, + } + } + + /// What it breaks into, if anything. + pub fn smaller(self) -> Option<Size> { + match self { + Size::Small => None, + Size::Medium => Some(Size::Small), + Size::Large => Some(Size::Medium), + } + } +} + +pub struct Rock { + pub pos: Vec2, + /// Drift in a straight line, in units per second. + pub vel: Vec2, + /// Turning around the middle of the map, in radians per second: rocks in + /// a planet's orbit circle it rather than drifting through it. + pub circling: f64, + pub size: Size, + pub ore: Option<&'static Ore>, + /// Seconds of laser fire left before it breaks. + pub integrity: f64, +} + +impl Rock { + pub fn new(pos: Vec2, vel: Vec2, circling: f64, size: Size, ore: Option<&'static Ore>) -> Rock { + Rock { pos, vel, circling, size, ore, integrity: size.toughness() } + } + + pub fn velocity(&self) -> Vec2 { + self.vel + Vec2::new(-self.pos.y, self.pos.x) * self.circling + } + + pub fn step(&mut self, dt: f64, extent: f64) { + self.pos = self.pos.rotated(self.circling * dt) + self.vel * dt; + // Drifters that leave one side come back in the other. + let wrap = |v: f64| (v + extent).rem_euclid(2.0 * extent) - extent; + self.pos = Vec2::new(wrap(self.pos.x), wrap(self.pos.y)); + } +} + +/// The planet or moon in the middle of the map. It sits at the origin. +pub struct Body { + pub name: String, + pub kind: &'static BodyKind, + pub radius: f64, + /// Whether anyone lives there to trade with. + pub settled: bool, +} + +/// The station in the middle of the map. It sits at the origin. +pub struct Dock { + pub name: String, + pub culture: Culture, +} + +/// How close to the middle of a station you must be to dock. +pub const DOCK_RADIUS: f64 = 5.0; + +/// Something far off in the sky: a moon seen from its planet, or the +/// planet behind a moon. You can't fly to it here; it's scenery that barely +/// moves as you do. +pub struct Backdrop { + pub name: String, + pub kind: &'static BodyKind, + /// Where it hangs, relative to the middle of the screen when you're at + /// the middle of the map. + pub pos: Vec2, + pub radius: f64, +} + +/// How much backdrops move as you fly: a tenth as fast as the map. +pub const BACKDROP_PARALLAX: f64 = 0.1; + +pub struct LocalWreck { + pub wreck: Wreck, + pub pos: Vec2, + /// Which way its bow points, for drawing. + pub heading: f64, +} + +pub struct Locale { + pub place: usize, + pub name: String, + /// Where you are, as you'd say it: "in orbit around Qeth, a desert world". + pub what: String, + /// Half the width of the map. Past this the edge pushes you back, and + /// drifting rocks wrap round. + pub extent: f64, + /// Unit vector towards the star, on the map (y down). + pub sun: Vec2, + pub body: Option<Body>, + pub dock: Option<Dock>, + pub backdrops: Vec<Backdrop>, + pub rocks: Vec<Rock>, + pub wrecks: Vec<LocalWreck>, + /// Where you come in. + pub arrival: Vec2, +} + +impl Locale { + /// What you'd crash the laser into, or land on: the body or the station, + /// as a circle at the origin. + pub fn solid_radius(&self) -> Option<f64> { + self.body.as_ref().map(|b| b.radius).or(self.dock.as_ref().map(|_| DOCK_RADIUS - 2.0)) + } +} + +/// From system coordinates (y up) to map coordinates (y down). +fn to_map(v: Vec2) -> Vec2 { + Vec2::new(v.x, -v.y) +} + +/// The map of `place` on `day`, having set out for it on `departed`, +/// without the wrecks you've `stripped`. +pub fn generate( + system: &System, + place: usize, + day: f64, + departed: Option<f64>, + stripped: &HashSet<u64>, +) -> Locale { + let layout_seed = seed::derive(system.seed, "place", place as u64); + let mut rng = seed::rng(layout_seed); + let mut rocks_rng = seed::rng(seed::derive(layout_seed, "rocks", day.floor() as u64)); + + let here = system.position(place, day); + let sun = to_map(-here).normalised(); + let name = system.places[place].name.clone(); + + let mut body = None; + let mut dock = None; + let mut backdrops = Vec::new(); + let mut rocks = Vec::new(); + let (what, extent); + match system.places[place].kind { + PlaceKind::Planet(p) => { + let planet = &system.planets[p]; + what = format!("in orbit around {}, {}", planet.name, names::a(planet.kind.name)); + let radius = planet.kind.size; + extent = 90.0 + radius * 2.0; + body = Some(Body { + name: planet.name.clone(), + kind: planet.kind, + radius, + settled: planet.people.is_some(), + }); + for (m, moon) in planet.moons.iter().enumerate() { + let toward = to_map(moon.orbit.position(day)).normalised(); + backdrops.push(Backdrop { + name: moon.name.clone(), + kind: moon.kind, + pos: toward * (40.0 + 12.0 * m as f64), + radius: moon.kind.size * 0.25, + }); + } + let count = if planet.kind.giant { + rocks_rng.random_range(14..=24) + } else { + rocks_rng.random_range(2..=8) + }; + ring(&mut rocks, &mut rocks_rng, count, radius + 8.0, radius + 30.0, 0.3); + } + PlaceKind::Moon(p, m) => { + let planet = &system.planets[p]; + let moon = &planet.moons[m]; + what = format!("in orbit around {}, a moon of {}", moon.name, planet.name); + let radius = moon.kind.size * 0.6; + extent = 80.0; + body = Some(Body { + name: moon.name.clone(), + kind: moon.kind, + radius, + settled: moon.people.is_some(), + }); + backdrops.push(Backdrop { + name: planet.name.clone(), + kind: planet.kind, + pos: to_map(-moon.orbit.position(day)).normalised() * (36.0 + planet.kind.size), + radius: planet.kind.size * 1.1, + }); + let count = rocks_rng.random_range(1..=5); + ring(&mut rocks, &mut rocks_rng, count, radius + 6.0, radius + 22.0, 0.3); + } + PlaceKind::Belt => { + let belt = &system.belt; + what = format!("among the rocks of {}", belt.name); + // A denser field spreads wider too, so it's thicker without + // being packed solid. + extent = 140.0 * belt.density.sqrt().max(1.0); + // The whole field drifts one way, each rock a little differently. + let flow = Vec2::from_heading(rng.random_range(0.0..TAU)) * rng.random_range(0.6..1.6); + let many = |n: f64| (n * belt.density).round() as i32; + for _ in 0..rocks_rng.random_range(many(70.0)..=many(100.0)) { + let pos = loop { + let pos = random_point(&mut rocks_rng, extent); + if pos.length() > 14.0 { + break pos; + } + }; + let wobble = Vec2::from_heading(rocks_rng.random_range(0.0..TAU)) * 0.4; + let (size, ore) = random_rock(&mut rocks_rng, belt.richness); + rocks.push(Rock::new(pos, flow + wobble, 0.0, size, ore)); + } + } + PlaceKind::Station(s) => { + let station = &system.stations[s]; + let planet = &system.planets[station.planet]; + let point = if station.leading { "L4" } else { "L5" }; + what = format!("by {}, at {}'s {point} point", station.name, planet.name); + extent = 90.0; + dock = Some(Dock { name: station.name.clone(), culture: station.culture }); + let toward_planet = planet.orbit.position(day) - here; + backdrops.push(Backdrop { + name: planet.name.clone(), + kind: planet.kind, + pos: to_map(toward_planet).normalised() * 40.0, + radius: planet.kind.size * 0.15, + }); + // Trojans: a loose swarm, sharing the point with the station. + for _ in 0..rocks_rng.random_range(25..=40) { + let pos = loop { + let pos = random_point(&mut rocks_rng, extent); + if pos.length() > 18.0 { + break pos; + } + }; + let vel = Vec2::from_heading(rocks_rng.random_range(0.0..TAU)) * 0.3; + let (size, ore) = random_rock(&mut rocks_rng, 0.45); + rocks.push(Rock::new(pos, vel, 0.0, size, ore)); + } + } + } + + let clear = + body.as_ref().map_or(0.0, |b| b.radius) + dock.as_ref().map_or(0.0, |_| DOCK_RADIUS); + let arrival = Vec2::new(0.0, (clear + 16.0).min(extent * 0.8)); + + let wrecks = system + .wrecks_at(place, day, departed, stripped) + .into_iter() + .map(|wreck| { + let mut rng = seed::rng(seed::derive(wreck.seed, "drift", place as u64)); + let pos = loop { + let pos = random_point(&mut rng, extent * 0.7); + if pos.length() > clear + 12.0 && pos.distance(arrival) > 10.0 { + break pos; + } + }; + LocalWreck { wreck, pos, heading: rng.random_range(0.0..TAU) } + }) + .collect(); + + Locale { place, name, what, extent, sun, body, dock, backdrops, rocks, wrecks, arrival } +} + +/// Rocks circling the middle of the map between two radii, all the same way. +fn ring(rocks: &mut Vec<Rock>, rng: &mut Rng, count: usize, inner: f64, outer: f64, ore: f64) { + let way = if rng.random_bool(0.5) { 1.0 } else { -1.0 }; + for _ in 0..count { + let r = rng.random_range(inner..outer); + let pos = Vec2::from_heading(rng.random_range(0.0..TAU)) * r; + // Further out goes round more slowly, as in a real orbit. + let circling = way * 4.0 / r.powf(1.5) * rng.random_range(0.9..1.1); + let (size, ore) = random_rock(rng, ore); + rocks.push(Rock::new(pos, Vec2::ZERO, circling, size, ore)); + } +} + +fn random_rock(rng: &mut Rng, ore_chance: f64) -> (Size, Option<&'static Ore>) { + let size = *[Size::Small, Size::Medium, Size::Medium, Size::Large].choose(rng).unwrap(); + let ore = rng.random_bool(ore_chance).then(|| ORES.choose_weighted(rng, |o| o.weight).unwrap()); + (size, ore) +} + +fn random_point(rng: &mut Rng, extent: f64) -> Vec2 { + Vec2::new(rng.random_range(-extent..extent), rng.random_range(-extent..extent)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::system; + + #[test] + fn every_place_makes_a_map() { + for seed in 0..40 { + let s = system::generate(seed); + for place in 0..s.places.len() { + let l = generate(&s, place, 12.5, None, &HashSet::new()); + assert!((l.sun.length() - 1.0).abs() < 1e-9); + assert!(l.arrival.x.abs() < l.extent && l.arrival.y.abs() < l.extent); + let solid = l.solid_radius().unwrap_or(0.0); + assert!(l.arrival.length() > solid + 5.0, "you arrive clear of what's there"); + for r in &l.rocks { + assert!(r.pos.length() > solid, "{}: a rock inside the middle", l.name); + } + let here = s.wrecks_on(12.5).iter().filter(|w| w.place == place).count(); + assert_eq!(l.wrecks.len(), here); + } + } + } + + #[test] + fn the_layout_stays_but_the_rocks_come_back() { + let s = system::generate(2); + let belt = s.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap(); + let (a, b, c) = { + let none = HashSet::new(); + ( + generate(&s, belt, 3.2, None, &none), + generate(&s, belt, 3.7, None, &none), + generate(&s, belt, 9.0, None, &none), + ) + }; + assert_eq!(a.rocks.len(), b.rocks.len(), "same day, same rocks"); + assert_eq!(a.rocks[0].pos, b.rocks[0].pos); + assert_ne!(a.rocks[0].pos, c.rocks[0].pos, "another day, new rocks"); + } + + #[test] + fn circling_rocks_keep_their_distance() { + let mut rock = Rock::new(Vec2::new(30.0, 0.0), Vec2::ZERO, 0.05, Size::Small, None); + for _ in 0..1000 { + rock.step(0.1, 100.0); + } + assert!((rock.pos.length() - 30.0).abs() < 1e-6); + } + + #[test] + fn drifting_rocks_wrap_round() { + let mut rock = Rock::new(Vec2::new(99.0, 0.0), Vec2::new(5.0, 0.0), 0.0, Size::Small, None); + rock.step(1.0, 100.0); + assert!((rock.pos.x + 96.0).abs() < 1e-9); + } +} diff --git a/src/space/mod.rs b/src/space/mod.rs new file mode 100644 index 0000000..74b092e --- /dev/null +++ b/src/space/mod.rs @@ -0,0 +1,154 @@ +//! Space: star systems with proper orbits, the places in them you can fly +//! around, and the rules of flight. +//! +//! There are two scales. A system is measured in AU and days: planets follow +//! Kepler orbits around the star, moons around their planets, and the +//! calendar moves them on. You never fly at that scale. Instead you travel +//! between places (a planet's orbit, a moon, a belt, a station), and each +//! place is a small local map you fly around in, measured in local units: +//! one unit is one terminal cell across and half a cell down (cells are about +//! twice as tall as they are wide), so circles come out round on screen. +//! Local maps use screen-style coordinates, with y pointing down. +//! +//! Above both is the galaxy: star systems in light-years, joined by lanes +//! you jump along (see `galaxy`). + +pub mod flight; +pub mod galaxy; +pub mod locale; +pub mod orbit; +pub mod survey; +pub mod system; + +use std::f64::consts::{PI, TAU}; +use std::ops::{Add, AddAssign, Mul, Neg, Sub, SubAssign}; + +use crate::seed; + +/// The star system a universe first started everyone in. It's one of the +/// four peoples' seats in its galaxy (see `galaxy`). +pub fn home_system(universe: u64) -> system::System { + system::generate(seed::derive(universe, "system", 0)) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Vec2 { + pub x: f64, + pub y: f64, +} + +impl Vec2 { + pub const ZERO: Vec2 = Vec2::new(0.0, 0.0); + + pub const fn new(x: f64, y: f64) -> Vec2 { + Vec2 { x, y } + } + + /// On a local map, the unit vector pointing along `heading`: 0 is up the + /// screen, and headings increase clockwise. + pub fn from_heading(heading: f64) -> Vec2 { + Vec2::new(heading.sin(), -heading.cos()) + } + + /// The heading this vector points along, the inverse of `from_heading`. + pub fn heading(self) -> f64 { + self.x.atan2(-self.y).rem_euclid(TAU) + } + + pub fn rotated(self, angle: f64) -> Vec2 { + let (s, c) = angle.sin_cos(); + Vec2::new(self.x * c - self.y * s, self.x * s + self.y * c) + } + + pub fn length(self) -> f64 { + self.x.hypot(self.y) + } + + pub fn distance(self, other: Vec2) -> f64 { + (self - other).length() + } + + pub fn dot(self, other: Vec2) -> f64 { + self.x * other.x + self.y * other.y + } + + /// This vector at length 1, or zero if it has no length. + pub fn normalised(self) -> Vec2 { + let len = self.length(); + if len > 0.0 { self * (1.0 / len) } else { Vec2::ZERO } + } +} + +impl Add for Vec2 { + type Output = Vec2; + fn add(self, o: Vec2) -> Vec2 { + Vec2::new(self.x + o.x, self.y + o.y) + } +} + +impl Sub for Vec2 { + type Output = Vec2; + fn sub(self, o: Vec2) -> Vec2 { + Vec2::new(self.x - o.x, self.y - o.y) + } +} + +impl Mul<f64> for Vec2 { + type Output = Vec2; + fn mul(self, k: f64) -> Vec2 { + Vec2::new(self.x * k, self.y * k) + } +} + +impl Neg for Vec2 { + type Output = Vec2; + fn neg(self) -> Vec2 { + Vec2::new(-self.x, -self.y) + } +} + +impl AddAssign for Vec2 { + fn add_assign(&mut self, o: Vec2) { + *self = *self + o; + } +} + +impl SubAssign for Vec2 { + fn sub_assign(&mut self, o: Vec2) { + *self = *self - o; + } +} + +/// The signed angle to turn through to get from `from` to `to`, the short +/// way round: between -π and π. +pub fn turn_between(from: f64, to: f64) -> f64 { + (to - from + PI).rem_euclid(TAU) - PI +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headings_round_trip() { + for i in 0..16 { + let h = TAU * f64::from(i) / 16.0; + assert!((Vec2::from_heading(h).heading() - h).abs() < 1e-9); + } + let up = Vec2::from_heading(0.0); + assert!(up.x.abs() < 1e-9 && (up.y + 1.0).abs() < 1e-9, "heading 0 points up the screen"); + assert!((Vec2::from_heading(TAU / 4.0).x - 1.0).abs() < 1e-9, "a quarter turn is right"); + } + + #[test] + fn turning_takes_the_short_way() { + assert!((turn_between(0.1, TAU - 0.1) + 0.2).abs() < 1e-9); + assert!((turn_between(TAU - 0.1, 0.1) - 0.2).abs() < 1e-9); + } + + #[test] + fn rotation_is_anticlockwise() { + let v = Vec2::new(1.0, 0.0).rotated(PI / 2.0); + assert!(v.x.abs() < 1e-9 && (v.y - 1.0).abs() < 1e-9); + } +} diff --git a/src/space/orbit.rs b/src/space/orbit.rs new file mode 100644 index 0000000..0bcea5e --- /dev/null +++ b/src/space/orbit.rs @@ -0,0 +1,164 @@ +//! Kepler orbits, and how long trips between them take. +//! +//! Distances are in AU, times in days, masses in suns. Orbits lie flat in +//! the plane of the system (no inclination) and run anticlockwise. + +use std::f64::consts::TAU; + +use super::Vec2; + +/// Days in a year for a body 1 AU from a star of one solar mass. +const YEAR: f64 = 365.25; +/// An Earth mass, in suns. +pub const EARTH_MASS: f64 = 3.003e-6; + +#[derive(Clone, Copy, Debug)] +pub struct Orbit { + /// Semi-major axis. + pub a: f64, + /// Eccentricity: 0 is a circle. + pub e: f64, + /// Angle from the x axis to the closest point of the orbit. + pub periapsis: f64, + /// Where along the orbit the body is at day 0, as a mean anomaly. + pub mean_at_epoch: f64, + /// Days per revolution. + pub period: f64, +} + +impl Orbit { + /// An orbit around a body of `parent_mass` suns, with its period from + /// Kepler's third law. + pub fn new(a: f64, e: f64, periapsis: f64, mean_at_epoch: f64, parent_mass: f64) -> Orbit { + let period = YEAR * (a.powi(3) / parent_mass).sqrt(); + Orbit { a, e, periapsis, mean_at_epoch, period } + } + + /// Position relative to the parent on `day`. + pub fn position(&self, day: f64) -> Vec2 { + let mean = self.mean_at_epoch + TAU * day / self.period; + self.at_eccentric_anomaly(eccentric_anomaly(mean, self.e)) + } + + /// `n` points evenly spaced around the ellipse, for drawing it. + pub fn outline(&self, n: usize) -> impl Iterator<Item = Vec2> + '_ { + (0..n).map(move |i| self.at_eccentric_anomaly(TAU * i as f64 / n as f64)) + } + + /// Closest and farthest distances from the parent. + pub fn periapsis_distance(&self) -> f64 { + self.a * (1.0 - self.e) + } + + pub fn apoapsis_distance(&self) -> f64 { + self.a * (1.0 + self.e) + } + + fn at_eccentric_anomaly(&self, ea: f64) -> Vec2 { + let x = self.a * (ea.cos() - self.e); + let y = self.a * (1.0 - self.e * self.e).sqrt() * ea.sin(); + Vec2::new(x, y).rotated(self.periapsis) + } +} + +/// Solves Kepler's equation, M = E - e sin E, for E by Newton's method. +fn eccentric_anomaly(mean: f64, e: f64) -> f64 { + let m = mean.rem_euclid(TAU); + let mut ea = if e > 0.8 { std::f64::consts::PI } else { m }; + for _ in 0..12 { + let step = (ea - e * ea.sin() - m) / (1.0 - e * ea.cos()); + ea -= step; + if step.abs() < 1e-12 { + break; + } + } + ea +} + +/// Days a trip of 1 AU takes. Trips are skewed well away from realism, so +/// that a far planet costs only a few days more than a near one: days grow +/// with the cube root of the distance. A moon is hours away, most planets +/// two to five days, and the far edge of the widest systems about ten. +pub const AU_TRIP: f64 = 2.25; + +/// The shortest trip, however close the destination: undocking, getting +/// clear and settling into the new place. +pub const MIN_TRIP: f64 = 0.25; + +pub fn trip_days(distance: f64) -> f64 { + (AU_TRIP * distance.cbrt()).max(MIN_TRIP) +} + +/// How many days a trip from `from` (where you are now, on `day`) to a +/// destination that moves along `destination(day)` takes. Aims for where +/// the destination will be on arrival, not where it is now. +pub fn plan_trip(from: Vec2, day: f64, destination: impl Fn(f64) -> Vec2) -> f64 { + let mut days = trip_days(from.distance(destination(day))); + for _ in 0..8 { + days = trip_days(from.distance(destination(day + days))); + } + days +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keplers_third_law() { + let earthlike = Orbit::new(1.0, 0.0, 0.0, 0.0, 1.0); + assert!((earthlike.period - 365.25).abs() < 1e-9); + let far = Orbit::new(4.0, 0.0, 0.0, 0.0, 1.0); + assert!( + (far.period - 8.0 * 365.25).abs() < 1e-9, + "four times out takes eight times as long" + ); + } + + #[test] + fn orbits_come_round_again() { + let o = Orbit::new(2.3, 0.2, 1.0, 0.4, 0.8); + let (p, q) = (o.position(17.0), o.position(17.0 + o.period)); + assert!(p.distance(q) < 1e-9); + } + + #[test] + fn ellipses_swing_between_periapsis_and_apoapsis() { + let o = Orbit::new(2.0, 0.25, 0.7, 0.0, 1.0); + let at_start = o.position(0.0).length(); + assert!((at_start - o.periapsis_distance()).abs() < 1e-9, "mean anomaly 0 is periapsis"); + let half = o.position(o.period / 2.0).length(); + assert!((half - o.apoapsis_distance()).abs() < 1e-9); + let p = o.position(0.0); + let angle = p.y.atan2(p.x); + assert!((angle - 0.7).abs() < 1e-9, "periapsis points where it says"); + } + + #[test] + fn keplers_second_law_bodies_hurry_near_periapsis() { + let o = Orbit::new(1.0, 0.3, 0.0, 0.0, 1.0); + let near = o.position(0.0).distance(o.position(1.0)); + let far = o.position(o.period / 2.0).distance(o.position(o.period / 2.0 + 1.0)); + assert!(near > far * 1.5); + } + + #[test] + fn trips_aim_ahead_of_a_moving_target() { + let target = Orbit::new(1.0, 0.0, 0.0, 0.0, 1.0); + let from = Vec2::new(5.0, 0.0); + let days = plan_trip(from, 0.0, |d| target.position(d)); + let arrival = target.position(days); + assert!((trip_days(from.distance(arrival)) - days).abs() < 1e-6); + } + + #[test] + fn trips_take_days_not_weeks() { + assert!(trip_days(0.003) < 0.5, "a moon is hours away"); + assert!((2.0..3.0).contains(&trip_days(1.0))); + assert!(trip_days(10.0) < 5.0); + assert!(trip_days(90.0) < 11.0, "even the far edge of a wide system"); + assert_eq!(trip_days(1e-6), MIN_TRIP); + // Far places cost more, but not much more. + assert!((trip_days(27.0) / trip_days(1.0) - 3.0).abs() < 1e-9); + } +} diff --git a/src/space/survey.rs b/src/space/survey.rs new file mode 100644 index 0000000..e3c91fe --- /dev/null +++ b/src/space/survey.rs @@ -0,0 +1,365 @@ +//! What a ship's survey says about a place: how hot it is and how that +//! swings over its year, how long its days are, how hard it pulls, what its +//! air is like, what hangs in its sky, and how many people live there. +//! None of it changes how the game plays. It's there so each place feels +//! like somewhere. +//! +//! Temperatures are worked out, not made up: the starlight a body gets at +//! its distance from the star (which changes along an eccentric orbit), +//! less what it reflects, plus what its air traps or its insides give off. +//! Moons share their planet's distance. Everything else is drawn from a +//! hash of the place, so it's the same every visit and system generation +//! (and every universe already made) is left alone. + +use std::f64::consts::TAU; + +use super::orbit::{EARTH_MASS, Orbit}; +use super::system::{Climate, Landing, PlaceKind, StarKind, System, SystemKind}; +use crate::names::Culture; +use crate::scenes::sky::hash01; +use crate::seed; + +/// How warm a bare, black, quickly turning rock gets 1 AU from a star of +/// one sun, in kelvin. +const WARMTH_AT_1AU: f64 = 278.6; +/// Water freezes at this many kelvin. +pub const FREEZING: f64 = 273.15; +/// How wide the Sun looks from 1 AU, in degrees. +const SUN_AT_1AU: f64 = 0.533; +const EARTH_RADIUS_KM: f64 = 6371.0; +const AU_KM: f64 = 149.6e6; +/// Rocks in the belt: dark, and no air to keep them warm. +const BELT_CLIMATE: Climate = Climate { albedo: 0.1, warmth: 0.0, air: &[], breathable: false }; + +pub struct Survey { + /// At the surface, or a giant's cloud tops. None aboard a station, + /// where it's whatever they set it to. + pub temperature: Option<Temperature>, + /// How long a day lasts, or how fast a station spins. + pub day: Option<String>, + /// What its tilt does to its year, if it has days to speak of. + pub seasons: Option<&'static str>, + /// In g: at the surface, at a giant's cloud tops, or from a station's spin. + pub gravity: Option<f64>, + /// What the air is like, and whether you can breathe it. None if + /// there isn't any. + pub air: Option<(&'static str, bool)>, + /// What you'd see overhead. + pub sky: String, + /// How many live there, if anyone does. + pub population: Option<u64>, +} + +/// In kelvin. +#[derive(Clone, Copy, Debug)] +pub struct Temperature { + pub now: f64, + /// At the far end of its orbit. + pub coldest: f64, + /// At the near end. + pub hottest: f64, +} + +impl Temperature { + fn along(orbit: &Orbit, day: f64, star: &StarKind, climate: &Climate) -> Temperature { + let at = |distance: f64| { + WARMTH_AT_1AU * (star.luminosity * (1.0 - climate.albedo)).powf(0.25) / distance.sqrt() + + climate.warmth + }; + Temperature { + now: at(orbit.position(day).length()), + coldest: at(orbit.apoapsis_distance()), + hottest: at(orbit.periapsis_distance()), + } + } + + /// One word for how it feels, going by the temperature now. + pub fn word(self) -> &'static str { + match self.now - FREEZING { + c if c < -150.0 => "deep-frozen", + c if c < -60.0 => "frozen", + c if c < -20.0 => "bitter", + c if c < 5.0 => "cold", + c if c < 15.0 => "cool", + c if c < 28.0 => "temperate", + c if c < 45.0 => "hot", + c if c < 120.0 => "scorching", + c if c < 400.0 => "searing", + _ => "molten", + } + } +} + +/// Kelvin to whole degrees Celsius, for showing. +pub fn celsius(kelvin: f64) -> i64 { + (kelvin - FREEZING).round() as i64 +} + +pub fn survey(s: &System, place: usize, day: f64) -> Survey { + let key = seed::derive(s.seed, "survey", place as u64); + let h = |i: i64| hash01(key, i, 0); + let star = s.star; + let landing = s.landing(place); + match s.places[place].kind { + PlaceKind::Planet(p) => { + let planet = &s.planets[p]; + let kind = planet.kind; + let temperature = Temperature::along(&planet.orbit, day, star, &kind.climate); + // Close to a small star, tides stop a world turning. + let locked = !kind.giant && planet.orbit.a < 0.4 * star.mass; + let (day_length, seasons) = if locked { + ("locked: one face in endless day".to_string(), None) + } else { + let hours = if kind.giant { + lerp(9.0, 18.0, h(1)) + } else if h(2) < 0.08 { + 24.0 * log_lerp(5.0, 120.0, h(1)) + } else { + log_lerp(10.0, 80.0, h(1)) + }; + (duration(hours), Some(seasons(h(3), h(4)))) + }; + let earths = planet.mass / EARTH_MASS; + let radius = radius(earths, kind.giant); + let moons = match planet.moons.len() { + 0 => String::new(), + 1 => " and a moon".to_string(), + n => format!(" and {n} moons"), + }; + Survey { + temperature: Some(temperature), + day: Some(day_length), + seasons, + gravity: Some(earths / (radius * radius)), + air: air(&kind.climate, temperature, h(5)), + sky: format!("{}{moons}", sun(star, planet.orbit.position(day).length())), + population: population(landing, s.kind, p == s.home, false, h(6)), + } + } + PlaceKind::Moon(p, m) => { + let planet = &s.planets[p]; + let moon = &planet.moons[m]; + let temperature = Temperature::along(&planet.orbit, day, star, &moon.kind.climate); + let earths = log_lerp(0.004, 0.12, h(1)); + // How wide its planet looks, from down on the surface. + let planet_km = radius(planet.mass / EARTH_MASS, planet.kind.giant) * EARTH_RADIUS_KM; + let across = 2.0 * (planet_km / (moon.orbit.a * AU_KM)).atan().to_degrees(); + let size = if across < 1.0 { format!("{across:.1}") } else { format!("{across:.0}") }; + let looms = if across >= 3.0 { "looms" } else { "hangs" }; + Survey { + temperature: Some(temperature), + day: Some(format!( + "{}, always facing {}", + duration(24.0 * moon.orbit.period), + planet.name + )), + seasons: None, + gravity: Some(earths.powf(0.46)), + air: air(&moon.kind.climate, temperature, h(5)), + sky: format!( + "{} {looms} {size}° wide; {}", + planet.name, + sun(star, planet.orbit.position(day).length()) + ), + population: population(landing, s.kind, false, true, h(6)), + } + } + PlaceKind::Station(k) => { + let station = &s.stations[k]; + let planet = &s.planets[station.planet]; + // Spun for gravity: the bigger the wheel, the slower it turns. + let g = lerp(0.35, 1.0, h(1)); + let wheel = lerp(150.0, 900.0, h(2)); + let seconds = TAU * (wheel / (g * 9.81)).sqrt(); + Survey { + temperature: None, + day: Some(format!("turns once every {seconds:.0} seconds")), + seasons: None, + gravity: Some(g), + air: Some((station_air(station.culture), true)), + sky: format!( + "{}, and {} a bright star", + sun(star, planet.orbit.position(day).length()), + planet.name + ), + population: population(landing, s.kind, false, false, h(6)), + } + } + PlaceKind::Belt => { + let region = &s.belt.region; + Survey { + temperature: Some(Temperature::along(region, day, star, &BELT_CLIMATE)), + day: None, + seasons: None, + gravity: None, + air: None, + sky: format!("{}, and rocks tumbling all round", sun(star, region.a)), + population: None, + } + } + } +} + +/// In Earth radii, for a body of `earths` Earth masses: rocky worlds grow +/// slowly with mass, and giants stop growing at about Jupiter's size. +fn radius(earths: f64, giant: bool) -> f64 { + if giant { earths.powf(0.48).min(11.3) } else { earths.powf(0.27) } +} + +fn air(climate: &Climate, t: Temperature, h: f64) -> Option<(&'static str, bool)> { + if climate.air.is_empty() { + return None; + } + let what = climate.air[(h * climate.air.len() as f64) as usize % climate.air.len()]; + let bearable = (FREEZING - 40.0..=FREEZING + 55.0).contains(&t.now); + Some((what, climate.breathable && bearable)) +} + +fn station_air(culture: Culture) -> &'static str { + match culture { + Culture::Qaari => "warm and dry, scented with spice-tea", + Culture::Cant => "recycled; hot solder and incense", + Culture::Houses => "perfumed, and filtered twice over", + Culture::Drift => "stale, and patched like the hulls", + } +} + +/// The star as it looks from `distance` AU. +fn sun(star: &StarKind, distance: f64) -> String { + let colour = star.name.trim_end_matches(" dwarf").trim_end_matches(" star"); + // Near enough for a star in the prime of its life. + let radius = star.mass.powf(0.8); + let degrees = SUN_AT_1AU * radius / distance; + let size = match degrees { + d if d < 0.12 => return format!("a {colour} sun, shrunk to a point"), + d if d < 0.35 => "small ", + d if d < 0.9 => "", + d if d < 2.5 => "broad ", + d if d < 6.0 => "vast ", + _ => "sky-filling ", + }; + format!("a {size}{colour} sun") +} + +/// How much its tilt changes its year. Most worlds lean a little; a few +/// roll round their star on their side. +fn seasons(h: f64, h2: f64) -> &'static str { + let tilt = if h < 0.05 { 80.0 + 20.0 * h2 } else { 40.0 * h2 * h2 }; + match tilt { + t if t < 4.0 => "no seasons", + t if t < 15.0 => "mild seasons", + t if t < 30.0 => "strong seasons", + t if t < 60.0 => "harsh seasons", + _ => "spins on its side", + } +} + +/// How many people live somewhere: billions on a seat's homeworld, far +/// fewer out on the frontier or among the wrecks. +fn population(landing: Landing, kind: SystemKind, main: bool, moon: bool, h: f64) -> Option<u64> { + let (low, high) = match landing { + Landing::Capital(_) if main => match kind { + SystemKind::Seat => (6e8, 4e9), + SystemKind::Settled => (4e7, 6e8), + SystemKind::Rubble => (3e6, 6e7), + _ => (2e5, 8e6), + }, + Landing::Capital(_) if moon => (2e5, 2e7), + Landing::Capital(_) => (5e6, 3e8), + Landing::Aloft(_) => (1e6, 4e7), + Landing::Market(_) => (3e3, 8e4), + Landing::Wilds | Landing::Nowhere => return None, + }; + // Away from the settled heart of things, everywhere is emptier. + let thin = matches!(kind, SystemKind::Frontier | SystemKind::Graveyard) && !main; + let (low, high) = if thin { (low * 0.1, high * 0.1) } else { (low, high) }; + // To two figures: nobody counts heads more closely than that. + let n = log_lerp(low, high, h); + let unit = 10f64.powi(n.log10().floor() as i32 - 1); + Some(((n / unit).round() * unit) as u64) +} + +/// "26 hours", "3.5 days", "41 days". +pub fn duration(hours: f64) -> String { + let days = hours / 24.0; + if hours < 72.0 { + format!("{hours:.0} hours") + } else if days < 10.0 { + format!("{days:.1} days") + } else { + format!("{days:.0} days") + } +} + +fn lerp(low: f64, high: f64, k: f64) -> f64 { + low + (high - low) * k +} + +/// From `low` to `high`, evenly in orders of magnitude. +fn log_lerp(low: f64, high: f64, k: f64) -> f64 { + (low.ln() + (high.ln() - low.ln()) * k).exp() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::system::generate; + + #[test] + fn surveys_are_the_same_every_time() { + let s = generate(4); + for place in 0..s.places.len() { + let (a, b) = (survey(&s, place, 30.0), survey(&s, place, 30.0)); + assert_eq!((a.day, a.sky, a.population), (b.day, b.sky, b.population)); + } + } + + #[test] + fn temperatures_swing_between_the_ends_of_the_orbit() { + for seed in 0..60 { + let s = generate(seed); + for place in 0..s.places.len() { + for day in [0.0, 100.0, 1234.5] { + let Some(t) = survey(&s, place, day).temperature else { continue }; + assert!(t.coldest <= t.now + 1e-9 && t.now <= t.hottest + 1e-9); + assert!(t.coldest > 0.0, "seed {seed}: below absolute zero"); + } + } + } + } + + #[test] + fn a_wet_world_in_the_middle_of_the_habitable_zone_is_mild() { + let star = &crate::space::system::STARS[0]; + let hz = star.luminosity.sqrt(); + let orbit = Orbit::new(hz, 0.0, 0.0, 0.0, star.mass); + let ocean = Climate { albedo: 0.3, warmth: 33.0, air: &["wet"], breathable: true }; + let t = Temperature::along(&orbit, 0.0, star, &ocean); + assert_eq!(celsius(t.now), 15, "about Earth's average"); + assert_eq!(t.word(), "cool"); + assert_eq!(air(&ocean, t, 0.0), Some(("wet", true))); + } + + #[test] + fn only_settled_places_have_people() { + for seed in 0..40 { + let s = generate(seed); + for place in 0..s.places.len() { + let people = survey(&s, place, 0.0).population; + assert_eq!( + people.is_some(), + !matches!(s.landing(place), Landing::Wilds | Landing::Nowhere) + ); + } + let home = survey(&s, s.home_place(), 0.0).population.unwrap(); + assert!(home >= 600_000_000, "seed {seed}: a homeworld of {home}"); + } + } + + #[test] + fn durations_read_naturally() { + assert_eq!(duration(26.2), "26 hours"); + assert_eq!(duration(84.0), "3.5 days"); + assert_eq!(duration(24.0 * 41.2), "41 days"); + } +} diff --git a/src/space/system.rs b/src/space/system.rs new file mode 100644 index 0000000..7a3742a --- /dev/null +++ b/src/space/system.rs @@ -0,0 +1,1215 @@ +//! Star systems: a star, planets on Kepler orbits with moons of their own, +//! an asteroid belt, stations at Lagrange points, and wrecks that come and +//! go with the calendar. +//! +//! Planets are laid out outward from the star, each orbit 1.5-2 times the +//! last (roughly how real systems space out). What a planet is depends on +//! where it sits: scorched rock close in, temperate worlds around the +//! habitable zone, ice beyond, and giants past the frost line. The belt +//! takes the first wide gap past the frost line. +//! +//! Everything you can travel to is a `Place`, listed outward from the star. +//! +//! Systems come in kinds (see `SystemKind`): a people's seat, ordinary +//! settled systems, thin frontiers, desolate ones no one lives in, rubble +//! fields with a great belt, and graveyards of old wars. The galaxy decides +//! which kind each system is, and who holds it, and hands that over as a +//! `Profile`. The system a universe first started you in is made without +//! one, exactly as it always was, so old universes and saves keep it. + +use std::cell::RefCell; +use std::collections::{BTreeMap, HashSet}; +use std::f64::consts::{PI, TAU}; +use std::ops::RangeInclusive; + +use rand::RngExt; +use rand::seq::IndexedRandom; + +use super::Vec2; +use super::orbit::{EARTH_MASS, Orbit}; +use crate::dungeon::crawl; +use crate::names::Culture; +use crate::seed::{self, Rng}; + +pub type Rgb = (u8, u8, u8); + +pub struct StarKind { + pub name: &'static str, + pub rgb: Rgb, + /// In suns. + pub mass: f64, + /// In suns; sets where the habitable zone is. + pub luminosity: f64, + weight: u32, + /// How likely it is in a system no one lives in. + dead_weight: u32, +} + +#[rustfmt::skip] +pub const STARS: &[StarKind] = &[ + StarKind { name: "yellow dwarf", rgb: (255, 226, 130), mass: 1.0, luminosity: 1.0, weight: 4, dead_weight: 1 }, + StarKind { name: "orange dwarf", rgb: (255, 168, 90), mass: 0.75, luminosity: 0.35, weight: 3, dead_weight: 2 }, + StarKind { name: "red dwarf", rgb: (255, 110, 80), mass: 0.4, luminosity: 0.04, weight: 2, dead_weight: 5 }, + StarKind { name: "blue-white star", rgb: (175, 205, 255), mass: 1.9, luminosity: 9.0, weight: 1, dead_weight: 2 }, +]; + +/// A star for a system of this kind: dim red dwarfs, mostly, where no one +/// lives. +pub fn random_star(kind: SystemKind, rng: &mut Rng) -> &'static StarKind { + if kind == SystemKind::Desolate { + STARS.choose_weighted(rng, |s| s.dead_weight).unwrap() + } else { + STARS.choose_weighted(rng, |s| s.weight).unwrap() + } +} + +/// What sort of system it is, which shapes everything in it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SystemKind { + /// The seat of a people: their homeworld and its great capital. + Seat, + /// An ordinary system: a capital, settled worlds, a station or two. + Settled, + /// Thinly settled: a small capital or an outpost station. + Frontier, + /// No one lives here at all, and nothing is for sale. + Desolate, + /// Few planets, and a wide belt thick with ore, worked by mining stations. + Rubble, + /// An old battlefield: wrecks everywhere, few people. + Graveyard, +} + +impl SystemKind { + pub const ALL: [SystemKind; 6] = [ + SystemKind::Seat, + SystemKind::Settled, + SystemKind::Frontier, + SystemKind::Desolate, + SystemKind::Rubble, + SystemKind::Graveyard, + ]; + + /// "a frontier system", for "a frontier system of the Qaari". + pub fn name(self) -> &'static str { + match self { + SystemKind::Seat => "the seat", + SystemKind::Settled => "a settled system", + SystemKind::Frontier => "a frontier system", + SystemKind::Desolate => "a desolate system", + SystemKind::Rubble => "a rubble field", + SystemKind::Graveyard => "a graveyard of ships", + } + } + + /// What you'll find there, in a sentence. + pub fn blurb(self) -> &'static str { + match self { + SystemKind::Seat => "Their homeworld and its great capital, busy stations, fuel.", + SystemKind::Settled => "Settled worlds round a capital, a station or two, fuel.", + SystemKind::Frontier => "Thinly settled: a small capital or an outpost, and fuel.", + SystemKind::Desolate => "No one lives here. Nothing is for sale, fuel least of all.", + SystemKind::Rubble => "A wide belt thick with ore, worked by mining stations.", + SystemKind::Graveyard => "An old battlefield. Wrecks drift everywhere; few people.", + } + } + + /// Whether anyone in a system of this kind sells fuel. Only desolate + /// systems have no one to. + pub fn sells_fuel(self) -> bool { + self != SystemKind::Desolate + } +} + +/// What the galaxy decides about a system before it's made. +pub struct Profile { + pub name: String, + /// Who holds it. + pub culture: Culture, + pub star: &'static StarKind, + pub kind: SystemKind, + /// The people over the border, for a system on one: they settle here + /// too, and may keep a station. + pub neighbour: Option<Culture>, +} + +/// How a kind of system is laid out and settled. +struct Rules { + planets: RangeInclusive<usize>, + /// How much wider than it starts the belt is, outer edge over inner. + belt_width: (f64, f64), + /// Scales every world's chance of being settled. + settle: f64, + /// Of settled worlds, the share the system's own people hold. + own_share: f64, + /// Who holds the rest; anyone at all, if no one in particular. + minority: Option<Culture>, + /// The chance the main world has a capital. + capital: f64, + /// No life: oceans and jungles give way to rock and ice. + dead: bool, + stations: Stations, + /// How many wrecks are calling at once, on average. + wrecks: f64, + /// Rocks in the belt, and the share with ore in them, against an + /// ordinary belt's. + density: f64, + richness: f64, +} + +#[derive(Clone, Copy, PartialEq)] +enum Stations { + /// One at the heaviest planet, and often one at the main world. + Usual, + /// One at the heaviest planet and one at the main world, or another. + Two, + /// Exactly one. + One, + /// One if there's no capital to trade at; otherwise, maybe. + Outpost, + None, +} + +impl Rules { + /// The rules a system was always made by, before there were kinds. + const ORIGINAL: Rules = Rules { + planets: 4..=7, + belt_width: (1.4, 1.7), + settle: 1.0, + own_share: 0.7, + minority: None, + capital: 1.0, + dead: false, + stations: Stations::Usual, + wrecks: 2.5, + density: 1.0, + richness: 0.6, + }; + + fn for_profile(p: &Profile) -> Rules { + // On a border, the neighbours settle nearly as much as the owners. + let (own_share, minority) = match p.neighbour { + Some(n) => (0.6, Some(n)), + None => (0.92, None), + }; + let base = Rules { own_share, minority, ..Rules::ORIGINAL }; + match p.kind { + SystemKind::Seat => Rules { stations: Stations::Two, ..base }, + SystemKind::Settled => base, + SystemKind::Frontier => Rules { + planets: 3..=6, + settle: 0.35, + capital: 0.55, + stations: Stations::Outpost, + wrecks: 2.0, + ..base + }, + SystemKind::Desolate => Rules { + planets: 3..=6, + settle: 0.0, + capital: 0.0, + dead: true, + stations: Stations::None, + wrecks: 2.0, + ..base + }, + SystemKind::Rubble => Rules { + planets: 2..=4, + belt_width: (2.2, 2.8), + settle: 0.6, + capital: 0.5, + stations: Stations::Two, + density: 1.6, + richness: 0.85, + ..base + }, + SystemKind::Graveyard => { + Rules { settle: 0.3, capital: 0.35, stations: Stations::One, wrecks: 6.5, ..base } + } + } + } +} + +pub struct BodyKind { + pub name: &'static str, + pub rgb: Rgb, + /// Drawn with cloud bands. + pub banded: bool, + /// Radius when you're in orbit around it, in local map units. Not to + /// scale; giants are just bigger. + pub size: f64, + pub giant: bool, + /// The chance someone lives there. + settled: f64, + pub climate: Climate, + pub terrain: Terrain, + flavour: &'static [&'static str], +} + +/// What a world's ground is, for a city built on it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Terrain { + Desert, + Ice, + Ocean, + Jungle, + Barren, + Forge, + /// A giant's: no ground at all. No one builds a city on one. + Clouds, +} + +/// How a kind of world holds heat, and what its air is like. Only for +/// show: see `space::survey`. +pub struct Climate { + /// How much starlight it reflects, from 0 to 1. + pub albedo: f64, + /// How much warmer it is than bare rock would be at the same distance, + /// in kelvin: heat its air traps, or its insides give off. + pub warmth: f64, + /// A few ways of putting what the air is like. Empty if there's none. + pub air: &'static [&'static str], + /// Whether people could breathe it, if it weren't too hot or cold. + pub breathable: bool, +} + +macro_rules! kind { + ($name:expr, $terrain:expr, $rgb:expr, $size:expr, $settled:expr, $climate:expr, + $($flavour:expr),+) => { + BodyKind { name: $name, rgb: $rgb, banded: false, size: $size, giant: false, + settled: $settled, climate: $climate, terrain: $terrain, + flavour: &[$($flavour),+] } + }; +} + +macro_rules! climate { + ($albedo:expr, $warmth:expr, $breathable:expr, [$($air:expr),*]) => { + Climate { albedo: $albedo, warmth: $warmth, breathable: $breathable, air: &[$($air),*] } + }; +} + +const DESERT: BodyKind = kind!( + "desert world", + Terrain::Desert, + (214, 172, 104), + 10.0, + 0.7, + climate!(0.25, 35.0, true, ["thin, dry and dusty", "dry; it smells of hot stone"]), + "Wind-carved dunes run unbroken from pole to pole.", + "Glass storms scour the plains at dusk.", + "Under the sand lie the bones of something enormous." +); +const ICE: BodyKind = kind!( + "ice world", + Terrain::Ice, + (190, 222, 240), + 9.0, + 0.3, + climate!(0.6, 8.0, false, ["thin and bitter", "a whisper of nitrogen"]), + "Blue glaciers grind slowly over a buried sea.", + "Its auroras whistle and sing on the comm bands." +); +const OCEAN: BodyKind = kind!( + "ocean world", + Terrain::Ocean, + (70, 124, 205), + 11.0, + 0.6, + climate!(0.3, 33.0, true, ["thick, humid and salty", "warm, wet and briny"]), + "One endless ocean, broken by a scatter of black islands.", + "Kelp forests a hundred metres tall sway beneath the waves." +); +const JUNGLE: BodyKind = kind!( + "jungle world", + Terrain::Jungle, + (84, 164, 84), + 11.0, + 0.5, + climate!(0.25, 40.0, true, ["dense, wet, spore-laden", "heavy and green-smelling"]), + "Canopy covers everything; nothing on the ground has seen the sun.", + "The air is thick with spores and slow-drifting seed-kites." +); +const BARREN: BodyKind = kind!( + "barren rock", + Terrain::Barren, + (146, 136, 124), + 7.0, + 0.15, + climate!(0.12, 0.0, false, []), + "Cratered grey rock, airless and silent.", + "Ancient mine shafts riddle the crust." +); +const FORGE: BodyKind = kind!( + "forge world", + Terrain::Forge, + (200, 86, 60), + 9.0, + 0.4, + climate!(0.15, 220.0, false, ["sulphurous and choking", "thin, hot and ashy"]), + "Rivers of lava light up the night side.", + "The crust is thin and restless, and the ground shakes daily." +); +const GAS_GIANT: BodyKind = BodyKind { + banded: true, + giant: true, + ..kind!( + "gas giant", + Terrain::Clouds, + (206, 154, 112), + 20.0, + 0.25, + climate!(0.34, 45.0, false, ["hydrogen and helium", "hydrogen, streaked with ammonia"]), + "Storms the size of worlds turn in its banded clouds.", + "Cloud-skimmers dive into its upper layers for fuel." + ) +}; +const ICE_GIANT: BodyKind = BodyKind { + banded: true, + giant: true, + ..kind!( + "ice giant", + Terrain::Clouds, + (120, 200, 210), + 16.0, + 0.1, + climate!(0.3, 15.0, false, ["hydrogen and methane haze", "cold hydrogen, faintly blue"]), + "A pale blue-green ball of methane haze, cold and still.", + "Diamond hail falls forever through its depths." + ) +}; + +/// What a planet `a` AU out is likely to be, given where the habitable zone +/// (`hz`) and the frost line are. +fn planet_kinds(a: f64, hz: f64) -> &'static [&'static BodyKind] { + if a < 0.75 * hz { + &[&FORGE, &BARREN, &DESERT] + } else if a < 1.7 * hz { + &[&OCEAN, &JUNGLE, &DESERT, &DESERT] + } else if a < FROST_LINE * hz { + &[&ICE, &BARREN, &DESERT] + } else { + &[&GAS_GIANT, &GAS_GIANT, &ICE_GIANT] + } +} + +/// The same, in a dead system: no oceans or jungles, only rock and ice. +fn dead_kinds(a: f64, hz: f64) -> &'static [&'static BodyKind] { + if a < 0.75 * hz { + &[&FORGE, &BARREN, &BARREN] + } else if a < 1.7 * hz { + &[&BARREN, &DESERT, &BARREN] + } else if a < FROST_LINE * hz { + &[&ICE, &BARREN] + } else { + &[&GAS_GIANT, &ICE_GIANT, &ICE_GIANT] + } +} + +/// Beyond this many habitable-zone distances out, water freezes and giants form. +const FROST_LINE: f64 = 2.7; + +/// What a moon is likely to be, inside the frost line and beyond it. Forge +/// moons are the ones a giant kneads hot with its tides. +const MOON_KINDS: [&[&BodyKind]; 2] = + [&[&BARREN, &BARREN, &DESERT, &FORGE], &[&ICE, &ICE, &BARREN, &FORGE]]; + +fn settlements(culture: Culture) -> &'static [&'static str] { + match culture { + Culture::Qaari => &[ + "Qaari caravan-cities crawl across the surface on great tracked platforms.", + "The Qaari keep a water-court here, where oaths are sworn over cisterns.", + ], + Culture::Cant => &[ + "Foundry-cathedrals of the Cant hum day and night with litanies.", + "The Cant strip old hulls here, and sing over the parts.", + ], + Culture::Houses => &[ + "A High House palace sprawls across the one temperate valley.", + "House banners hang from the spires of a lofty court-city.", + ], + Culture::Drift => &[ + "A tangle of lashed-together hulls hangs in orbit: a Drift anchorage.", + "The Drift trade here, from a market of docked ships.", + ], + } +} + +const UNSETTLED: &[&str] = &[ + "No one lives here. At night the only lights are the stars.", + "A survey beacon still blinks on the surface, long abandoned.", +]; + +pub struct Planet { + pub name: String, + pub kind: &'static BodyKind, + /// Who lives there, if anyone. + pub people: Option<Culture>, + pub orbit: Orbit, + /// In suns. + pub mass: f64, + pub moons: Vec<Moon>, + /// A few sentences for the landing screen. + pub description: Vec<String>, +} + +pub struct Moon { + pub name: String, + pub kind: &'static BodyKind, + pub people: Option<Culture>, + /// Around its planet. + pub orbit: Orbit, + pub description: Vec<String>, +} + +pub struct Belt { + pub name: String, + pub inner: f64, + pub outer: f64, + /// The stretch of belt you can travel to, which goes round with it. + pub region: Orbit, + /// How many rocks there are, against an ordinary belt. + pub density: f64, + /// The chance a rock has ore in it. + pub richness: f64, +} + +pub struct Station { + pub name: String, + pub culture: Culture, + /// The planet whose Lagrange point it sits at. + pub planet: usize, + /// At L4, sixty degrees ahead of the planet, rather than L5, behind it. + pub leading: bool, + pub description: Vec<String>, +} + +/// A derelict with its distress beacon calling. Wrecks come and go: each +/// drifts in on some day, calls for a month or so, and then its beacon fails +/// and it's lost to the dark. +#[derive(Clone, Debug, PartialEq)] +pub struct Wreck { + /// Board it and this seeds the derelict you find. + pub seed: u64, + pub name: String, + /// "Cant hauler". + pub description: String, + /// Which place it drifts in. + pub place: usize, + /// The day its beacon started calling. + pub arrived: f64, + /// The day its beacon fails and it's gone. + pub leaves: f64, +} + +impl Wreck { + pub fn calling(&self, day: f64) -> bool { + self.arrived <= day && day < self.leaves + } +} + +/// Wrecks turn up in batches, one batch for every this many days. +const WRECK_EPOCH: f64 = 10.0; +/// How long a beacon calls, in days. +const BEACON_LIFE: (f64, f64) = (20.0, 45.0); +/// The most wrecks one batch can bring. +const WRECK_TRIES: usize = 6; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlaceKind { + /// Orbit around a planet. + Planet(usize), + /// Orbit around a planet's moon. + Moon(usize, usize), + Belt, + Station(usize), +} + +/// What there is for you when you land or dock somewhere. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Landing { + /// People live on a world with ground to land on: you set down outside + /// their capital and walk in, to a full set of shops. + Capital(Culture), + /// A station: dock and sell ore, and that's all. + Market(Culture), + /// People live in a giant's clouds, but there's no ground to land on. + Aloft(Culture), + /// A world no one lives on: land and look around, with no one to trade with. + Wilds, + /// Nowhere to land at all: the belt, or an empty giant. + Nowhere, +} + +impl Landing { + /// Whether anyone here buys ore. + pub fn trades(self) -> bool { + matches!(self, Landing::Capital(_) | Landing::Market(_)) + } +} + +pub struct Place { + pub kind: PlaceKind, + /// "Qeth Amarul", "the Zirim Belt". + pub name: String, +} + +pub struct System { + pub seed: u64, + pub name: String, + /// Whose space this is; most settled worlds here are theirs. + pub culture: Culture, + pub kind: SystemKind, + pub star: &'static StarKind, + pub planets: Vec<Planet>, + /// The system's main world: in a seat, the homeworld of its people, + /// where you start. In a system no one lives in, the world likeliest to + /// have been settled. + pub home: usize, + pub belt: Belt, + pub stations: Vec<Station>, + /// Everywhere you can travel to, outward from the star. + pub places: Vec<Place>, + /// How many wrecks are calling at once, on average. + wreck_rate: f64, + /// On a battlefield, the place most wrecks turn up. + battle: Option<usize>, + /// Batches of wrecks already made, by epoch: naming one means + /// generating its whole ship. + batches: RefCell<BTreeMap<i64, Vec<Wreck>>>, +} + +impl System { + /// Where a place is on `day`, in AU from the star. + pub fn position(&self, place: usize, day: f64) -> Vec2 { + match self.places[place].kind { + PlaceKind::Planet(p) => self.planets[p].orbit.position(day), + PlaceKind::Moon(p, m) => { + self.planets[p].orbit.position(day) + self.planets[p].moons[m].orbit.position(day) + } + PlaceKind::Belt => self.belt.region.position(day), + PlaceKind::Station(s) => { + let station = &self.stations[s]; + let lead = if station.leading { PI / 3.0 } else { -PI / 3.0 }; + self.planets[station.planet].orbit.position(day).rotated(lead) + } + } + } + + /// What there is for you if you land or dock at a place. + pub fn landing(&self, place: usize) -> Landing { + let world = |kind: &BodyKind, people: Option<Culture>| match (people, kind.giant) { + (Some(c), true) => Landing::Aloft(c), + (Some(c), false) => Landing::Capital(c), + (None, true) => Landing::Nowhere, + (None, false) => Landing::Wilds, + }; + match self.places[place].kind { + PlaceKind::Planet(p) => world(self.planets[p].kind, self.planets[p].people), + PlaceKind::Moon(p, m) => { + let moon = &self.planets[p].moons[m]; + world(moon.kind, moon.people) + } + PlaceKind::Station(s) => Landing::Market(self.stations[s].culture), + PlaceKind::Belt => Landing::Nowhere, + } + } + + /// The place for orbit around the homeworld, where journeys start. + pub fn home_place(&self) -> usize { + self.places.iter().position(|p| p.kind == PlaceKind::Planet(self.home)).unwrap() + } + + /// Whether the main world is the homeworld of a people, not just the + /// biggest place in the system. + pub fn is_homeworld(&self, planet: usize) -> bool { + planet == self.home && self.kind == SystemKind::Seat + } + + /// Where a jump brings you out: the outermost planet, or the belt if it + /// lies further out. + pub fn edge_place(&self) -> usize { + self.places + .iter() + .rposition(|p| matches!(p.kind, PlaceKind::Planet(_) | PlaceKind::Belt)) + .unwrap() + } + + /// Whether you can buy fuel at a place: at a station's dock, or a + /// city's shipyard. + pub fn sells_fuel_at(&self, place: usize) -> bool { + self.landing(place).trades() + } + + /// Whether anywhere in the system sells fuel. + pub fn sells_fuel(&self) -> bool { + (0..self.places.len()).any(|i| self.sells_fuel_at(i)) + } + + /// Where to go for fuel: the first station, or failing that the first + /// capital. + pub fn fuel_place(&self) -> Option<usize> { + let station = |i: &usize| matches!(self.landing(*i), Landing::Market(_)); + (0..self.places.len()) + .find(station) + .or_else(|| (0..self.places.len()).find(|&i| self.sells_fuel_at(i))) + } + + /// How far out anything goes, in AU. + pub fn extent(&self) -> f64 { + let planets = self.planets.iter().map(|p| p.orbit.apoapsis_distance()); + planets.fold(self.belt.outer, f64::max) + } + + /// The wrecks whose beacons are calling on `day`, oldest first. + pub fn wrecks_on(&self, day: f64) -> Vec<Wreck> { + let first = ((day - BEACON_LIFE.1) / WRECK_EPOCH).floor() as i64; + let last = (day / WRECK_EPOCH).floor() as i64; + let mut wrecks: Vec<Wreck> = + (first..=last).flat_map(|epoch| self.batch(epoch)).filter(|w| w.calling(day)).collect(); + wrecks.sort_by(|a, b| a.arrived.total_cmp(&b.arrived)); + wrecks + } + + /// The wrecks calling on `day` that you haven't stripped yet. + pub fn beacons(&self, day: f64, stripped: &HashSet<u64>) -> Vec<Wreck> { + let mut wrecks = self.wrecks_on(day); + wrecks.retain(|w| !stripped.contains(&w.seed)); + wrecks + } + + /// The wrecks you find at `place` on `day`, having set out for it on + /// `departed`: those calling now, and those that were calling when you + /// set out, even if their beacons have failed on the way, since you + /// came by their last fix. Not those you've stripped. Oldest first. + pub fn wrecks_at( + &self, + place: usize, + day: f64, + departed: Option<f64>, + stripped: &HashSet<u64>, + ) -> Vec<Wreck> { + let mut wrecks = self.beacons(day, stripped); + if let Some(departed) = departed.filter(|&d| d < day) { + for w in self.beacons(departed, stripped) { + if !wrecks.iter().any(|x| x.seed == w.seed) { + wrecks.push(w); + } + } + } + wrecks.retain(|w| w.place == place); + wrecks.sort_by(|a, b| a.arrived.total_cmp(&b.arrived)); + wrecks + } + + /// The wrecks that turned up in one epoch, whenever they leave. + fn batch(&self, epoch: i64) -> Vec<Wreck> { + if let Some(batch) = self.batches.borrow().get(&epoch) { + return batch.clone(); + } + let batch_seed = seed::derive(self.seed, "wrecks", epoch as u64); + let mut rng = seed::rng(batch_seed); + // Enough turn up, for as long as they last, that `wreck_rate` are + // calling at any one time. + let expected = self.wreck_rate * WRECK_EPOCH / ((BEACON_LIFE.0 + BEACON_LIFE.1) / 2.0); + let chance = (expected / WRECK_TRIES as f64).min(1.0); + let count = (0..WRECK_TRIES).filter(|_| rng.random_bool(chance)).count(); + let batch: Vec<Wreck> = (0..count) + .map(|i| { + let seed = seed::derive(batch_seed, "wreck", i as u64); + let (name, description) = crawl::describe(seed); + let place = match self.battle { + Some(b) if rng.random_bool(0.6) => b, + _ => rng.random_range(..self.places.len()), + }; + let arrived = (epoch as f64 + rng.random_range(0.0..1.0)) * WRECK_EPOCH; + let leaves = arrived + rng.random_range(BEACON_LIFE.0..BEACON_LIFE.1); + Wreck { seed, name, description, place, arrived, leaves } + }) + .collect(); + self.batches.borrow_mut().insert(epoch, batch.clone()); + batch + } +} + +/// The system a universe has always started you in, made from its seed +/// alone: who holds it, its name and its star come from the seed too. +pub fn generate(seed: u64) -> System { + build(seed, None) +} + +/// A system the galaxy has already decided things about. +pub fn generate_as(seed: u64, profile: &Profile) -> System { + build(seed, Some(profile)) +} + +// Without a profile, this must draw from the RNG exactly as it always has, +// or the system every old universe started in would change under its saves. +fn build(seed: u64, profile: Option<&Profile>) -> System { + let mut rng = seed::rng(seed); + let (culture, system_name, star, kind, rules) = match profile { + None => { + let culture = random_culture(&mut rng); + let name = culture.word(&mut rng); + let star = STARS.choose_weighted(&mut rng, |s| s.weight).unwrap(); + (culture, name, star, SystemKind::Seat, Rules::ORIGINAL) + } + Some(p) => (p.culture, p.name.clone(), p.star, p.kind, Rules::for_profile(p)), + }; + let hz = star.luminosity.sqrt(); + + // Orbits first, so the homeworld can be chosen before names are given. + let count = rng.random_range(rules.planets.clone()); + let mut orbits: Vec<(Orbit, &'static BodyKind, f64)> = Vec::new(); + let mut belt_span = None; + let mut a = hz * rng.random_range(0.3..0.55); + let mut outermost = 0.0; + for i in 0..count { + if i > 0 { + a *= rng.random_range(1.5..2.0); + } + if belt_span.is_none() && i > 0 && a > FROST_LINE * hz { + let inner: f64 = outermost * 1.3; + let outer = inner * rng.random_range(rules.belt_width.0..rules.belt_width.1); + belt_span = Some((inner, outer)); + a = a.max(outer * 1.45); + } + // Keep each orbit well clear of the last, however eccentric. + let mut e = if rng.random_bool(0.15) { + rng.random_range(0.12..0.22) + } else { + rng.random_range(0.0..0.08) + }; + while i > 0 && e > 1e-6 && a * (1.0 - e) < outermost * 1.15 { + e *= 0.5; + } + let kinds = if rules.dead { dead_kinds(a, hz) } else { planet_kinds(a, hz) }; + let kind = *kinds.choose(&mut rng).unwrap(); + let mass = EARTH_MASS + * if kind.giant { rng.random_range(15.0..320.0) } else { rng.random_range(0.05..2.5) }; + let orbit = + Orbit::new(a, e, rng.random_range(0.0..TAU), rng.random_range(0.0..TAU), star.mass); + outermost = orbit.apoapsis_distance(); + orbits.push((orbit, kind, mass)); + } + let (inner, outer) = belt_span.unwrap_or_else(|| { + let inner = outermost * 1.3; + let width = if rules.belt_width == Rules::ORIGINAL.belt_width { + 1.5 + } else { + rng.random_range(rules.belt_width.0..rules.belt_width.1) + }; + (inner, inner * width) + }); + let region = Orbit::new((inner + outer) / 2.0, 0.0, 0.0, rng.random_range(0.0..TAU), star.mass); + let word = culture.word(&mut rng); + let belt = Belt { + name: if kind == SystemKind::Rubble { + format!("the {word} Shoals") + } else { + format!("the {word} Belt") + }, + inner, + outer, + region, + density: rules.density, + richness: rules.richness, + }; + + // Home is the habitable-zone world nearest the middle of the zone. + let home = (0..orbits.len()) + .filter(|&i| !orbits[i].1.giant) + .min_by(|&i, &j| { + let off = |k: usize| (orbits[k].0.a / hz).ln().abs(); + off(i).total_cmp(&off(j)) + }) + .unwrap_or(0); + // And it's always somewhere people could live, unless nothing lives here. + if !rules.dead { + orbits[home].1 = *[&OCEAN, &JUNGLE, &DESERT].choose(&mut rng).unwrap(); + } + let capital = rules.capital >= 1.0 || rng.random_bool(rules.capital); + + let mut planets = Vec::new(); + for (i, (orbit, kind, mass)) in orbits.into_iter().enumerate() { + let people = if i == home { + capital.then_some(culture) + } else { + settle(kind, culture, &rules, &mut rng) + }; + let name = match people { + Some(c) => c.word(&mut rng), + None => format!("{system_name} {}", roman(i + 1)), + }; + let moon_count = + if kind.giant { rng.random_range(1..=4) } else { usize::from(rng.random_bool(0.4)) }; + let mut moon_a = (if kind.giant { 0.002 } else { 0.0012 }) * rng.random_range(1.0..1.6); + // Moons only stay put well inside the planet's Hill sphere, where + // its pull beats the star's. + let hill = orbit.periapsis_distance() * (mass / (3.0 * star.mass)).cbrt(); + let frozen = orbit.a > FROST_LINE * hz; + let mut moons = Vec::new(); + for _ in 0..moon_count { + if moon_a > 0.4 * hill { + break; + } + let kind = *MOON_KINDS[usize::from(frozen)].choose(&mut rng).unwrap(); + let people = settle(kind, culture, &rules, &mut rng).filter(|_| rng.random_bool(0.5)); + let moon_name = match people { + Some(c) => c.word(&mut rng), + None => culture.word(&mut rng), + }; + let orbit = Orbit::new( + moon_a, + rng.random_range(0.0..0.05), + rng.random_range(0.0..TAU), + rng.random_range(0.0..TAU), + mass, + ); + moons.push(Moon { + name: moon_name, + kind, + people, + orbit, + description: describe(kind, people, &mut rng), + }); + moon_a *= rng.random_range(1.6..2.2); + } + planets.push(Planet { + name, + kind, + people, + orbit, + mass, + moons, + description: describe(kind, people, &mut rng), + }); + } + + let stations = stations(&planets, home, culture, &rules, &mut rng); + let places = list_places(&planets, &belt, &stations); + // On a battlefield, most of the wrecks turn up in one place: where the + // fighting was thickest, and still draws scavengers and the lost. + let battle = (kind == SystemKind::Graveyard) + .then(|| seed::rng(seed::derive(seed, "battle", 0)).random_range(..places.len())); + + System { + seed, + name: system_name, + culture, + kind, + star, + planets, + home, + belt, + stations, + places, + wreck_rate: rules.wrecks, + battle, + batches: RefCell::new(BTreeMap::new()), + } +} + +fn settle(kind: &BodyKind, culture: Culture, rules: &Rules, rng: &mut Rng) -> Option<Culture> { + rng.random_bool((kind.settled * rules.settle).min(1.0)).then(|| { + if rng.random_bool(rules.own_share) { + culture + } else { + rules.minority.unwrap_or_else(|| random_culture(rng)) + } + }) +} + +/// Stations at Lagrange points: usually one at the heaviest planet, and +/// often one at the homeworld's too. +fn stations( + planets: &[Planet], + home: usize, + culture: Culture, + rules: &Rules, + rng: &mut Rng, +) -> Vec<Station> { + let heaviest = + (0..planets.len()).max_by(|&i, &j| planets[i].mass.total_cmp(&planets[j].mass)).unwrap(); + let capital = planets[home].people.is_some() && !planets[home].kind.giant; + let mut anchors = vec![heaviest]; + match rules.stations { + Stations::Usual => { + if heaviest != home && rng.random_bool(0.6) { + anchors.push(home); + } + } + Stations::Two => { + let second = if heaviest != home { + Some(home) + } else { + (0..planets.len()) + .filter(|&p| p != heaviest) + .max_by_key(|&p| planets[p].moons.len()) + }; + anchors.extend(second); + } + Stations::One => {} + Stations::Outpost => { + if capital && rng.random_bool(0.5) { + anchors.clear(); + } + } + Stations::None => anchors.clear(), + } + anchors + .into_iter() + .enumerate() + .map(|(i, p)| { + // Over a border, the neighbours may keep the second station. + let who = match rules.minority { + Some(n) if i > 0 && rng.random_bool(0.5) => n, + _ => culture, + }; + station(p, &planets[p], who, rng) + }) + .collect() +} + +fn describe(kind: &BodyKind, people: Option<Culture>, rng: &mut Rng) -> Vec<String> { + let settlement = match people { + Some(c) => settlements(c).choose(rng).unwrap(), + None => UNSETTLED.choose(rng).unwrap(), + }; + vec![kind.flavour.choose(rng).unwrap().to_string(), settlement.to_string()] +} + +fn station(planet: usize, at: &Planet, culture: Culture, rng: &mut Rng) -> Station { + let leading = rng.random_bool(0.5); + let word = culture.word(rng); + let name = match culture { + Culture::Qaari => format!("the {word} Caravanserai"), + Culture::Cant => format!("Foundry {word}"), + Culture::Houses => format!("the {word} Court"), + Culture::Drift => format!("{word} Anchorage"), + }; + let (point, side) = if leading { ("L4", "ahead of") } else { ("L5", "behind") }; + let mut description = vec![format!( + "It turns slowly at {}'s {point} point, sixty degrees {side} the planet, \ + where the pulls of {} and the star balance.", + at.name, at.name + )]; + description.push( + match culture { + Culture::Qaari => { + "Its docks smell of spice-tea and hot metal; caravans of ore haulers queue outside." + } + Culture::Cant => { + "Its hull is studded with salvaged plating, every plate engraved with a prayer." + } + Culture::Houses => { + "Liveried pilots wait in its gilded docks, and everyone is watching everyone." + } + Culture::Drift => { + "It's less a station than a raft of docked ships, rearranged every season." + } + } + .to_string(), + ); + Station { name, culture, planet, leading, description } +} + +/// Every place, in order outward: each planet followed by its moons and any +/// station at its Lagrange points, with the belt where it falls. +fn list_places(planets: &[Planet], belt: &Belt, stations: &[Station]) -> Vec<Place> { + let mut places = Vec::new(); + let mut belt_listed = false; + for (p, planet) in planets.iter().enumerate() { + if !belt_listed && planet.orbit.a > belt.region.a { + places.push(Place { kind: PlaceKind::Belt, name: belt.name.clone() }); + belt_listed = true; + } + places.push(Place { kind: PlaceKind::Planet(p), name: planet.name.clone() }); + for (m, moon) in planet.moons.iter().enumerate() { + places.push(Place { kind: PlaceKind::Moon(p, m), name: moon.name.clone() }); + } + for (s, station) in stations.iter().enumerate().filter(|(_, s)| s.planet == p) { + places.push(Place { kind: PlaceKind::Station(s), name: station.name.clone() }); + } + } + if !belt_listed { + places.push(Place { kind: PlaceKind::Belt, name: belt.name.clone() }); + } + places +} + +fn random_culture(rng: &mut Rng) -> Culture { + Culture::ALL[rng.random_range(..Culture::ALL.len())] +} + +fn roman(n: usize) -> &'static str { + ["0", "I", "II", "III", "IV", "V", "VI", "VII", "VIII"].get(n).copied().unwrap_or("IX") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn systems_are_sound() { + for seed in 0..300 { + let s = generate(seed); + assert!((4..=7).contains(&s.planets.len())); + let mut outermost = 0.0; + for p in &s.planets { + assert!( + p.orbit.periapsis_distance() > outermost, + "seed {seed}: {} crosses", + p.name + ); + outermost = p.orbit.apoapsis_distance(); + let hill = p.orbit.periapsis_distance() * (p.mass / (3.0 * s.star.mass)).cbrt(); + for m in &p.moons { + assert!( + m.orbit.a < 0.4 * hill, + "seed {seed}: {} strays from {}", + m.name, + p.name + ); + assert!(m.orbit.period < p.orbit.period / 5.0); + } + } + let crosses_belt = |p: &Planet| { + p.orbit.apoapsis_distance() > s.belt.inner + && p.orbit.periapsis_distance() < s.belt.outer + }; + assert!( + !s.planets.iter().any(crosses_belt), + "seed {seed}: a planet runs through the belt" + ); + assert_eq!(s.planets[s.home].people, Some(s.culture), "seed {seed}: home is unsettled"); + assert!(!s.planets[s.home].kind.giant); + assert!(!s.stations.is_empty()); + let belts = s.places.iter().filter(|p| p.kind == PlaceKind::Belt).count(); + assert_eq!(belts, 1); + for w in s.wrecks_on(0.0) { + assert!(w.place < s.places.len()); + assert!(w.calling(0.0)); + } + } + } + + #[test] + fn capitals_need_people_and_ground() { + for seed in 0..100 { + let s = generate(seed); + assert_eq!(s.landing(s.home_place()), Landing::Capital(s.culture)); + for (i, place) in s.places.iter().enumerate() { + let landing = s.landing(i); + match place.kind { + PlaceKind::Planet(p) if s.planets[p].kind.giant => { + assert!(matches!(landing, Landing::Aloft(_) | Landing::Nowhere)); + } + PlaceKind::Station(_) => assert!(matches!(landing, Landing::Market(_))), + PlaceKind::Belt => assert_eq!(landing, Landing::Nowhere), + _ => {} + } + } + } + } + + #[test] + fn places_are_listed_outward() { + let s = generate(8); + let distances: Vec<f64> = (0..s.places.len()) + .filter(|&i| matches!(s.places[i].kind, PlaceKind::Planet(_) | PlaceKind::Belt)) + .map(|i| match s.places[i].kind { + PlaceKind::Belt => s.belt.region.a, + PlaceKind::Planet(p) => s.planets[p].orbit.a, + _ => unreachable!(), + }) + .collect(); + assert!(distances.windows(2).all(|w| w[0] < w[1]), "{distances:?}"); + } + + #[test] + fn stations_sit_sixty_degrees_from_their_planet() { + let s = generate(3); + for (i, place) in s.places.iter().enumerate() { + if let PlaceKind::Station(k) = place.kind { + let planet = s.planets[s.stations[k].planet].orbit.position(40.0); + let station = s.position(i, 40.0); + let between = (planet.dot(station) / (planet.length() * station.length())).acos(); + assert!((between - PI / 3.0).abs() < 1e-9); + } + } + } + + #[test] + fn same_seed_same_system() { + let names = |s: System| s.places.iter().map(|p| p.name.clone()).collect::<Vec<_>>(); + assert_eq!(names(generate(5)), names(generate(5))); + } + + #[test] + fn wrecks_come_and_go() { + let mut total = 0; + let days = 0..400; + for seed in 0..6 { + let s = generate(seed); + for day in days.clone() { + let day = day as f64; + let here = s.wrecks_on(day); + total += here.len(); + for w in &here { + let life = w.leaves - w.arrived; + assert!((BEACON_LIFE.0..BEACON_LIFE.1).contains(&life)); + } + } + // A wreck calling now is long gone in a few months, and others + // have come in its place. + let now: Vec<u64> = s.wrecks_on(10.0).iter().map(|w| w.seed).collect(); + let later = s.wrecks_on(110.0); + assert!(later.iter().all(|w| !now.contains(&w.seed))); + // The same day always has the same wrecks, cached or not. + let again: Vec<u64> = generate(seed).wrecks_on(10.0).iter().map(|w| w.seed).collect(); + assert_eq!(now, again); + } + let average = total as f64 / (6 * days.len()) as f64; + assert!((2.0..3.0).contains(&average), "{average} wrecks calling at once"); + } + + #[test] + fn a_failed_beacons_last_fix_still_finds_its_wreck() { + let s = generate(1); + let none = HashSet::new(); + let wreck = s.wrecks_on(0.0)[0].clone(); + let later = wreck.leaves + 2.0; + let found = |departed| s.wrecks_at(wreck.place, later, departed, &none); + assert!(!found(None).contains(&wreck), "its beacon has failed"); + assert!(found(Some(0.0)).contains(&wreck), "but it called when you set out"); + assert!(!found(Some(wreck.leaves)).contains(&wreck), "not if it had failed already"); + assert!(found(Some(0.0)).iter().all(|w| w.place == wreck.place), "only those here"); + let stripped = HashSet::from([wreck.seed]); + assert!(!s.wrecks_at(wreck.place, later, Some(0.0), &stripped).contains(&wreck)); + } + + #[test] + fn graveyards_are_thick_with_wrecks() { + let profile = Profile { + name: "Ashfall".into(), + culture: Culture::Houses, + star: &STARS[0], + kind: SystemKind::Graveyard, + neighbour: None, + }; + let mut total = 0; + let mut at_battle = 0; + for seed in 0..6 { + let s = generate_as(seed, &profile); + let battle = s.battle.unwrap(); + for day in (0..300).step_by(3) { + let here = s.wrecks_on(day as f64); + total += here.len(); + at_battle += here.iter().filter(|w| w.place == battle).count(); + } + } + let average = total as f64 / (6 * 100) as f64; + assert!((5.0..8.0).contains(&average), "{average} wrecks calling at once"); + assert!(at_battle * 2 > total, "most lie where the fighting was"); + } +} diff --git a/src/town/interior.rs b/src/town/interior.rs new file mode 100644 index 0000000..4e09333 --- /dev/null +++ b/src/town/interior.rs @@ -0,0 +1,1112 @@ +//! Inside a building. +//! +//! Inside is drawn at a much closer scale than the street: a house three +//! rows deep outside is eleven rows deep inside. You come in by a door on +//! the same side as the one you bumped outside, at the same place along +//! the wall. +//! +//! Rooms come from splitting the floor with walls, each wall with a +//! doorway, so every room can be reached. Most buildings have a front room +//! as wide as the building with smaller rooms behind it: a shop's counter, +//! a tavern's taproom, a temple's nave, a palace's throne room. Houses are +//! split any which way. Each room is furnished for what it's for, with a +//! check after every piece that nothing has been cut off, and then the +//! people go in. +//! +//! "Back" is the side away from the door, wherever that is. Rooms are +//! furnished in a frame turned to match: `u` runs along the back wall and +//! `v` away from it, so a counter or an altar always stands at the back. + +use rand::RngExt; +use rand::seq::{IndexedRandom, SliceRandom}; + +use super::words; +use super::*; +use crate::seed::Rng; + +/// Furniture. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Thing { + Table, + Chair, + Bed, + Hearth, + Stove, + Shelf, + Chest, + Crate, + Barrel, + Altar, + Pew, + Pillar, + Statue, + Throne, + Plant, + /// Hot water, in a bathhouse. + Bath, + Anvil, + Forge, + Loom, + Workbench, + /// Something to sit on, in a changing room or a steam room. + Bench, + /// Weapons, in a guardroom. + Rack, + Lamp, + /// Pieces of ship, in a shipyard. + Engine, +} + +impl Thing { + /// Seats can be walked over; people sit on them. + pub fn walkable(self) -> bool { + matches!(self, Thing::Chair | Thing::Pew | Thing::Bench | Thing::Throne) + } +} + +/// What a room is for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Purpose { + Living, + Bedroom, + Kitchen, + Store, + /// A room of one's own, in a tenement. + Lodging, + Taproom, + Parlour, + Cellar, + /// A shop's front room. + Counter, + Ward, + Stacks, + Assay, + Yard, + /// Where an armourer makes and mends. + Smithy, + Hall, + Dining, + Library, + Bedchamber, + Gallery, + Throne, + Council, + Treasury, + Nave, + Vestry, + Cell, + Shrine, + Workroom, + Storehouse, + Office, + Changing, + Baths, + Steam, + Guardroom, + Bunks, +} + +struct Room { + rect: Rect, + purpose: Purpose, +} + +pub struct Interior { + pub building: usize, + pub culture: Culture, + pub kind: Kind, + pub site: Site, + rooms: Vec<Room>, + /// Which room each cell is in, `NO_ROOM` for walls and doorways. + room_of: Vec<u8>, + /// Where you stand when you come in, just inside the door. + pub start: Pos, + pub exit: Pos, + /// The shop whose counter this is. + pub shop: Option<usize>, +} + +const NO_ROOM: u8 = u8::MAX; + +impl Interior { + pub fn purpose_at(&self, p: Pos) -> Option<Purpose> { + if !self.site.contains(p) { + return None; + } + self.rooms.get(usize::from(self.room_of[self.site.index(p)])).map(|r| r.purpose) + } + + pub fn room_count(&self) -> usize { + self.rooms.len() + } + + /// The shop, if `p` is its counter or its keeper. + pub fn shop_at(&self, p: Pos) -> Option<usize> { + let shop = self.shop?; + let keeper = self.site.person_at(p).map(|i| self.site.people[i].role); + (self.site.tile(p) == Tile::Counter || keeper == Some(Role::Keeper(shop))).then_some(shop) + } +} + +/// A room, turned so its back wall is at `v = -1`. +#[derive(Clone, Copy)] +struct Frame { + r: Rect, + back: Side, +} + +impl Frame { + fn len_u(self) -> i32 { + if matches!(self.back, Side::North | Side::South) { + self.r.width() + } else { + self.r.height() + } + } + + fn len_v(self) -> i32 { + if matches!(self.back, Side::North | Side::South) { + self.r.height() + } else { + self.r.width() + } + } + + fn at(self, u: i32, v: i32) -> Pos { + let r = self.r; + match self.back { + Side::North => Pos::new(r.left + u, r.top + v), + Side::South => Pos::new(r.left + u, r.bottom - v), + Side::West => Pos::new(r.left + v, r.top + u), + Side::East => Pos::new(r.right - v, r.top + u), + } + } +} + +enum Layout { + /// Split any which way, into this many rooms or so. + Split(usize, usize), + /// A front room as wide as the building, `share` of its depth, with + /// this many rooms side by side behind it. + Hall(f64, usize, usize), + Single, +} + +/// How a building of this kind is laid out: the plan, what the front room +/// is for, and what the rooms behind it are for, in order. If there are +/// more rooms than purposes, the last purpose repeats. +fn layout(kind: Kind) -> (Layout, Purpose, &'static [Purpose]) { + use Purpose::*; + match kind { + Kind::House => (Layout::Split(1, 4), Living, &[Bedroom, Kitchen, Bedroom, Store, Bedroom]), + Kind::Tenement => (Layout::Split(4, 9), Kitchen, &[Lodging]), + Kind::Manor => ( + Layout::Hall(0.45, 3, 6), + Hall, + &[Dining, Library, Bedchamber, Gallery, Kitchen, Bedchamber, Store], + ), + Kind::Palace => ( + Layout::Hall(0.55, 4, 8), + Throne, + &[Council, Treasury, Library, Bedchamber, Dining, Gallery, Kitchen, Guardroom], + ), + Kind::Temple => (Layout::Hall(0.72, 1, 3), Nave, &[Vestry, Cell, Cell]), + Kind::Shrine => (Layout::Single, Shrine, &[]), + Kind::Workshop => (Layout::Hall(0.62, 1, 2), Workroom, &[Store, Bedroom]), + Kind::Warehouse => (Layout::Hall(0.8, 1, 1), Storehouse, &[Office]), + Kind::Bathhouse => (Layout::Hall(0.32, 1, 2), Changing, &[Baths, Steam]), + Kind::Guardhouse => (Layout::Split(2, 3), Guardroom, &[Bunks, Store]), + Kind::Shop(ShopKind::Broker) => (Layout::Hall(0.55, 1, 2), Counter, &[Assay, Store]), + Kind::Shop(ShopKind::Doctor) => (Layout::Hall(0.5, 1, 2), Counter, &[Ward, Store]), + Kind::Shop(ShopKind::Scholar) => (Layout::Hall(0.5, 1, 2), Counter, &[Stacks]), + Kind::Shop(ShopKind::Tavern) => { + (Layout::Hall(0.65, 1, 3), Taproom, &[Parlour, Cellar, Kitchen]) + } + Kind::Shop(ShopKind::Shipyard) => (Layout::Hall(0.45, 1, 2), Counter, &[Yard, Store]), + Kind::Shop(ShopKind::Armoury) => (Layout::Hall(0.5, 1, 2), Counter, &[Smithy, Store]), + } +} + +struct Builder { + culture: Culture, + kind: Kind, + rng: Rng, + site: Site, + rooms: Vec<Room>, + start: Pos, + exit: Pos, + /// Which side the back wall is on. + back: Side, + shop: Option<usize>, + keeper: Option<String>, + /// How many cells you can walk to from the door, to check nothing gets + /// cut off as the furniture goes in. + reachable: usize, +} + +pub(super) fn build(t: &Town, b: usize) -> Interior { + let building = &t.buildings[b]; + let kind = building.kind; + let mut rng = seed::rng(seed::derive(t.seed, "interior", b as u64)); + let scale = if matches!(kind, Kind::Palace | Kind::Temple | Kind::Manor) { 3.6 } else { 3.4 }; + let grow = |n: i32, lo: i32, hi: i32| ((f64::from(n) * scale).round() as i32 + 1).clamp(lo, hi); + let shopfront = matches!(kind, Kind::Shop(_)); + let width = grow(building.width(), if shopfront { 17 } else { 13 }, 77); + let height = grow(building.height(), if shopfront { 11 } else { 9 }, 29); + let width = width + rng.random_range(0..=2); + + // The door in, at the same place along the wall as outside. + let along = |at: i32, lo: i32, hi: i32, len: i32| { + let k = if hi > lo { f64::from(at - lo) / f64::from(hi - lo) } else { 0.5 }; + ((1.0 + k * f64::from(len - 3)).round() as i32).clamp(2, len - 3) + }; + let (bx, by) = (building.door.x, building.door.y); + let exit = match building.facing { + Side::North => Pos::new(along(bx, building.left, building.right, width), 0), + Side::South => Pos::new(along(bx, building.left, building.right, width), height - 1), + Side::West => Pos::new(0, along(by, building.top, building.bottom, height)), + Side::East => Pos::new(width - 1, along(by, building.top, building.bottom, height)), + }; + let (dx, dy) = building.facing.step(); + let start = exit.offset(-dx, -dy); + + let mut site = Site::new(width, height, Tile::Wall, Tile::Wall, false); + site.fill(Rect::new(1, 1, width - 2, height - 2), Tile::Floor); + site.set(exit, Tile::Exit); + let shop = building.shop; + let mut builder = Builder { + culture: t.culture, + kind, + rng, + site, + rooms: Vec::new(), + start, + exit, + back: building.facing.opposite(), + shop, + keeper: shop.map(|s| t.shops[s].keeper.clone()), + reachable: 0, + }; + builder.divide(); + builder.reachable = builder.count_reachable(); + for room in 0..builder.rooms.len() { + builder.furnish(room); + } + builder.residents(); + + let Builder { site, rooms, .. } = builder; + let mut room_of = vec![NO_ROOM; (width * height) as usize]; + for (i, room) in rooms.iter().enumerate() { + for p in room.rect.cells() { + room_of[site.index(p)] = i as u8; + } + } + Interior { building: b, culture: t.culture, kind, site, rooms, room_of, start, exit, shop } +} + +impl Builder { + fn tile(&self, p: Pos) -> Tile { + self.site.tile(p) + } + + fn is_door(&self, p: Pos) -> bool { + matches!(self.tile(p), Tile::Doorway | Tile::Exit) + } + + /// Whether a wall across `r` at `at` would block a doorway that opens + /// onto the end of it. + fn can_wall(&self, r: Rect, upright: bool, at: i32) -> bool { + let ends = if upright { + [Pos::new(at, r.top - 1), Pos::new(at, r.bottom + 1)] + } else { + [Pos::new(r.left - 1, at), Pos::new(r.right + 1, at)] + }; + !ends.iter().any(|&p| self.is_door(p)) + } + + /// Builds a wall across `r`, upright (a column) or not (a row), with a + /// doorway in it somewhere in `doors`. Returns the rooms either side. + fn wall( + &mut self, + r: Rect, + upright: bool, + at: i32, + doors: std::ops::RangeInclusive<i32>, + ) -> (Rect, Rect) { + let cells: Vec<Pos> = if upright { + (r.top..=r.bottom).map(|y| Pos::new(at, y)).collect() + } else { + (r.left..=r.right).map(|x| Pos::new(x, at)).collect() + }; + for &p in &cells { + self.site.set(p, Tile::Wall); + } + let options: Vec<Pos> = cells + .iter() + .copied() + .filter(|p| { + let k = if upright { p.y } else { p.x }; + doors.contains(&k) + }) + .collect(); + let door = options.choose(&mut self.rng).or(cells.get(cells.len() / 2)).copied(); + if let Some(door) = door { + self.site.set(door, Tile::Doorway); + } + if upright { + ( + Rect::new(r.left, r.top, at - 1, r.bottom), + Rect::new(at + 1, r.top, r.right, r.bottom), + ) + } else { + ( + Rect::new(r.left, r.top, r.right, at - 1), + Rect::new(r.left, at + 1, r.right, r.bottom), + ) + } + } + + /// Splits the floor into rooms. + fn divide(&mut self) { + let floor = Rect::new(1, 1, self.site.width - 2, self.site.height - 2); + let (plan, front, behind) = layout(self.kind); + let mut rects = Vec::new(); + match plan { + Layout::Single => rects.push(floor), + Layout::Split(lo, hi) => { + let area = floor.area() as usize; + let most = (area / 40).max(1); + let target = self.rng.random_range(lo..=hi).min(most).max(lo.min(most)); + self.split(floor, target, &mut rects); + } + Layout::Hall(share, lo, hi) => self.hall(floor, share, (lo, hi), &mut rects), + } + // The front room is the one you walk into. + let first = rects.iter().position(|r| r.contains(self.start)).unwrap_or(0); + let front_rect = rects.remove(first); + self.rooms.push(Room { rect: front_rect, purpose: front }); + for (i, rect) in rects.into_iter().enumerate() { + let purpose = behind.get(i).or(behind.last()).copied().unwrap_or(front); + self.rooms.push(Room { rect, purpose }); + } + } + + fn split(&mut self, r: Rect, rooms: usize, out: &mut Vec<Rect>) { + let (min_w, min_h) = (4, 3); + if rooms <= 1 { + out.push(r); + return; + } + let wide = + f64::from(r.width()) > 2.0 * f64::from(r.height()) * self.rng.random_range(0.7..1.4); + for upright in [wide, !wide] { + let (lo, hi) = if upright { + (r.left + min_w, r.right - min_w) + } else { + (r.top + min_h, r.bottom - min_h) + }; + let options: Vec<i32> = (lo..=hi).filter(|&at| self.can_wall(r, upright, at)).collect(); + let Some(&at) = options.choose(&mut self.rng) else { continue }; + let span = if upright { r.top + 1..=r.bottom - 1 } else { r.left + 1..=r.right - 1 }; + let (a, b) = self.wall(r, upright, at, span); + // Share the rooms out by size. + let share = a.area() as f64 / (a.area() + b.area()).max(1) as f64; + let first = ((rooms as f64 * share).round() as usize).clamp(1, rooms - 1); + self.split(a, first, out); + self.split(b, rooms - first, out); + return; + } + out.push(r); + } + + fn hall(&mut self, floor: Rect, share: f64, (lo, hi): (usize, usize), out: &mut Vec<Rect>) { + let f = Frame { r: floor, back: self.back }; + let (len_u, len_v) = (f.len_u(), f.len_v()); + let upright = matches!(self.back, Side::West | Side::East); + // The back rooms take `v` from 0 to `k - 1`, then comes the wall, + // then the front room, at least four deep for a counter. + let least = if upright { 4 } else { 2 }; + if len_v < least + 5 { + out.push(floor); + return; + } + let k = ((f64::from(len_v) * (1.0 - share)).round() as i32).clamp(least, len_v - 5); + let at = if upright { f.at(0, k).x } else { f.at(0, k).y }; + // Temples and palaces keep the middle of the wall for the altar or + // throne, so their doorway is off to one side. + let off_centre = matches!(self.kind, Kind::Temple | Kind::Palace); + let span = if upright { + floor.top + 1..=floor.bottom - 1 + } else { + floor.left + 1..=floor.right - 1 + }; + let span = if off_centre { + let (a, b) = (*span.start(), *span.end()); + let quarter = ((b - a) / 5).max(1); + if self.rng.random_bool(0.5) { a..=a + quarter } else { b - quarter..=b } + } else { + span + }; + let (one, two) = self.wall(floor, upright, at, span); + let (back_rect, front_rect) = + if one.contains(f.at(0, 0)) { (one, two) } else { (two, one) }; + out.push(front_rect); + // Rooms side by side behind: split across `u`. + let min_u = if upright { 3 } else { 5 }; + let most = (len_u / (min_u + 1)).max(1) as usize; + let n = self.rng.random_range(lo..=hi).min(most); + let mut rest = back_rect; + for i in (1..n).rev() { + // Cut off one room from the far end of `rest`, sized to leave + // the others about as big. + let (from, to) = + if upright { (rest.top, rest.bottom) } else { (rest.left, rest.right) }; + let size = (to - from + 1) / (i as i32 + 1); + let ideal = to - size; + let options: Vec<i32> = (ideal - 2..=ideal + 2) + .filter(|&a| a - from >= min_u && to - a >= min_u) + .filter(|&a| self.can_wall(rest, !upright, a)) + .collect(); + let Some(&a) = options.choose(&mut self.rng) else { break }; + let span = if upright { rest.left..=rest.right } else { rest.top..=rest.bottom }; + let (near, far) = self.wall(rest, !upright, a, span); + out.push(far); + rest = near; + } + out.push(rest); + } + + fn count_reachable(&self) -> usize { + self.site.reachable(self.start).iter().filter(|&&seen| seen).count() + } + + /// Puts a piece of furniture on a bare floor cell, unless it would + /// crowd a doorway or cut off part of the building. + fn put(&mut self, p: Pos, thing: Thing) -> bool { + if self.tile(p) != Tile::Floor || p.neighbours().any(|n| self.is_door(n)) || p == self.start + { + return false; + } + self.site.set(p, Tile::Furniture(thing)); + if thing.walkable() { + return true; + } + let now = self.count_reachable(); + if now + 1 == self.reachable { + self.reachable = now; + true + } else { + self.site.set(p, Tile::Floor); + false + } + } + + fn frame(&self, room: usize) -> Frame { + Frame { r: self.rooms[room].rect, back: self.back } + } + + fn floor_of(&self, room: usize) -> Vec<Pos> { + self.rooms[room].rect.cells().filter(|&p| self.tile(p) == Tile::Floor).collect() + } + + /// Bare floor against the walls, in a random order. + fn by_wall(&mut self, room: usize) -> Vec<Pos> { + let mut cells: Vec<Pos> = self + .floor_of(room) + .into_iter() + .filter(|&p| { + [(0, -1), (0, 1), (-1, 0), (1, 0)] + .iter() + .any(|&(dx, dy)| self.tile(p.offset(dx, dy)) == Tile::Wall) + }) + .collect(); + cells.shuffle(&mut self.rng); + cells + } + + /// Bare floor out in the room, away from the walls, in a random order. + fn middle(&mut self, room: usize) -> Vec<Pos> { + let mut cells: Vec<Pos> = self + .floor_of(room) + .into_iter() + .filter(|&p| p.neighbours().all(|n| self.tile(n) != Tile::Wall)) + .collect(); + cells.shuffle(&mut self.rng); + cells + } + + fn scatter(&mut self, cells: Vec<Pos>, thing: Thing, n: usize) -> usize { + let mut placed = 0; + for p in cells { + if placed == n { + break; + } + if self.put(p, thing) { + placed += 1; + } + } + placed + } + + fn against(&mut self, room: usize, thing: Thing, lo: usize, hi: usize) { + let n = self.rng.random_range(lo..=hi); + let cells = self.by_wall(room); + self.scatter(cells, thing, n); + } + + fn out_in(&mut self, room: usize, thing: Thing, lo: usize, hi: usize) { + let n = self.rng.random_range(lo..=hi); + let cells = self.middle(room); + self.scatter(cells, thing, n); + } + + fn rug(&mut self, room: usize, chance: f64) { + if !self.rng.random_bool(chance) { + return; + } + let r = self.rooms[room].rect; + if r.width() < 6 || r.height() < 4 { + return; + } + let inner = Rect::new(r.left + 2, r.top + 1, r.right - 2, r.bottom - 1); + for p in inner.cells() { + if self.tile(p) == Tile::Floor { + self.site.set(p, Tile::Rug); + } + } + } + + /// A table a few cells long out in the room, chairs round it. + fn table(&mut self, room: usize, seats: usize) -> Vec<Pos> { + let cells = self.middle(room); + for c in cells.into_iter().take(6) { + let len = self.rng.random_range(1..=3); + if !(0..len).all(|i| self.tile(c.offset(i, 0)) == Tile::Floor) { + continue; + } + let mut placed = 0; + for i in 0..len { + if self.put(c.offset(i, 0), Thing::Table) { + placed += 1; + } + } + if placed == 0 { + continue; + } + let mut around: Vec<Pos> = (0..placed) + .flat_map(|i| [c.offset(i, -1), c.offset(i, 1)]) + .chain([c.offset(-1, 0), c.offset(placed, 0)]) + .collect(); + around.shuffle(&mut self.rng); + let mut chairs = Vec::new(); + for p in around.into_iter().take(seats.max(2)) { + if self.put(p, Thing::Chair) { + chairs.push(p); + } + } + return chairs; + } + Vec::new() + } + + /// A long table down the middle of the room, chairs either side. + fn long_table(&mut self, room: usize) -> Vec<Pos> { + let r = self.rooms[room].rect; + let c = r.centre(); + let mut chairs = Vec::new(); + if r.width() < 7 || r.height() < 3 { + return chairs; + } + for x in r.left + 2..=r.right - 2 { + if self.put(Pos::new(x, c.y), Thing::Table) { + for dy in [-1, 1] { + if self.put(Pos::new(x, c.y + dy), Thing::Chair) { + chairs.push(Pos::new(x, c.y + dy)); + } + } + } + } + chairs + } + + /// Rows of something across the room, every `every` rows back from the + /// back wall, leaving aisles at the ends and, if `aisle`, one down the + /// middle. + fn rows(&mut self, room: usize, thing: Thing, first: i32, every: i32, aisle: bool) -> Vec<Pos> { + let f = self.frame(room); + let (len_u, len_v) = (f.len_u(), f.len_v()); + let mid = len_u / 2; + let mut placed = Vec::new(); + let mut v = first; + while v < len_v - 2 { + for u in 1..len_u - 1 { + if aisle && (u - mid).abs() <= 1 { + continue; + } + let p = f.at(u, v); + if self.put(p, thing) { + placed.push(p); + } + } + v += every; + } + placed + } + + /// A runner from the front of the room to the back, down the middle. + fn runner(&mut self, room: usize) { + let f = self.frame(room); + let mid = f.len_u() / 2; + for v in 0..f.len_v() { + for u in [mid - 1, mid, mid + 1] { + let p = f.at(u, v); + if self.tile(p) == Tile::Floor { + self.site.set(p, Tile::Rug); + } + } + } + } + + /// The keeper's corner at the back of the room: shelves against the + /// back wall, a counter in front of them, the keeper between, and the + /// counter turned back to the wall at either end so nobody walks round + /// it. Keeps clear of doorways in the back wall. Returns where the + /// keeper stands. + fn nook(&mut self, room: usize, share: f64) -> Option<Pos> { + let f = self.frame(room); + let (len_u, len_v) = (f.len_u(), f.len_v()); + if len_v < 4 || len_u < 5 { + return None; + } + // Doorways in the back wall, and the side walls by the back. + let blocked: Vec<i32> = + (0..len_u).filter(|&u| self.is_door(f.at(u, -1)) || self.is_door(f.at(u, 0))).collect(); + let side_doors = (0..3).any(|v| self.is_door(f.at(-1, v))) + || (0..3).any(|v| self.is_door(f.at(len_u, v))); + // The longest run of the back wall with no doorway within a cell. + let mut best = (0, -1); + let mut start = 0; + for u in 0..=len_u { + let clear = u < len_u && !blocked.iter().any(|&b| (b - u).abs() <= 1); + if !clear { + if u - 1 - start > best.1 - best.0 { + best = (start, u - 1); + } + start = u + 1; + } + } + let (lo, hi) = best; + // Room for a cap at each end that isn't against a side wall. + let lo = if lo == 0 && !side_doors { 0 } else { lo + 1 }; + let hi = if hi == len_u - 1 && !side_doors { hi } else { hi - 1 }; + let room_for = hi - lo + 1; + if room_for < 3 { + return None; + } + let want = ((f64::from(len_u) * share).round() as i32).clamp(3, room_for); + let a = self.rng.random_range(lo..=hi - want + 1); + let b = a + want - 1; + let mid = (a + b) / 2; + for u in a - 1..=b + 1 { + if u >= 0 && u < len_u { + self.site.set(f.at(u, 1), Tile::Counter); + } + } + for u in [a - 1, b + 1] { + if u >= 0 && u < len_u { + self.site.set(f.at(u, 0), Tile::Counter); + } + } + for u in a..=b { + self.site.set(f.at(u, 0), if u == mid { Tile::Floor } else { Tile::Shelf }); + } + self.reachable = self.count_reachable(); + Some(f.at(mid, 0)) + } + + /// A free floor cell in the room that you can walk to. + fn spot(&mut self, room: usize, seen: &[bool]) -> Option<Pos> { + let mut cells: Vec<Pos> = self.rooms[room] + .rect + .cells() + .filter(|&p| { + matches!(self.tile(p), Tile::Floor | Tile::Rug) + && seen[self.site.index(p)] + && self.site.person_at(p).is_none() + && p != self.start + && !p.neighbours().any(|n| n == self.exit) + }) + .collect(); + cells.shuffle(&mut self.rng); + cells.first().copied() + } + + fn add(&mut self, pos: Pos, role: Role, stays: bool) { + let named = matches!( + role, + Role::Resident | Role::Ruler | Role::Priest | Role::Worker | Role::Courtier + ); + let name = match role { + Role::Keeper(_) => self.keeper.clone(), + _ if named => Some(self.culture.person(&mut self.rng)), + _ => None, + }; + let says = *words::lines(role, self.culture).choose(&mut self.rng).unwrap_or(&"..."); + self.site.people.push(Person { pos, role, name, says, stays }); + } + + /// Up to `n` people wandering the room. + fn crowd(&mut self, room: usize, role: Role, n: std::ops::RangeInclusive<usize>) { + let n = self.rng.random_range(n); + let seen = self.site.reachable(self.start); + for _ in 0..n { + if let Some(p) = self.spot(room, &seen) { + self.add(p, role, false); + } + } + } + + /// Up to `n` people sitting in these seats, staying put. + fn seat(&mut self, seats: &[Pos], role: Role, n: std::ops::RangeInclusive<usize>) { + let n = self.rng.random_range(n); + let mut seats: Vec<Pos> = + seats.iter().copied().filter(|&p| self.site.person_at(p).is_none()).collect(); + seats.shuffle(&mut self.rng); + for p in seats.into_iter().take(n) { + self.add(p, role, true); + } + } + + /// Puts `thing` at the middle of the back wall, and returns the cell in + /// front of it. + fn at_back(&mut self, room: usize, thing: Thing) -> Option<Pos> { + let f = self.frame(room); + let mid = f.len_u() / 2; + for u in [mid, mid - 1, mid + 1, mid - 2, mid + 2] { + let p = f.at(u, 0); + if self.put(p, thing) { + return Some(f.at(u, 1)); + } + } + None + } + + fn furnish(&mut self, room: usize) { + use Purpose::*; + use Thing::*; + let purpose = self.rooms[room].purpose; + let area = self.rooms[room].rect.area() as usize; + let culture = self.culture; + match purpose { + Living => { + self.against(room, Hearth, 1, 1); + self.table(room, 4); + self.against(room, Shelf, 1, 2); + self.against(room, Chest, 0, 1); + self.out_in(room, Plant, 0, 1); + self.rug(room, 0.3); + } + Bedroom => { + self.against(room, Bed, 1, 2); + self.against(room, Chest, 0, 1); + self.rug(room, 0.3); + } + Lodging => { + self.against(room, Bed, 1, 1); + self.against(room, Chest, 0, 1); + self.crowd(room, Role::Resident, 0..=1); + } + Kitchen => { + self.against(room, Stove, 1, 1); + self.against(room, Barrel, 1, 3); + self.against(room, Shelf, 0, 1); + self.table(room, 2); + if self.kind == Kind::Tenement { + self.crowd(room, Role::Resident, 1..=2); + } + } + Store => { + self.against(room, Crate, 2, 5); + self.against(room, Barrel, 1, 3); + } + Taproom => { + if let Some(at) = self.nook(room, 0.45) { + self.add(at, Role::Keeper(self.shop.unwrap_or(0)), true); + } + self.against(room, Hearth, 1, 1); + let mut seats = Vec::new(); + for _ in 0..(area / 28).clamp(2, 8) { + seats.extend(self.table(room, 4)); + } + let n = self.rng.random_range(3..=(seats.len().max(3))); + self.seat(&seats, Role::Patron, n..=n); + self.crowd(room, Role::Patron, 0..=2); + } + Parlour => { + let mut seats = self.table(room, 4); + if area > 40 { + seats.extend(self.table(room, 4)); + } + self.seat(&seats, Role::Patron, 1..=3); + self.against(room, Shelf, 0, 1); + } + Cellar => { + self.rows(room, Barrel, 1, 2, false); + self.against(room, Crate, 1, 3); + } + Counter => { + if let Some(at) = self.nook(room, 0.75) { + self.add(at, Role::Keeper(self.shop.unwrap_or(0)), true); + } + match self.kind { + Kind::Shop(ShopKind::Doctor) => { + let cells = self.by_wall(room); + let seats: Vec<Pos> = + cells.into_iter().filter(|&p| self.put(p, Chair)).take(3).collect(); + self.seat(&seats, Role::Patient, 0..=1); + self.out_in(room, Plant, 0, 1); + } + Kind::Shop(ShopKind::Scholar) => { + self.against(room, Shelf, 2, 5); + self.out_in(room, Lamp, 1, 1); + } + Kind::Shop(ShopKind::Broker) => self.against(room, Crate, 2, 4), + Kind::Shop(ShopKind::Shipyard) => { + self.out_in(room, Engine, 1, 2); + self.against(room, Crate, 1, 3); + } + Kind::Shop(ShopKind::Armoury) => self.against(room, Rack, 2, 5), + _ => {} + } + } + Ward => { + let n = (self.rooms[room].rect.width() / 3).clamp(2, 8) as usize; + self.against(room, Bed, n, n); + self.crowd(room, Role::Patient, 1..=3); + self.against(room, Chest, 0, 1); + } + Stacks => { + self.rows(room, Shelf, 1, 2, true); + self.crowd(room, Role::Reader, 0..=2); + } + Assay => { + self.out_in(room, Workbench, 1, 2); + self.against(room, Crate, 2, 4); + self.crowd(room, Role::Worker, 0..=1); + } + Smithy => { + self.against(room, Forge, 1, 1); + self.out_in(room, Anvil, 1, 2); + self.against(room, Rack, 1, 2); + self.out_in(room, Workbench, 0, 1); + self.crowd(room, Role::Worker, 0..=1); + } + Yard => { + self.out_in(room, Engine, 2, 4); + self.against(room, Crate, 2, 5); + self.out_in(room, Anvil, 0, 1); + self.crowd(room, Role::Worker, 1..=3); + } + Hall => { + self.runner(room); + self.against(room, Statue, 2, 4); + self.against(room, Plant, 1, 3); + self.against(room, Lamp, 1, 2); + } + Dining => { + let seats = self.long_table(room); + self.against(room, Shelf, 0, 1); + if self.rng.random_bool(0.4) { + self.seat(&seats, Role::Resident, 1..=1); + } + self.crowd(room, Role::Servant, 0..=1); + } + Library => { + self.against(room, Shelf, 4, 12); + let seats = self.table(room, 2); + self.seat(&seats, Role::Reader, 0..=1); + } + Bedchamber => { + self.against(room, Bed, 1, 1); + self.against(room, Chest, 1, 1); + self.against(room, Lamp, 0, 1); + self.out_in(room, Plant, 0, 1); + self.rug(room, 0.7); + } + Gallery => { + self.against(room, Statue, 3, 6); + self.out_in(room, Plant, 1, 2); + } + Purpose::Throne => { + self.runner(room); + let f = self.frame(room); + let mid = f.len_u() / 2; + // The throne at the end of the runner, lamps beside it. + let throne = f.at(mid, 0); + if self.tile(throne) == Tile::Rug { + self.site.set(throne, Tile::Floor); + } + if self.put(throne, Thing::Throne) { + self.add(throne, Role::Ruler, true); + } + for u in [mid - 2, mid + 2] { + self.put(f.at(u, 0), Lamp); + } + // Pillars down both sides, guards along the runner. + let mut v = 2; + while v < f.len_v() - 2 { + for u in [mid - 4, mid + 4] { + if u > 0 && u < f.len_u() - 1 { + self.put(f.at(u, v), Pillar); + } + } + v += 3; + } + let mut guards = 0; + for v in (2..f.len_v() - 1).step_by(4) { + for u in [mid - 2, mid + 2] { + let p = f.at(u, v); + if guards < 6 && self.tile(p).walkable() && self.site.person_at(p).is_none() + { + self.add(p, Role::Guard, true); + guards += 1; + } + } + } + self.crowd(room, Role::Courtier, 2..=4); + } + Council => { + let seats = self.long_table(room); + self.seat(&seats, Role::Courtier, 1..=4); + self.against(room, Shelf, 0, 2); + } + Treasury => { + self.against(room, Chest, 4, 9); + self.crowd(room, Role::Guard, 1..=1); + } + Nave => { + let altar = self.at_back(room, Altar); + let f = self.frame(room); + let mid = f.len_u() / 2; + for u in [mid - 2, mid + 2] { + self.put(f.at(u, 0), Lamp); + } + if let Some(at) = altar { + self.add(at, Role::Priest, true); + } + let pews = self.rows(room, Pew, 3, 2, true); + let n = self.rng.random_range(2..=(pews.len() / 3).max(3)); + self.seat(&pews, Role::Worshipper, n..=n); + self.crowd(room, Role::Pilgrim, 0..=2); + } + Vestry => { + self.against(room, Shelf, 1, 2); + self.against(room, Chest, 1, 1); + self.table(room, 1); + } + Cell => { + self.against(room, Bed, 1, 1); + self.against(room, Chest, 0, 1); + if self.rng.random_bool(0.5) { + self.crowd(room, Role::Priest, 1..=1); + } + } + Shrine => { + if let Some(at) = self.at_back(room, Altar) { + self.add(at, Role::Priest, true); + } + let f = self.frame(room); + let mid = f.len_u() / 2; + for u in [mid - 2, mid + 2] { + self.put(f.at(u, 0), Lamp); + } + let benches = self.rows(room, Bench, 3, 3, true); + self.seat(&benches, Role::Worshipper, 0..=2); + self.crowd(room, Role::Pilgrim, 0..=2); + } + Workroom => { + match culture { + Culture::Cant => { + self.against(room, Forge, 1, 1); + self.out_in(room, Anvil, 1, 2); + self.against(room, Rack, 0, 1); + } + Culture::Qaari => { + self.out_in(room, Loom, 1, 2); + self.against(room, Barrel, 1, 2); + } + Culture::Houses => { + self.out_in(room, Workbench, 1, 3); + self.against(room, Shelf, 1, 2); + } + Culture::Drift => { + self.out_in(room, Engine, 1, 1); + self.out_in(room, Workbench, 1, 2); + } + } + self.against(room, Workbench, 1, 2); + self.against(room, Crate, 0, 2); + self.crowd(room, Role::Worker, 1..=3); + } + Storehouse => { + let goods = if self.rng.random_bool(0.6) { Crate } else { Barrel }; + self.rows(room, goods, 1, 2, true); + self.crowd(room, Role::Watchman, 1..=1); + } + Office => { + let seats = self.table(room, 1); + self.against(room, Shelf, 1, 1); + self.seat(&seats, Role::Worker, 0..=1); + } + Changing => { + self.against(room, Bench, 2, 5); + self.against(room, Chest, 1, 2); + self.crowd(room, Role::Worker, 1..=1); + } + Baths => { + let r = self.rooms[room].rect; + let pool = Rect::new(r.left + 3, r.top + 2, r.right - 3, r.bottom - 2); + if pool.width() >= 2 && pool.height() >= 1 { + for p in pool.cells() { + self.put(p, Bath); + } + } + self.crowd(room, Role::Bather, 2..=5); + } + Steam => { + self.against(room, Stove, 1, 1); + let cells = self.by_wall(room); + let seats: Vec<Pos> = + cells.into_iter().filter(|&p| self.put(p, Bench)).take(5).collect(); + self.seat(&seats, Role::Bather, 1..=3); + } + Guardroom => { + let seats = self.table(room, 4); + self.seat(&seats, Role::Guard, 1..=3); + self.against(room, Rack, 1, 3); + self.crowd(room, Role::Guard, 0..=2); + } + Bunks => self.against(room, Bed, 3, 8), + } + } + + /// The people who live in a house or a great house. + fn residents(&mut self) { + let (role, n, servants) = match self.kind { + Kind::House => (Role::Resident, self.rng.random_range(1..=4), 0), + Kind::Manor => { + (Role::Resident, self.rng.random_range(1..=2), self.rng.random_range(1..=3)) + } + Kind::Palace => (Role::Servant, self.rng.random_range(1..=3), 0), + Kind::Workshop => (Role::Worker, self.rng.random_range(0..=1), 0), + _ => return, + }; + let rooms = self.rooms.len(); + for (role, n) in [(role, n), (Role::Servant, servants)] { + for _ in 0..n { + let room = self.rng.random_range(0..rooms); + self.crowd(room, role, 1..=1); + } + } + } +} diff --git a/src/town/mod.rs b/src/town/mod.rs new file mode 100644 index 0000000..fd17b20 --- /dev/null +++ b/src/town/mod.rs @@ -0,0 +1,850 @@ +//! Capital cities: where you walk when you land somewhere people live. +//! +//! A capital comes at two scales. Outside is the whole city, seen from +//! high up: a wall around it, a landing field beyond the west gate, avenues +//! to a market square, and streets and alleys between blocks of buildings, +//! each one a small roof with a door. Bump a door and you go in, and the +//! building becomes a map of its own at a much closer scale: rooms, +//! furniture, and the people who live or work there. +//! +//! - `plan` lays out the city: walls, gates, quarters, streets, landmarks +//! and the buildings, and which of them are shops. +//! - `interior` builds what's inside a building when you go in. +//! - `words` holds what everyone says, and what things are called. +//! +//! A city is generated from its place, so it's the same city every time you +//! land, and the more people live on a world the bigger its capital is. +//! Interiors come from the building, so a house has the same rooms each +//! visit too. +//! +//! The world it stands on shapes it as well (see `Land`): the ground, what +//! grows in its parks and what stands outside its walls, how many parks +//! there are, and, on an ocean world, the sea all round. The weather on the +//! day you land only changes how it looks. + +mod interior; +mod plan; +pub mod words; + +pub use interior::{Interior, Purpose, Thing}; + +use rand::seq::IndexedRandom; + +use crate::dungeon::Pos; +use crate::names::Culture; +use crate::seed; +use crate::space::survey::{self, FREEZING, Temperature}; +use crate::space::system::{PlaceKind, System, Terrain}; + +/// Columns and rows your ship takes up on the pad. +pub const SHIP_SIZE: (i32, i32) = (5, 3); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tile { + /// Outside the walls. + Ground, + /// The landing field. + Pad, + /// Your ship, standing on the pad. + Ship, + CityWall, + Gate, + Street, + /// Paving: the market square, plazas, the forecourts of great buildings. + Square, + /// Open ground inside the walls: yards, courtyards, parks. + Garden, + /// A pond. + Water, + /// The open sea round a city on an ocean world. + Sea, + /// Pipes, scrap: in the way, just for looks. Cultures that don't leave + /// things lying about plant trees instead. + Decor, + /// A tree, or whatever grows instead on this world. + Tree, + /// Something outside the walls: a boulder, a cactus, an ice spire, a + /// vent. Each world has two sorts (see `Town::wild_sort`). + Wild, + /// The well, fountain or furnace in the middle of the market square. + Centrepiece, + /// A market stall's awning. + Stall, + Statue, + Well, + Lamp, + /// A building, seen from the street. Which one is in `Town::building_at`. + Roof, + /// A building's street door. Bump it to go in, unless it's barred. + Door { + open: bool, + }, + /// A building's walls, from inside. + Wall, + Floor, + /// The way between two rooms. + Doorway, + /// The door back out to the street. + Exit, + /// A shop's counter, with the keeper behind it. + Counter, + /// The shop's wares, behind the counter. + Shelf, + Rug, + Furniture(Thing), +} + +impl Tile { + pub fn walkable(self) -> bool { + match self { + Tile::Ground + | Tile::Pad + | Tile::Gate + | Tile::Street + | Tile::Square + | Tile::Garden + | Tile::Floor + | Tile::Doorway + | Tile::Rug => true, + Tile::Furniture(thing) => thing.walkable(), + _ => false, + } + } + + /// Open air inside the walls, where townsfolk wander. + pub fn outdoors(self) -> bool { + matches!(self, Tile::Gate | Tile::Street | Tile::Square | Tile::Garden) + } + + /// A street or square: what a building's door has to open onto. + fn paved(self) -> bool { + matches!(self, Tile::Street | Tile::Square) + } +} + +/// A rectangle of cells, edges included. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Rect { + left: i32, + top: i32, + right: i32, + bottom: i32, +} + +impl Rect { + const fn new(left: i32, top: i32, right: i32, bottom: i32) -> Rect { + Rect { left, top, right, bottom } + } + + fn width(self) -> i32 { + self.right - self.left + 1 + } + + fn height(self) -> i32 { + self.bottom - self.top + 1 + } + + fn area(self) -> i32 { + self.width().max(0) * self.height().max(0) + } + + fn centre(self) -> Pos { + Pos::new((self.left + self.right) / 2, (self.top + self.bottom) / 2) + } + + fn contains(self, p: Pos) -> bool { + p.x >= self.left && p.x <= self.right && p.y >= self.top && p.y <= self.bottom + } + + fn cells(self) -> impl Iterator<Item = Pos> { + (self.top..=self.bottom) + .flat_map(move |y| (self.left..=self.right).map(move |x| Pos::new(x, y))) + } +} + +/// One of the four ways a door can face. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Side { + North, + South, + West, + East, +} + +impl Side { + pub fn step(self) -> (i32, i32) { + match self { + Side::North => (0, -1), + Side::South => (0, 1), + Side::West => (-1, 0), + Side::East => (1, 0), + } + } + + fn opposite(self) -> Side { + match self { + Side::North => Side::South, + Side::South => Side::North, + Side::West => Side::East, + Side::East => Side::West, + } + } +} + +/// A map you can walk around, and the people on it. The city's streets are +/// one; each building's inside is another. +pub struct Site { + pub width: i32, + pub height: i32, + tiles: Vec<Tile>, + pub people: Vec<Person>, + /// What's off the edge of the map. + edge: Tile, + /// Whether people here keep to the open air (the streets) or to the + /// floors (indoors). + streets: bool, +} + +impl Site { + fn new(width: i32, height: i32, fill: Tile, edge: Tile, streets: bool) -> Site { + Site { + width, + height, + tiles: vec![fill; (width * height) as usize], + people: Vec::new(), + edge, + streets, + } + } + + pub fn contains(&self, p: Pos) -> bool { + p.x >= 0 && p.y >= 0 && p.x < self.width && p.y < self.height + } + + fn index(&self, p: Pos) -> usize { + (p.y * self.width + p.x) as usize + } + + /// The tile at `p`; off the map is the edge, so nothing walks off it. + pub fn tile(&self, p: Pos) -> Tile { + if self.contains(p) { self.tiles[self.index(p)] } else { self.edge } + } + + fn set(&mut self, p: Pos, tile: Tile) { + let i = self.index(p); + self.tiles[i] = tile; + } + + fn fill(&mut self, r: Rect, tile: Tile) { + for p in r.cells() { + if self.contains(p) { + self.set(p, tile); + } + } + } + + pub fn positions(&self) -> impl Iterator<Item = Pos> + use<> { + Rect::new(0, 0, self.width - 1, self.height - 1).cells() + } + + pub fn person_at(&self, p: Pos) -> Option<usize> { + self.people.iter().position(|person| person.pos == p) + } + + /// Whether someone could step onto `p` right now. + pub fn free(&self, p: Pos) -> bool { + self.tile(p).walkable() && self.person_at(p).is_none() + } + + /// Where people here may wander. + fn roams(&self, tile: Tile) -> bool { + if self.streets { tile.outdoors() } else { tile.walkable() } + } + + /// Steps someone who's wandering aside onto `to`, where you were, as you + /// walk through them, if they'd go there. Otherwise they stay where + /// they are, and you share the cell until they wander off. + pub fn make_way(&mut self, who: usize, to: Pos) { + if !self.people[who].stays && self.roams(self.tile(to)) && self.free(to) { + self.people[who].pos = to; + } + } + + /// Moves someone a step at random, if they're the wandering kind, + /// staying off `avoid` (where you're standing). + pub fn wander(&mut self, who: usize, rng: &mut seed::Rng, avoid: Pos) { + if self.people[who].stays { + return; + } + let from = self.people[who].pos; + let steps: Vec<Pos> = from + .neighbours() + .filter(|&p| p != avoid && self.roams(self.tile(p)) && self.free(p)) + .collect(); + if let Some(&to) = steps.choose(rng) { + self.people[who].pos = to; + } + } + + /// Every cell you can walk to from `from`. + pub fn reachable(&self, from: Pos) -> Vec<bool> { + let mut seen = vec![false; self.tiles.len()]; + if !self.contains(from) { + return seen; + } + let mut queue = std::collections::VecDeque::from([from]); + seen[self.index(from)] = true; + while let Some(p) = queue.pop_front() { + for n in p.neighbours() { + if self.contains(n) && self.tile(n).walkable() && !seen[self.index(n)] { + seen[self.index(n)] = true; + queue.push_back(n); + } + } + } + seen + } +} + +/// What someone in a city is doing there, which decides what they say and +/// whether they move about. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + Passerby, + Child, + Porter, + Pilgrim, + Stallholder, + Guard, + /// The keeper of the shop with this index in `Town::shops`. + Keeper(usize), + Resident, + Servant, + Ruler, + Courtier, + Priest, + Worshipper, + Worker, + Patron, + Patient, + Bather, + Watchman, + Reader, +} + +/// Someone in the streets or indoors. +pub struct Person { + pub pos: Pos, + pub role: Role, + /// Residents, keepers and the like have names; passers-by don't. + pub name: Option<String>, + pub says: &'static str, + /// Stallholders, keepers, worshippers in their pews: they stay put. + pub stays: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShopKind { + Broker, + Doctor, + Scholar, + Tavern, + Shipyard, + /// Weapons, bought and sold. + Armoury, +} + +impl ShopKind { + pub const ALL: [ShopKind; 6] = [ + ShopKind::Broker, + ShopKind::Doctor, + ShopKind::Scholar, + ShopKind::Tavern, + ShopKind::Shipyard, + ShopKind::Armoury, + ]; + + /// What's painted on the roof. + pub fn sign(self) -> &'static str { + match self { + ShopKind::Broker => "ORE", + ShopKind::Doctor => "DOCTOR", + ShopKind::Scholar => "SCHOLAR", + ShopKind::Tavern => "TAVERN", + ShopKind::Shipyard => "SHIPYARD", + ShopKind::Armoury => "ARMS", + } + } + + /// "the doctor", for giving directions. + pub fn trade(self) -> &'static str { + match self { + ShopKind::Broker => "an ore broker", + ShopKind::Doctor => "a doctor", + ShopKind::Scholar => "a scholar", + ShopKind::Tavern => "a tavern", + ShopKind::Shipyard => "a shipyard", + ShopKind::Armoury => "an armourer", + } + } +} + +/// What a building is for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Shop(ShopKind), + House, + /// Lots of small rooms, lots of people. + Tenement, + /// A great house, in the grand quarter. + Manor, + /// Where the city's ruler sits. One to a city. + Palace, + /// The city's great temple. One to a city. + Temple, + Shrine, + Workshop, + Warehouse, + Bathhouse, + Guardhouse, +} + +/// One building, as it stands in the street. +pub struct Building { + pub kind: Kind, + /// Its footprint on the city map. + pub left: i32, + pub top: i32, + pub right: i32, + pub bottom: i32, + /// On the footprint's edge, facing the street. + pub door: Pos, + pub facing: Side, + pub barred: bool, + /// Index into `Town::shops`, if it's a shop. + pub shop: Option<usize>, + /// "the Sand Archive", "a house", "House Vastre". + pub name: String, +} + +impl Building { + /// The street cell in front of the door. + pub fn front(&self) -> Pos { + let (dx, dy) = self.facing.step(); + self.door.offset(dx, dy) + } + + pub fn centre(&self) -> Pos { + Pos::new((self.left + self.right) / 2, (self.top + self.bottom) / 2) + } + + pub fn width(&self) -> i32 { + self.right - self.left + 1 + } + + pub fn height(&self) -> i32 { + self.bottom - self.top + 1 + } + + #[cfg(test)] + fn rect(&self) -> Rect { + Rect::new(self.left, self.top, self.right, self.bottom) + } +} + +pub struct Shop { + pub kind: ShopKind, + /// "the Sand Archive". + pub name: String, + pub keeper: String, + pub building: usize, +} + +/// The kinds of neighbourhood a city is made of. They decide how big the +/// blocks and buildings are, and what the buildings are. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QuarterKind { + /// Around the square. + Market, + /// Inside the west gate, by the landing field. + Gate, + Homes, + Poor, + /// Where the palace and the great houses are. + Grand, + /// Around the temple. + Holy, + Works, + Gardens, +} + +pub struct Quarter { + pub kind: QuarterKind, + /// "the Spice Souk". + pub name: String, + /// Where it's centred, for the map. + pub centre: Pos, +} + +pub struct Town { + /// "Erg Khaseem". + pub name: String, + pub culture: Culture, + /// The world it's built on, and the weather today. + pub land: Land, + seed: u64, + /// The streets, and the people in them. + pub site: Site, + /// Which building each cell is part of, `NONE` for none. + owner: Vec<u16>, + /// Which quarter each cell inside the walls is in, `NONE` outside. + quarter_of: Vec<u8>, + pub quarters: Vec<Quarter>, + pub buildings: Vec<Building>, + pub shops: Vec<Shop>, + /// The top left of your ship, which is `SHIP_SIZE` big. + pub ship: Pos, + /// Where you step off your ship. + pub start: Pos, + /// The middle of the market square. + pub square: Pos, + /// The middle of each gate, the west gate first. + pub gates: Vec<Pos>, +} + +const NO_OWNER: u16 = u16::MAX; +const NO_QUARTER: u8 = u8::MAX; + +impl Town { + pub fn width(&self) -> i32 { + self.site.width + } + + pub fn height(&self) -> i32 { + self.site.height + } + + pub fn tile(&self, p: Pos) -> Tile { + self.site.tile(p) + } + + /// Which of its world's two sorts of `Tile::Wild` stands at `p`. + pub fn wild_sort(&self, p: Pos) -> usize { + let h = seed::derive(self.seed, "wild", (p.x as u64) << 32 | p.y as u64); + usize::from(h.is_multiple_of(3)) + } + + /// How much redder, greener and bluer this city's ground is than + /// others on the same kind of world, from 0.92 to 1.08 times, so no two + /// look quite alike. + pub fn tint(&self) -> [f64; 3] { + std::array::from_fn(|i| { + let h = seed::derive(self.seed, "tint", i as u64); + 0.92 + 0.16 * (h >> 11) as f64 / (1u64 << 53) as f64 + }) + } + + /// The building whose footprint `p` is in, door included. + pub fn building_at(&self, p: Pos) -> Option<usize> { + if !self.site.contains(p) { + return None; + } + let owner = self.owner[self.site.index(p)]; + (owner != NO_OWNER).then_some(usize::from(owner)) + } + + /// The building whose street door is at `p`. + pub fn door_at(&self, p: Pos) -> Option<usize> { + self.building_at(p).filter(|&b| self.buildings[b].door == p) + } + + pub fn quarter_at(&self, p: Pos) -> Option<&Quarter> { + if !self.site.contains(p) { + return None; + } + self.quarters.get(usize::from(self.quarter_of[self.site.index(p)])) + } + + /// Goes into a building: builds its inside, the same every time. + pub fn enter(&self, building: usize) -> Interior { + interior::build(self, building) + } + + /// The shop of this kind nearest to `from`. + pub fn nearest_shop(&self, kind: ShopKind, from: Pos) -> Option<&Shop> { + self.shops + .iter() + .filter(|s| s.kind == kind) + .min_by_key(|s| distance2(self.buildings[s.building].door, from)) + } +} + +/// Squared distance as it looks on screen, where rows are twice as tall as +/// columns are wide. +pub fn distance2(a: Pos, b: Pos) -> i32 { + (a.x - b.x).pow(2) + (2 * (a.y - b.y)).pow(2) +} + +/// The seed for the capital of `place` in a system. +pub fn seed_for(system_seed: u64, place: usize) -> u64 { + seed::derive(system_seed, "town", place as u64) +} + +/// What the capital `generate` would build is called, without building it. +pub fn name(seed: u64, culture: Culture) -> String { + culture.place(&mut seed::rng(seed)) +} + +/// How big a capital is, from 0 to 1, for a world of this many people. +pub fn size_for(population: Option<u64>) -> f64 { + let people = population.unwrap_or(10_000_000).max(1) as f64; + ((people.log10() - 5.3) / 4.3).clamp(0.0, 1.0) +} + +/// Builds the capital for `seed`, `size` big (see `size_for`), on `land`. +/// Only the land's terrain changes the plan: the weather doesn't move +/// anything. +pub fn generate(seed: u64, culture: Culture, size: f64, land: Land) -> Town { + let mut rng = seed::rng(seed); + // The name comes first, so `name` can find it alone. + let name = culture.place(&mut rng); + plan::build(name, culture, land, seed, size.clamp(0.0, 1.0), &mut rng) +} + +/// What the world a city stands on does to it: what the ground is, and +/// what the weather's doing to it today. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Land { + pub terrain: Terrain, + /// From 0 to 1: how hard it's frozen, on a world that isn't always. + pub frost: f64, + /// From 0 to 1: how parched the gardens are. + pub drought: f64, +} + +/// What's in a city's ponds today. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Pond { + Water, + Ice, + /// Dried to cracked mud. + Dry, + Lava, +} + +impl Land { + /// `terrain`, on a day `temperature` warm. Only worlds with air and + /// water see frost come and go and gardens wither: frost from 4°C + /// down, white by -10°C; drought from 30°C up, worst by 50°C. + pub fn new(terrain: Terrain, temperature: Option<Temperature>) -> Land { + let seasons = matches!(terrain, Terrain::Desert | Terrain::Ocean | Terrain::Jungle); + let c = temperature.map_or(15.0, |t| t.now - FREEZING); + let (frost, drought) = if seasons { + (((4.0 - c) / 14.0).clamp(0.0, 1.0), ((c - 30.0) / 20.0).clamp(0.0, 1.0)) + } else { + (0.0, 0.0) + }; + Land { terrain, frost, drought } + } + + /// The land under the capital of `place` on `day`, if it's a planet or + /// a moon. + pub fn of(s: &System, place: usize, day: f64) -> Option<Land> { + let kind = match s.places[place].kind { + PlaceKind::Planet(p) => s.planets[p].kind, + PlaceKind::Moon(p, m) => s.planets[p].moons[m].kind, + PlaceKind::Belt | PlaceKind::Station(_) => return None, + }; + Some(Land::new(kind.terrain, survey::survey(s, place, day).temperature)) + } + + pub fn pond(self) -> Pond { + match self.terrain { + Terrain::Forge => Pond::Lava, + Terrain::Ice => Pond::Ice, + Terrain::Barren | Terrain::Clouds => Pond::Dry, + _ if self.frost > 0.6 => Pond::Ice, + _ if self.drought > 0.8 => Pond::Dry, + _ => Pond::Water, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TERRAINS: [Terrain; 6] = [ + Terrain::Desert, + Terrain::Ice, + Terrain::Ocean, + Terrain::Jungle, + Terrain::Barren, + Terrain::Forge, + ]; + + fn land(terrain: Terrain) -> Land { + Land::new(terrain, None) + } + + /// A mild day on a jungle world. + fn green() -> Land { + land(Terrain::Jungle) + } + + fn towns() -> impl Iterator<Item = Town> { + (0..12).flat_map(|seed| { + Culture::ALL.into_iter().map(move |c| { + let terrain = TERRAINS[(seed as usize + c as usize) % TERRAINS.len()]; + generate(seed, c, (seed % 4) as f64 / 3.0, land(terrain)) + }) + }) + } + + #[test] + fn a_capitals_name_is_known_before_its_built() { + for seed in 0..40 { + for culture in Culture::ALL { + assert_eq!(name(seed, culture), generate(seed, culture, 0.5, green()).name); + } + } + } + + #[test] + fn the_same_seed_builds_the_same_city() { + let a = generate(7, Culture::Houses, 0.6, green()); + let b = generate(7, Culture::Houses, 0.6, green()); + assert_eq!(a.site.tiles, b.site.tiles); + assert_eq!(a.buildings.len(), b.buildings.len()); + } + + #[test] + fn the_weather_changes_how_a_city_looks_not_where_anything_is() { + let day = |now: f64| Temperature { now: FREEZING + now, coldest: 0.0, hottest: 0.0 }; + let cold = Land::new(Terrain::Ocean, Some(day(-12.0))); + let hot = Land::new(Terrain::Ocean, Some(day(48.0))); + assert_eq!(cold.pond(), Pond::Ice); + assert_eq!(hot.pond(), Pond::Dry); + assert_eq!(Land::new(Terrain::Ocean, Some(day(15.0))).pond(), Pond::Water); + let a = generate(4, Culture::Qaari, 0.5, cold); + let b = generate(4, Culture::Qaari, 0.5, hot); + assert_eq!(a.site.tiles, b.site.tiles); + // Ice and forge worlds have no seasons to speak of. + assert_eq!(Land::new(Terrain::Ice, Some(day(-180.0))).frost, 0.0); + assert_eq!(Land::new(Terrain::Forge, Some(day(300.0))).drought, 0.0); + } + + #[test] + fn the_world_shapes_the_city() { + let count = + |t: &Town, tile: Tile| t.site.positions().filter(|&p| t.tile(p) == tile).count(); + let mut trees = [0; 6]; + let mut wild = [0; 6]; + let mut ponds = [0; 6]; + for seed in 0..6 { + for (i, &terrain) in TERRAINS.iter().enumerate() { + let t = generate(seed, Culture::Houses, 0.5, land(terrain)); + trees[i] += count(&t, Tile::Tree); + wild[i] += count(&t, Tile::Wild); + ponds[i] += count(&t, Tile::Water); + let sea = count(&t, Tile::Sea); + if terrain == Terrain::Ocean { + assert!(sea > count(&t, Tile::Ground), "{}: mostly sea outside", t.name); + // The landing field is on dry land, and the road from it. + assert!(t.site.reachable(t.start)[t.site.index(t.gates[0])]); + } else { + assert_eq!(sea, 0); + } + } + } + let [desert, ice, _, jungle, barren, _] = [0, 1, 2, 3, 4, 5]; + assert!(trees[jungle] > 2 * trees[desert], "jungle {trees:?}"); + assert!(trees[ice] < trees[jungle]); + assert!(wild[jungle] > 3 * wild[desert], "jungle {wild:?}"); + assert_eq!(ponds[barren], 0, "nothing pools on airless rock"); + assert!(ponds[desert] < ponds[jungle], "{ponds:?}"); + } + + #[test] + fn no_two_cities_are_quite_the_same_colour() { + let a = generate(1, Culture::Drift, 0.3, green()).tint(); + let b = generate(2, Culture::Drift, 0.3, green()).tint(); + assert_ne!(a, b); + assert!(a.iter().chain(&b).all(|k| (0.92..=1.08).contains(k))); + } + + #[test] + fn bigger_worlds_have_bigger_capitals() { + let small = generate(3, Culture::Qaari, size_for(Some(300_000)), green()); + let big = generate(3, Culture::Qaari, size_for(Some(3_000_000_000)), green()); + let area = |t: &Town| t.width() * t.height(); + assert!(area(&big) * 2 > 5 * area(&small)); + assert!(big.buildings.len() > 2 * small.buildings.len()); + assert!(small.buildings.len() > 60, "even a small capital is a city"); + } + + #[test] + fn every_door_opens_onto_a_street_you_can_reach() { + for t in towns() { + let seen = t.site.reachable(t.start); + for (i, b) in t.buildings.iter().enumerate() { + assert_eq!(t.building_at(b.door), Some(i)); + assert!(t.tile(b.front()).paved(), "{}: door of {} opens onto nothing", t.name, i); + assert!(seen[t.site.index(b.front())], "{}: can't reach {}", t.name, b.name); + assert_eq!(t.tile(b.door), Tile::Door { open: !b.barred }); + for p in b.rect().cells() { + assert_eq!(t.building_at(p), Some(i), "buildings don't overlap"); + } + } + for p in &t.site.people { + assert!(t.tile(p.pos).walkable()); + assert!(seen[t.site.index(p.pos)], "{}: someone's stuck at {:?}", t.name, p.pos); + } + } + } + + #[test] + fn every_city_has_every_shop_and_a_palace_and_temple() { + for t in towns() { + for kind in ShopKind::ALL { + let n = t.shops.iter().filter(|s| s.kind == kind).count(); + if kind == ShopKind::Tavern { assert!(n >= 1) } else { assert_eq!(n, 1) } + } + for (i, shop) in t.shops.iter().enumerate() { + let b = &t.buildings[shop.building]; + assert_eq!(b.shop, Some(i)); + assert!(!b.barred); + assert!( + b.width() >= shop.kind.sign().len() as i32 + 2, + "{}: the sign fits", + t.name + ); + } + for kind in [Kind::Palace, Kind::Temple] { + let n = t.buildings.iter().filter(|b| b.kind == kind).count(); + assert_eq!(n, 1, "{}: {kind:?}", t.name); + } + let kinds: std::collections::HashSet<_> = + t.buildings.iter().map(|b| std::mem::discriminant(&b.kind)).collect(); + assert!(kinds.len() >= 8, "{}: a city has all sorts of buildings", t.name); + } + } + + #[test] + fn passers_by_stay_outdoors() { + let mut t = generate(5, Culture::Drift, 0.4, green()); + let mut rng = seed::rng(1); + for _ in 0..300 { + for who in 0..t.site.people.len() { + t.site.wander(who, &mut rng, t.start); + } + } + for p in &t.site.people { + assert!(t.tile(p.pos).outdoors() || p.stays, "{:?} wandered off the street", p.pos); + } + let mut places: Vec<Pos> = t.site.people.iter().map(|p| p.pos).collect(); + places.sort_by_key(|p| (p.x, p.y)); + places.dedup(); + assert_eq!(places.len(), t.site.people.len(), "no two people in one place"); + } +} diff --git a/src/town/plan.rs b/src/town/plan.rs new file mode 100644 index 0000000..90f5e32 --- /dev/null +++ b/src/town/plan.rs @@ -0,0 +1,1230 @@ +//! Laying out a capital. +//! +//! The wall is a rounded, slightly lumpy oval. A landing field lies outside +//! its west gate, and one to three more gates open on the other sides. A +//! road runs inside the wall all the way round, and avenues run from each +//! gate to the market square in the middle. +//! +//! The city is shared out into quarters around points scattered inside the +//! wall: a market around the square, a gate quarter by the landing field, +//! then a grand quarter, a holy one, homes, a poor quarter, works, gardens. +//! Then it's cut into blocks, from the outside in: each block is split in +//! two by a street until it's small enough for its quarter, so streets meet +//! at T-junctions and never quite line up, the way old cities grow. Poor +//! quarters end up with small blocks and alleys, grand ones with big blocks +//! and wide streets. +//! +//! A few blocks are kept whole: one for the palace, one for the temple, +//! parks and plazas. Every other block gets buildings packed along the +//! streets around it, doors facing out, with yards and courtyards left +//! behind them. Then each building is given a use to suit its quarter, and +//! the shops are chosen from buildings in the right places: the broker and +//! the shipyard by the gate you came in by, the tavern by the square. +//! +//! The world has a say too (`Lay`): how many parks there are, how big their +//! ponds, how thick the trees and what's scattered outside the walls. On +//! an ocean world the city stands on an island, its landing field out on a +//! spit of sand and its other roads running out to sea as causeways. + +use rand::RngExt; +use rand::seq::{IndexedRandom, SliceRandom}; + +use super::words; +use super::*; +use crate::seed::Rng; + +/// Columns west of the city wall, for the landing field and the road in. +const FIELD: i32 = 26; +/// The landing field's east edge. +const PAD_RIGHT: i32 = 19; +/// How far the wall's wobble can push it out, as a share of its radius. +const REACH: f64 = 1.08; + +/// How a world bends a city's plan. Each is times what it'd be otherwise. +struct Lay { + parks: f64, + trees: f64, + /// How big ponds are. None at all at 0. + ponds: f64, + /// The chance of something standing on any bit of ground outside the + /// walls (not times anything). + wild: f64, + /// Whether the sea comes up round the walls. + sea: bool, +} + +fn lay(terrain: Terrain) -> Lay { + let lay = |parks, trees, ponds, wild| Lay { parks, trees, ponds, wild, sea: false }; + match terrain { + // Few parks, small cisterns, little shade. + Terrain::Desert => lay(0.35, 0.5, 0.55, 0.04), + Terrain::Ice => lay(0.6, 0.6, 1.0, 0.06), + Terrain::Ocean => Lay { sea: true, ..lay(1.0, 1.0, 1.2, 0.05) }, + // Parks everywhere, trees crowding every yard. + Terrain::Jungle => lay(3.0, 3.0, 1.0, 0.3), + // Nothing pools on airless rock. + Terrain::Barren | Terrain::Clouds => lay(0.6, 0.8, 0.0, 0.05), + Terrain::Forge => lay(0.7, 0.7, 0.7, 0.06), + } +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +/// The line of the city wall: a superellipse, wobbled. +struct WallShape { + cx: f64, + cy: f64, + rx: f64, + ry: f64, + /// 2 is an ellipse; the higher, the squarer. + n: f64, + /// Sine waves on the radius: (how many round the wall, how big, where). + wobble: [(f64, f64, f64); 2], +} + +impl WallShape { + fn inside(&self, p: Pos) -> bool { + let dx = (f64::from(p.x) + 0.5 - self.cx) / self.rx; + let dy = (f64::from(p.y) + 0.5 - self.cy) / self.ry; + let d = (dx.abs().powf(self.n) + dy.abs().powf(self.n)).powf(1.0 / self.n); + let angle = dy.atan2(dx); + let r = + 1.0 + self.wobble.iter().map(|&(k, amp, at)| amp * (k * angle + at).sin()).sum::<f64>(); + d <= r + } +} + +/// How big blocks get in a quarter before a street splits them: columns +/// and rows. +fn block_size(q: QuarterKind) -> (i32, i32) { + match q { + QuarterKind::Poor => (14, 6), + QuarterKind::Homes => (20, 8), + QuarterKind::Market => (18, 8), + QuarterKind::Gate => (20, 8), + QuarterKind::Grand => (30, 12), + QuarterKind::Holy => (24, 10), + QuarterKind::Works => (26, 10), + QuarterKind::Gardens => (26, 11), + } +} + +/// How wide a street is, columns for one running north–south and rows for +/// one running east–west. A row is twice as tall as a column is wide, so +/// (2, 1) looks the same either way. +fn street_width(q: QuarterKind, depth: u32, rng: &mut Rng) -> (i32, i32) { + use QuarterKind::*; + match (q, depth) { + (Poor, 0) => (2, 1), + (Poor, _) => (1, 1), + (_, 0) | (Grand | Market | Gate, 1) => (3, 2), + (Homes, 3..) if rng.random_bool(0.3) => (1, 1), + _ => (2, 1), + } +} + +/// The sizes of building a quarter packs along its streets. +struct Lots { + /// For doors facing north or south: columns along the street, and rows + /// deep. + wide: (i32, i32), + deep: (i32, i32), + /// For doors facing east or west: rows along the street, and columns deep. + tall: (i32, i32), + long: (i32, i32), + /// The chance of a gap after each building. + gap: f64, +} + +fn lots(q: QuarterKind) -> Lots { + let lots = |wide, deep, tall, long, gap| Lots { wide, deep, tall, long, gap }; + match q { + QuarterKind::Poor => lots((4, 6), (2, 3), (3, 3), (4, 6), 0.12), + QuarterKind::Homes => lots((5, 8), (2, 4), (3, 4), (5, 8), 0.3), + QuarterKind::Market => lots((6, 11), (3, 4), (3, 4), (6, 10), 0.15), + QuarterKind::Gate => lots((6, 11), (3, 4), (3, 4), (6, 10), 0.25), + QuarterKind::Grand => lots((8, 13), (3, 5), (3, 5), (8, 12), 0.45), + QuarterKind::Holy => lots((5, 9), (3, 4), (3, 4), (5, 9), 0.35), + QuarterKind::Works => lots((7, 13), (3, 5), (3, 5), (7, 12), 0.2), + QuarterKind::Gardens => lots((5, 9), (2, 4), (3, 4), (5, 8), 0.6), + } +} + +/// What the buildings in a quarter tend to be, and how often. +fn uses(q: QuarterKind, culture: Culture) -> Vec<(Kind, f64)> { + use Kind::*; + let baths = if matches!(culture, Culture::Qaari | Culture::Houses) { 1.0 } else { 0.0 }; + match q { + QuarterKind::Market => vec![ + (House, 3.0), + (Workshop, 2.5), + (Warehouse, 1.0), + (Tenement, 1.0), + (Shrine, 0.3), + (Bathhouse, 0.6 * baths), + ], + QuarterKind::Gate => { + vec![(Warehouse, 3.0), (House, 2.0), (Workshop, 1.5), (Tenement, 1.0), (Shrine, 0.2)] + } + QuarterKind::Homes => vec![ + (House, 8.0), + (Tenement, 1.5), + (Shrine, 0.5), + (Workshop, 1.0), + (Bathhouse, 0.4 * baths), + ], + QuarterKind::Poor => vec![(Tenement, 5.0), (House, 3.0), (Workshop, 1.0), (Shrine, 0.6)], + QuarterKind::Grand => { + vec![(Manor, 6.0), (House, 1.5), (Shrine, 0.5), (Bathhouse, 0.6 * baths)] + } + QuarterKind::Holy => vec![(Shrine, 2.5), (House, 3.0), (Tenement, 1.0), (Workshop, 1.0)], + QuarterKind::Works => { + vec![(Workshop, 6.0), (Warehouse, 4.0), (Tenement, 2.0), (House, 1.0)] + } + QuarterKind::Gardens => { + vec![(House, 3.0), (Manor, 2.0), (Shrine, 1.0), (Bathhouse, 0.6 * baths)] + } + } +} + +/// Whether a building this many columns and rows is big enough to be one +/// of these. +fn fits(kind: Kind, w: i32, h: i32) -> bool { + let area = w * h; + match kind { + Kind::Manor => area >= 21, + Kind::Warehouse | Kind::Bathhouse => area >= 18, + Kind::Tenement => area >= 15, + _ => true, + } +} + +/// How likely each kind of building is to have its door barred to you. +fn barred_chance(kind: Kind) -> f64 { + match kind { + Kind::House => 0.35, + Kind::Manor => 0.5, + Kind::Warehouse => 0.4, + Kind::Tenement => 0.15, + Kind::Workshop => 0.05, + _ => 0.0, + } +} + +struct Planner<'a> { + t: Town, + rng: &'a mut Rng, + lay: Lay, + wall: WallShape, + size: f64, + main: i32, + square: Rect, + /// Quarter kinds and the points they're centred on. + points: Vec<(QuarterKind, Pos)>, + /// Blocks between streets, and whether each has been used for + /// something already. + blocks: Vec<(Rect, bool)>, + /// Parks, which get more trees. + parks: Vec<Rect>, + /// Market stalls, each with its keeper's place in front. + stalls: Vec<Pos>, + /// The grounds of the palace and the temple, which streets go round. + grounds: Vec<Rect>, +} + +pub(super) fn build( + name: String, + culture: Culture, + land: Land, + seed: u64, + size: f64, + rng: &mut Rng, +) -> Town { + let rx = lerp(60.0, 116.0, size) * rng.random_range(0.92..1.08); + let ry = rx * rng.random_range(0.40..0.50); + let width = FIELD + (2.0 * rx * REACH).ceil() as i32 + 6; + let height = (2.0 * ry * REACH).ceil() as i32 + 10; + let main = height / 2; + let mut wobble = || { + ( + f64::from(rng.random_range(3..=7)), + rng.random_range(0.0..0.035), + rng.random_range(0.0..6.3), + ) + }; + let wobble = [wobble(), wobble()]; + let wall = WallShape { + cx: f64::from(FIELD) + rx * REACH, + cy: f64::from(main) + 0.5, + rx, + ry, + n: rng.random_range(2.3..4.0), + wobble, + }; + let cells = (width * height) as usize; + let t = Town { + name, + culture, + land, + seed, + site: Site::new(width, height, Tile::Ground, Tile::CityWall, true), + owner: vec![NO_OWNER; cells], + quarter_of: vec![NO_QUARTER; cells], + quarters: Vec::new(), + buildings: Vec::new(), + shops: Vec::new(), + ship: Pos::new(7, main - 1), + start: Pos::new(13, main), + square: Pos::new(0, main), + gates: Vec::new(), + }; + let mut p = Planner { + t, + rng, + lay: lay(land.terrain), + wall, + size, + main, + square: Rect::new(0, 0, 0, 0), + points: Vec::new(), + blocks: Vec::new(), + parks: Vec::new(), + stalls: Vec::new(), + grounds: Vec::new(), + }; + p.walls(); + p.field(); + let sides = p.gates(); + p.market_square(); + p.avenues(&sides); + p.quarters(); + p.landmarks(); + let inside = p.inside_bounds(); + p.split(inside, 0); + for b in 0..p.t.buildings.len() { + let front = p.t.buildings[b].front(); + p.link(front); + } + p.parks_and_plazas(); + let blocks: Vec<Rect> = p.blocks.iter().filter(|(_, used)| !used).map(|&(r, _)| r).collect(); + for r in blocks { + p.pack(r); + } + p.uses(); + p.shops(); + p.guardhouses(); + if p.lay.sea { + p.sea(); + } + p.dress(); + p.people(); + p.t +} + +impl Planner<'_> { + fn tile(&self, p: Pos) -> Tile { + self.t.site.tile(p) + } + + fn set(&mut self, p: Pos, tile: Tile) { + if self.t.site.contains(p) { + self.t.site.set(p, tile); + } + } + + /// Paves over open ground: streets go round what's already built, and + /// round landmarks' grounds. + fn carve(&mut self, r: Rect) { + for p in r.cells() { + if self.tile(p) == Tile::Garden && !self.reserved(p) { + self.set(p, Tile::Street); + } + } + } + + fn walls(&mut self) { + let site = &self.t.site; + let all: Vec<Pos> = site.positions().collect(); + for &p in &all { + if self.wall.inside(p) { + let edge = p.neighbours().any(|n| !self.wall.inside(n)); + self.set(p, if edge { Tile::CityWall } else { Tile::Garden }); + } + } + // A road inside the wall, all the way round. + let ring: Vec<Pos> = all + .into_iter() + .filter(|&p| { + self.tile(p) == Tile::Garden + && p.neighbours().any(|n| self.tile(n) == Tile::CityWall) + }) + .collect(); + for p in ring { + self.set(p, Tile::Street); + } + } + + /// The landing field, with your ship on it. + fn field(&mut self) { + let main = self.main; + self.t.site.fill(Rect::new(3, main - 5, PAD_RIGHT, main + 5), Tile::Pad); + let (sw, sh) = SHIP_SIZE; + let ship = self.t.ship; + self.t.site.fill(Rect::new(ship.x, ship.y, ship.x + sw - 1, ship.y + sh - 1), Tile::Ship); + } + + /// Cuts a gate through the wall along each lane, paving the way to it + /// from outside, and returns the cell just inside the middle lane. + fn gate(&mut self, lanes: &[Pos], (dx, dy): (i32, i32)) -> Pos { + let mut inner = Vec::new(); + for &start in lanes { + let mut p = start; + while self.t.site.contains(p) && self.tile(p) != Tile::CityWall { + if self.tile(p) == Tile::Ground { + self.set(p, Tile::Street); + } + p = p.offset(dx, dy); + } + while self.tile(p) == Tile::CityWall && self.t.site.contains(p) { + self.set(p, Tile::Gate); + p = p.offset(dx, dy); + } + inner.push(p); + } + inner[lanes.len() / 2] + } + + /// The west gate, and one to three others. Returns which side each + /// gate is on, in the order of `Town::gates`. + fn gates(&mut self) -> Vec<Side> { + let main = self.main; + let west: Vec<Pos> = (main - 1..=main + 1).map(|y| Pos::new(PAD_RIGHT + 1, y)).collect(); + let inner = self.gate(&west, (1, 0)); + self.t.gates.push(inner); + let mut sides = vec![Side::North, Side::South, Side::East]; + sides.shuffle(self.rng); + let extra = 1 + self.rng.random_range(0..=(self.size * 2.0).round() as usize); + sides.truncate(extra); + let (w, h) = (self.t.width(), self.t.height()); + for &side in &sides { + let inner = match side { + Side::North | Side::South => { + let x = (self.wall.cx + self.wall.rx * self.rng.random_range(-0.45..0.45)) + .round() as i32; + let (y, dy) = if side == Side::North { (0, 1) } else { (h - 1, -1) }; + let lanes: Vec<Pos> = (x - 2..=x + 1).map(|x| Pos::new(x, y)).collect(); + self.gate(&lanes, (0, dy)) + } + _ => { + let y = (self.wall.cy + self.wall.ry * self.rng.random_range(-0.35..0.35)) + .round() as i32; + let lanes: Vec<Pos> = (y - 1..=y + 1).map(|y| Pos::new(w - 1, y)).collect(); + self.gate(&lanes, (-1, 0)) + } + }; + self.t.gates.push(inner); + } + let mut all = vec![Side::West]; + all.extend(sides); + all + } + + fn market_square(&mut self) { + let size = self.size; + let w = lerp(20.0, 36.0, size) as i32 + self.rng.random_range(0..5); + let h = lerp(7.0, 12.0, size) as i32 + self.rng.random_range(0..2); + let x = (self.wall.cx + self.wall.rx * self.rng.random_range(-0.12..0.12)).round() as i32; + let top = self.main - h / 2 + self.rng.random_range(-1..=1); + let r = Rect::new(x - w / 2, top, x - w / 2 + w - 1, top + h - 1); + for p in r.cells() { + if matches!(self.tile(p), Tile::Garden | Tile::Street) { + self.set(p, Tile::Square); + } + } + self.square = r; + self.t.square = Pos::new(x, self.main); + for dx in -1..=1 { + self.set(Pos::new(x + dx, self.main), Tile::Centrepiece); + } + } + + /// Paves an L from `a` to `b`, `cols` wide where it runs north–south + /// and `rows` tall where it runs east–west. + fn road(&mut self, a: Pos, b: Pos, (cols, rows): (i32, i32), down_first: bool) { + let vertical = |x: i32, y0: i32, y1: i32| { + Rect::new(x - cols / 2, y0.min(y1), x - cols / 2 + cols - 1, y0.max(y1)) + }; + let across = |y: i32, x0: i32, x1: i32| { + Rect::new(x0.min(x1), y - rows / 2, x0.max(x1), y - rows / 2 + rows - 1) + }; + if down_first { + self.carve(vertical(a.x, a.y, b.y)); + self.carve(across(b.y, a.x, b.x)); + } else { + self.carve(across(a.y, a.x, b.x)); + self.carve(vertical(b.x, a.y, b.y)); + } + } + + fn avenues(&mut self, sides: &[Side]) { + let sq = self.square; + for (i, &side) in sides.iter().enumerate() { + let g = self.t.gates[i]; + match side { + Side::West => self.road(g, Pos::new(sq.left + 1, self.main), (4, 3), false), + Side::North | Side::South => { + let y = if side == Side::North { sq.top + 1 } else { sq.bottom - 1 }; + let to = Pos::new(g.x.clamp(sq.left + 2, sq.right - 2), y); + self.road(g, to, (4, 3), true); + } + Side::East => { + let to = Pos::new(sq.right - 2, g.y.clamp(sq.top + 1, sq.bottom - 1)); + self.road(g, to, (4, 3), false); + } + } + } + } + + fn inside_bounds(&self) -> Rect { + let inside: Vec<Pos> = + self.t.site.positions().filter(|&p| self.tile(p) != Tile::Ground).collect(); + let xs = inside.iter().filter(|p| p.x > PAD_RIGHT + 1).map(|p| p.x); + let ys = inside.iter().map(|p| p.y); + Rect::new( + xs.clone().min().unwrap_or(0), + ys.clone().min().unwrap_or(0), + xs.max().unwrap_or(0), + ys.max().unwrap_or(0), + ) + } + + fn nearest_quarter(&self, p: Pos) -> usize { + (0..self.points.len()).min_by_key(|&i| distance2(self.points[i].1, p)).unwrap_or(0) + } + + fn quarter_kind(&self, p: Pos) -> QuarterKind { + self.points[self.nearest_quarter(p)].0 + } + + fn quarters(&mut self) { + use QuarterKind::*; + let count = 4 + (self.size * 4.0).round() as usize; + let gate = self.t.gates[0]; + self.points.push((Market, self.t.square)); + self.points.push((Gate, gate.offset((self.wall.rx * 0.3) as i32, 0))); + let mut rest = vec![Homes, Works, Poor, Gardens]; + rest.shuffle(self.rng); + let kinds = [Grand, Holy].into_iter().chain(rest).chain([Homes, Poor, Homes, Works]); + let inside: Vec<Pos> = + self.t.site.positions().filter(|&p| self.tile(p) == Tile::Garden).collect(); + for kind in kinds.take(count - 2) { + // Of a dozen places, the one furthest from the others. + let tries: Vec<Pos> = + (0..12).filter_map(|_| inside.choose(self.rng).copied()).collect(); + let far = tries.into_iter().max_by_key(|&p| { + self.points.iter().map(|&(_, q)| distance2(p, q)).min().unwrap_or(0) + }); + if let Some(p) = far { + self.points.push((kind, p)); + } + } + let mut sums = vec![(0i64, 0i64, 0i64); self.points.len()]; + for p in self.t.site.positions().collect::<Vec<_>>() { + if self.tile(p) != Tile::Ground && p.x > PAD_RIGHT { + let q = self.nearest_quarter(p); + let i = self.t.site.index(p); + self.t.quarter_of[i] = q as u8; + sums[q] = (sums[q].0 + i64::from(p.x), sums[q].1 + i64::from(p.y), sums[q].2 + 1); + } + } + let culture = self.t.culture; + for (i, &(kind, at)) in self.points.iter().enumerate() { + let (sx, sy, n) = sums[i]; + let centre = if n > 0 { Pos::new((sx / n) as i32, (sy / n) as i32) } else { at }; + let mut name = words::quarter_name(culture, kind, self.rng); + for _ in 0..10 { + if !self.t.quarters.iter().any(|q| q.name == name) { + break; + } + name = words::quarter_name(culture, kind, self.rng); + } + self.t.quarters.push(Quarter { kind, name, centre }); + } + } + + /// Splits `r` with a street, and its halves, and theirs, until each is + /// small enough to be a block in its quarter. + fn split(&mut self, r: Rect, depth: u32) { + let kind = self.quarter_kind(r.centre()); + let (max_w, max_h) = block_size(kind); + let (w, h) = (r.width(), r.height()); + let (wide, tall) = (w > max_w, h > max_h); + let nearly = w <= max_w * 3 / 2 && h <= max_h * 3 / 2; + if !wide && !tall || w < 8 || h < 5 || depth > 2 && nearly && self.rng.random_bool(0.15) { + self.blocks.push((r, false)); + return; + } + let upright = if wide && tall { + f64::from(w) > 2.0 * f64::from(h) * self.rng.random_range(0.8..1.25) + } else { + wide + }; + let (cols, rows) = street_width(kind, depth, self.rng); + if upright { + let lo = r.left + w * 3 / 10; + let hi = (r.right - w * 3 / 10 - cols + 1).max(lo); + let x = self.rng.random_range(lo..=hi); + self.carve(Rect::new(x, r.top, x + cols - 1, r.bottom)); + self.split(Rect::new(r.left, r.top, x - 1, r.bottom), depth + 1); + self.split(Rect::new(x + cols, r.top, r.right, r.bottom), depth + 1); + } else { + let lo = r.top + h * 3 / 10; + let hi = (r.bottom - h * 3 / 10 - rows + 1).max(lo); + let y = self.rng.random_range(lo..=hi); + self.carve(Rect::new(r.left, y, r.right, y + rows - 1)); + self.split(Rect::new(r.left, r.top, r.right, y - 1), depth + 1); + self.split(Rect::new(r.left, y + rows, r.right, r.bottom), depth + 1); + } + } + + /// Whether every cell of `r` is open ground: nothing built, no street + /// through it, all inside the wall, and not set aside for a landmark. + fn whole(&self, r: Rect) -> bool { + r.cells().all(|p| self.tile(p) == Tile::Garden && !self.reserved(p)) + } + + /// Whether `p` is in the grounds of a landmark. + fn reserved(&self, p: Pos) -> bool { + self.grounds.iter().any(|r| r.contains(p)) + } + + fn add_building(&mut self, kind: Kind, foot: Rect, door: Pos, facing: Side) -> usize { + let b = self.t.buildings.len(); + for p in foot.cells() { + self.set(p, Tile::Roof); + let i = self.t.site.index(p); + self.t.owner[i] = b as u16; + } + self.set(door, Tile::Door { open: true }); + let name = String::new(); + self.t.buildings.push(Building { + kind, + left: foot.left, + top: foot.top, + right: foot.right, + bottom: foot.bottom, + door, + facing, + barred: false, + shop: None, + name, + }); + b + } + + /// Paves from `from` in direction `step` until it meets a street. + fn pave_to_street(&mut self, from: Pos, (dx, dy): (i32, i32)) { + let mut p = from; + for _ in 0..12 { + if self.tile(p) != Tile::Garden { + return; + } + self.set(p, Tile::Square); + p = p.offset(dx, dy); + } + } + + /// Sets aside grounds for a great building before the streets are cut, + /// as near the middle of its quarter as they'll fit, with a street all + /// round them and a paved way from the door to it. Shrinks the building + /// if it won't fit anywhere. + fn landmark(&mut self, kind: Kind, home: QuarterKind, (mut w, mut h): (i32, i32)) { + let target = + self.points.iter().find(|(k, _)| *k == home).map_or(self.t.square, |&(_, p)| p); + let mut open: Vec<Pos> = + self.t.site.positions().filter(|&p| self.tile(p) == Tile::Garden).collect(); + open.sort_by_key(|&p| distance2(p, target)); + let around = |r: Rect| Rect::new(r.left - 2, r.top - 1, r.right + 2, r.bottom + 1); + loop { + let (gw, gh) = (w + 6, h + 2); + let at = |c: Pos| { + let (left, top) = (c.x - gw / 2, c.y - gh / 2); + Rect::new(left, top, left + gw - 1, top + gh - 1) + }; + let found = open.iter().map(|&c| at(c)).find(|&grounds| { + self.whole(grounds) + && around(grounds).cells().all(|p| { + grounds.contains(p) + || !self.reserved(p) + && matches!( + self.tile(p), + Tile::Garden | Tile::Street | Tile::Square + ) + }) + }); + if let Some(grounds) = found { + self.grounds.push(grounds); + self.carve(around(grounds)); + let (left, top) = (grounds.left + 3, grounds.top + 1); + let foot = Rect::new(left, top, left + w - 1, top + h - 1); + // The door faces the square, or at least its side of the city. + let facing = if grounds.centre().y < self.main { Side::South } else { Side::North }; + let x = foot.centre().x; + let door = Pos::new(x, if facing == Side::South { foot.bottom } else { foot.top }); + let step = facing.step(); + for dx in -1..=1 { + self.pave_to_street(door.offset(dx + step.0, step.1), step); + } + let front = door.offset(0, step.1); + for dx in [-3, 3] { + if self.tile(front.offset(dx, 0)) == Tile::Garden { + self.set(front.offset(dx, 0), Tile::Statue); + } + } + self.add_building(kind, foot, door, facing); + return; + } + if w <= 8 { + return; + } + w -= 2; + h = (h - 1).max(3); + } + } + + fn landmarks(&mut self) { + let size = self.size; + let palace = (lerp(14.0, 22.0, size) as i32, lerp(5.0, 8.0, size) as i32); + let temple = (lerp(10.0, 16.0, size) as i32, lerp(4.0, 6.0, size) as i32); + self.landmark(Kind::Palace, QuarterKind::Grand, palace); + self.landmark(Kind::Temple, QuarterKind::Holy, temple); + } + + /// The paved cells you can walk to from `from` without leaving the + /// streets. + fn paved_from(&self, from: Pos) -> Vec<bool> { + let site = &self.t.site; + let paved = + |p: Pos| site.contains(p) && self.tile(p).outdoors() && self.tile(p) != Tile::Garden; + let mut seen = vec![false; (site.width * site.height) as usize]; + if !paved(from) { + return seen; + } + let mut queue = std::collections::VecDeque::from([from]); + seen[site.index(from)] = true; + while let Some(p) = queue.pop_front() { + for n in p.neighbours() { + if paved(n) && !seen[site.index(n)] { + seen[site.index(n)] = true; + queue.push_back(n); + } + } + } + seen + } + + /// Makes sure you can walk by street from the west gate to `to`, paving + /// a road between the nearest two streets if not. + fn link(&mut self, to: Pos) { + let reached = self.paved_from(self.t.gates[0]); + let site = &self.t.site; + if reached[site.index(to)] { + return; + } + let cut_off = self.paved_from(to); + let ours: Vec<Pos> = site.positions().filter(|&p| reached[site.index(p)]).collect(); + let theirs = site.positions().filter(|&p| cut_off[site.index(p)]); + let pair = theirs + .flat_map(|a| ours.iter().map(move |&b| (a, b))) + .min_by_key(|&(a, b)| distance2(a, b)); + if let Some((a, b)) = pair { + let down_first = self.rng.random_bool(0.5); + self.road(a, b, (2, 1), down_first); + } + } + + fn parks_and_plazas(&mut self) { + for i in 0..self.blocks.len() { + let (r, used) = self.blocks[i]; + if used || r.width() < 6 || r.height() < 3 || r.cells().any(|p| self.reserved(p)) { + continue; + } + let q = self.quarter_kind(r.centre()); + let park = match q { + QuarterKind::Gardens => 0.55, + QuarterKind::Market | QuarterKind::Gate => 0.0, + _ => 0.03, + }; + let plaza = match q { + QuarterKind::Gardens | QuarterKind::Grand => 0.0, + _ if r.area() > 200 => 0.0, + _ => 0.03, + }; + if self.rng.random_bool((park * self.lay.parks).min(0.95)) { + self.blocks[i].1 = true; + self.park(r); + } else if self.rng.random_bool(plaza) { + self.blocks[i].1 = true; + self.plaza(r); + } + } + } + + /// Puts something in the middle of a park or plaza, if the middle is + /// open ground inside the walls. + fn centrepiece(&mut self, at: Pos, tile: Tile) { + if matches!(self.tile(at), Tile::Garden | Tile::Street | Tile::Square) { + self.set(at, tile); + } + } + + /// Paths crossing, a pond if there's room, and trees later. + fn park(&mut self, r: Rect) { + self.parks.push(r); + let c = r.centre(); + let path = |p: &mut Planner, cell: Pos| { + if p.tile(cell) == Tile::Garden { + p.set(cell, Tile::Street); + } + }; + for x in r.left..=r.right { + path(self, Pos::new(x, c.y)); + } + for y in r.top..=r.bottom { + path(self, Pos::new(c.x, y)); + path(self, Pos::new(c.x + 1, y)); + } + let size = self.lay.ponds; + if size > 0.0 && r.width() >= 14 && r.height() >= 6 { + // In one corner, between the paths. + let qx = + if self.rng.random_bool(0.5) { (r.left + c.x) / 2 } else { (c.x + r.right) / 2 }; + let qy = + if self.rng.random_bool(0.5) { (r.top + c.y) / 2 } else { (c.y + r.bottom) / 2 }; + let (ax, ay) = (f64::from(r.width()) / 7.0 * size, f64::from(r.height()) / 5.0 * size); + for p in r.cells() { + let dx = f64::from(p.x - qx) / ax.max(1.0); + let dy = f64::from(p.y - qy) / ay.max(0.8); + if dx * dx + dy * dy <= 1.0 && self.tile(p) == Tile::Garden { + self.set(p, Tile::Water); + } + } + } + let centre = if self.rng.random_bool(0.5) { Tile::Statue } else { Tile::Well }; + self.centrepiece(c, centre); + } + + /// A little square with a well or a statue in the middle. + fn plaza(&mut self, r: Rect) { + for p in r.cells() { + if self.tile(p) == Tile::Garden { + self.set(p, Tile::Square); + } + } + let wells = matches!(self.t.culture, Culture::Qaari | Culture::Drift); + let centre = if wells != self.rng.random_bool(0.25) { Tile::Well } else { Tile::Statue }; + self.centrepiece(r.centre(), centre); + } + + /// Packs buildings along the streets round a block, the long sides + /// first, then the ends. + fn pack(&mut self, r: Rect) { + let lots = lots(self.quarter_kind(r.centre())); + let mut long = [Side::North, Side::South]; + let mut ends = [Side::West, Side::East]; + long.shuffle(self.rng); + ends.shuffle(self.rng); + for side in long.into_iter().chain(ends) { + self.pack_side(r, side, &lots); + } + } + + fn pack_side(&mut self, r: Rect, side: Side, lots: &Lots) { + let upright = matches!(side, Side::North | Side::South); + let (len, cap) = if upright { + (r.width(), (r.height() + 1) / 2) + } else { + (r.height(), (r.width() + 1) / 2) + }; + let (along, deep) = if upright { (lots.wide, lots.deep) } else { (lots.tall, lots.long) }; + let min_deep = if upright { 2 } else { 3 }; + let mut u = 0; + while u < len { + let want = self.rng.random_range(along.0..=along.1); + let depth = self.rng.random_range(deep.0..=deep.1).min(cap); + if depth < min_deep { + return; + } + let mut placed = None; + for n in (along.0.min(want)..=want).rev() { + let foot = match side { + Side::North => { + Rect::new(r.left + u, r.top, r.left + u + n - 1, r.top + depth - 1) + } + Side::South => { + Rect::new(r.left + u, r.bottom - depth + 1, r.left + u + n - 1, r.bottom) + } + Side::West => { + Rect::new(r.left, r.top + u, r.left + depth - 1, r.top + u + n - 1) + } + Side::East => { + Rect::new(r.right - depth + 1, r.top + u, r.right, r.top + u + n - 1) + } + }; + let inside = foot.right <= r.right && foot.bottom <= r.bottom; + if inside + && self.whole(foot) + && let Some(door) = self.door_for(foot, side) + { + self.add_building(Kind::House, foot, door, side); + placed = Some(n); + break; + } + } + match placed { + Some(n) => { + let gap = if self.rng.random_bool(lots.gap) { + if self.rng.random_bool(0.25) { 2 } else { 1 } + } else { + 0 + }; + u += n + gap; + } + None => u += 1, + } + } + } + + /// A door on the `side` edge of a footprint, away from the corners, + /// that opens onto a street. + fn door_for(&mut self, foot: Rect, side: Side) -> Option<Pos> { + let (dx, dy) = side.step(); + let doors: Vec<Pos> = match side { + Side::North | Side::South => { + let y = if side == Side::North { foot.top } else { foot.bottom }; + (foot.left + 1..foot.right).map(|x| Pos::new(x, y)).collect() + } + Side::West | Side::East => { + let x = if side == Side::West { foot.left } else { foot.right }; + (foot.top + 1..foot.bottom).map(|y| Pos::new(x, y)).collect() + } + }; + let good: Vec<Pos> = + doors.into_iter().filter(|d| self.tile(d.offset(dx, dy)).paved()).collect(); + good.choose(self.rng).copied() + } + + /// Decides what each ordinary building is, by its quarter and size, and + /// names it. + fn uses(&mut self) { + let culture = self.t.culture; + for b in 0..self.t.buildings.len() { + let building = &self.t.buildings[b]; + let (w, h) = (building.width(), building.height()); + let mut kind = building.kind; + if kind == Kind::House { + let q = self.quarter_kind(building.centre()); + let options = uses(q, culture); + kind = options + .choose_weighted(self.rng, |(_, weight)| *weight) + .map_or(Kind::House, |(k, _)| *k); + if !fits(kind, w, h) { + kind = Kind::House; + } + } + let barred = self.rng.random_bool(barred_chance(kind)); + let name = words::building_name(kind, culture, self.rng); + let building = &mut self.t.buildings[b]; + building.kind = kind; + building.barred = barred; + building.name = name; + let door = building.door; + self.set(door, Tile::Door { open: !barred }); + } + } + + fn make(&mut self, b: usize, kind: Kind) { + let culture = self.t.culture; + let name = words::building_name(kind, culture, self.rng); + let building = &mut self.t.buildings[b]; + building.kind = kind; + building.barred = false; + building.name = name; + let door = building.door; + self.set(door, Tile::Door { open: true }); + } + + /// Picks one of the ordinary buildings nearest to `near`, and wide + /// enough for a sign `sign` long. + /// An ordinary building near `near` with room for the sign over its + /// door, and not just down the street from another shop of the same + /// kind. + fn site_near(&mut self, near: Pos, kind: ShopKind) -> Option<usize> { + let need = kind.sign().len() as i32 + 2; + let rivals: Vec<Pos> = self + .t + .shops + .iter() + .filter(|s| s.kind == kind) + .map(|s| self.t.buildings[s.building].door) + .collect(); + let mut options: Vec<usize> = (0..self.t.buildings.len()) + .filter(|&b| { + let building = &self.t.buildings[b]; + let ordinary = matches!( + building.kind, + Kind::House | Kind::Workshop | Kind::Warehouse | Kind::Tenement | Kind::Shrine + ); + ordinary + && building.width() >= need + && building.height() >= 3 + && rivals.iter().all(|&r| distance2(r, building.door) > 24 * 24) + }) + .collect(); + options.sort_by_key(|&b| distance2(self.t.buildings[b].door, near)); + options.truncate(4); + options.choose(self.rng).copied() + } + + fn shops(&mut self) { + let culture = self.t.culture; + let anchor = |p: &Planner, kinds: &[QuarterKind]| { + let quarters: Vec<&Quarter> = + p.t.quarters.iter().filter(|q| kinds.contains(&q.kind)).collect(); + quarters.first().map(|q| q.centre) + }; + let gate = self.t.gates[0].offset(8, 0); + let square = self.t.square; + let temple = self.t.buildings.iter().find(|b| b.kind == Kind::Temple).map(|b| b.front()); + let scholar = anchor(self, &[QuarterKind::Holy]).or(temple).unwrap_or(square); + let homes = anchor(self, &[QuarterKind::Homes, QuarterKind::Market]).unwrap_or(square); + let works = anchor(self, &[QuarterKind::Works, QuarterKind::Poor]).unwrap_or(gate); + let mut wanted = vec![ + (ShopKind::Broker, gate), + (ShopKind::Shipyard, gate), + (ShopKind::Tavern, square), + (ShopKind::Doctor, homes), + (ShopKind::Scholar, scholar), + (ShopKind::Armoury, works), + ]; + let mut quiet: Vec<Pos> = self + .t + .quarters + .iter() + .filter(|q| { + matches!(q.kind, QuarterKind::Poor | QuarterKind::Homes | QuarterKind::Works) + }) + .map(|q| q.centre) + .collect(); + // More taverns in bigger cities, each in a different quarter while + // there are quarters to go round. + quiet.shuffle(self.rng); + let extra = (self.size * 3.0).round() as usize; + wanted.extend(quiet.iter().cycle().take(extra).map(|&at| (ShopKind::Tavern, at))); + for (kind, near) in wanted { + let Some(b) = self.site_near(near, kind) else { continue }; + let mut name = words::shop_name(kind, culture, self.rng); + for _ in 0..10 { + if !self.t.shops.iter().any(|s| s.name == name) { + break; + } + name = words::shop_name(kind, culture, self.rng); + } + let shop = self.t.shops.len(); + self.t.shops.push(Shop { + kind, + name: name.clone(), + keeper: culture.person(self.rng), + building: b, + }); + self.make(b, Kind::Shop(kind)); + self.t.buildings[b].shop = Some(shop); + self.t.buildings[b].name = name; + } + } + + /// Each gate has a guardhouse just inside it. + fn guardhouses(&mut self) { + for g in self.t.gates.clone() { + let near = (0..self.t.buildings.len()) + .filter(|&b| { + let kind = self.t.buildings[b].kind; + !matches!(kind, Kind::Shop(_) | Kind::Palace | Kind::Temple | Kind::Guardhouse) + }) + .min_by_key(|&b| distance2(self.t.buildings[b].door, g)); + if let Some(b) = near.filter(|&b| distance2(self.t.buildings[b].door, g) < 40 * 40) { + self.make(b, Kind::Guardhouse); + } + } + } + + /// Floods the ground outside the walls, but for a shore round them and + /// the landing field, and the road between. The roads to the other + /// gates run on out to sea. + fn sea(&mut self) { + let site = &self.t.site; + let (main, cx) = (self.main, self.wall.cx); + let shore = |p: Pos| { + let t = site.tile(p); + let west_road = t == Tile::Street && (p.y - main).abs() <= 1 && f64::from(p.x) < cx; + !matches!(t, Tile::Ground | Tile::Street) || west_road + }; + // How far each bit of ground is from the shore, a step at a time. + let mut far = vec![i32::MAX; site.tiles.len()]; + let mut queue = std::collections::VecDeque::new(); + for p in site.positions() { + if shore(p) { + far[site.index(p)] = 0; + queue.push_back(p); + } + } + while let Some(p) = queue.pop_front() { + let d = far[site.index(p)]; + for n in p.neighbours() { + if site.contains(n) && site.tile(n) == Tile::Ground && far[site.index(n)] > d + 1 { + far[site.index(n)] = d + 1; + queue.push_back(n); + } + } + } + // A beach two or three wide, wider and narrower along the coast. + let (a, b) = (self.rng.random_range(0.0..6.3), self.rng.random_range(0.0..6.3)); + let flood: Vec<Pos> = site + .positions() + .filter(|&p| { + let (x, y) = (f64::from(p.x), f64::from(p.y)); + let beach = 2.2 + 1.3 * (x * 0.23 + a).sin() * (y * 0.41 + b).sin(); + site.tile(p) == Tile::Ground && f64::from(far[site.index(p)]) > beach + }) + .collect(); + for p in flood { + self.set(p, Tile::Sea); + } + } + + /// Trees, stalls and lamps. + fn dress(&mut self) { + let all: Vec<Pos> = self.t.site.positions().collect(); + let main = self.main; + // The Cant and the Drift leave pipes and scrap about; everyone plants. + let clutter = matches!(self.t.culture, Culture::Cant | Culture::Drift); + for &p in &all { + let tile = self.tile(p); + let roadside = + p.neighbours().any(|n| self.tile(n).paved() || self.tile(n) == Tile::Pad); + if tile == Tile::Ground && !roadside && (p.y - main).abs() > 1 { + if self.rng.random_bool(self.lay.wild) { + self.set(p, Tile::Wild); + } + } else if tile == Tile::Garden && !roadside { + let park = self.parks.iter().any(|r| r.contains(p)); + let grounds = p.neighbours().any(|n| { + self.t.building_at(n).is_some_and(|b| { + matches!(self.t.buildings[b].kind, Kind::Palace | Kind::Temple) + }) + }); + let chance = if park { + 0.16 + } else if grounds { + 0.3 + } else { + 0.07 + }; + if self.rng.random_bool((chance * self.lay.trees).min(0.6)) { + let planted = !clutter || park || grounds || self.rng.random_bool(0.4); + self.set(p, if planted { Tile::Tree } else { Tile::Decor }); + } + } + } + // Stalls along the top and bottom of the square, facing in, keeping + // clear of the streets that come into it. + let sq = self.square; + for (y, front) in [(sq.top + 1, 1), (sq.bottom - 1, -1)] { + let edge = if front == 1 { sq.top - 1 } else { sq.bottom + 1 }; + let mut x = sq.left + 2; + while x + 2 <= sq.right - 2 { + let clear = (x - 1..=x + 3).all(|x| !self.tile(Pos::new(x, edge)).paved()) + && (x..=x + 2).all(|x| self.tile(Pos::new(x, y)) == Tile::Square) + && (y - main).abs() > 1; + if clear && self.rng.random_bool(0.7) { + for dx in 0..3 { + self.set(Pos::new(x + dx, y), Tile::Stall); + } + self.stalls.push(Pos::new(x + 1, y + front)); + x += 6; + } else { + x += 2; + } + } + } + for p in [ + Pos::new(sq.left, sq.top), + Pos::new(sq.right, sq.top), + Pos::new(sq.left, sq.bottom), + Pos::new(sq.right, sq.bottom), + ] { + let doorstep = p.neighbours().any(|n| matches!(self.tile(n), Tile::Door { .. })); + if self.tile(p) == Tile::Square && !doorstep { + self.set(p, Tile::Lamp); + } + } + } + + fn person(&mut self, pos: Pos, role: Role, stays: bool) { + let culture = self.t.culture; + let says = *words::lines(role, culture).choose(self.rng).unwrap_or(&"..."); + self.t.site.people.push(Person { pos, role, name: None, says, stays }); + } + + fn people(&mut self) { + for at in self.stalls.clone() { + if self.t.site.free(at) { + self.person(at, Role::Stallholder, true); + } + } + // Guards just inside each gate, against the wall. + for g in self.t.gates.clone() { + let mut spots: Vec<Pos> = Rect::new(g.x - 3, g.y - 3, g.x + 3, g.y + 3) + .cells() + .filter(|&p| { + self.tile(p).outdoors() + && self.tile(p) != Tile::Gate + && self.t.site.free(p) + && p.neighbours().any(|n| self.tile(n) == Tile::CityWall) + && p.neighbours().all(|n| self.tile(n) != Tile::Gate) + }) + .collect(); + spots.shuffle(self.rng); + for p in spots.into_iter().take(2) { + self.person(p, Role::Guard, true); + } + } + let streets: Vec<Pos> = self.t.site.positions().filter(|&p| self.tile(p).paved()).collect(); + let count = (streets.len() / 55).clamp(16, 180); + let crowd = (self.square.area() / 30) as usize; + let square: Vec<Pos> = + streets.iter().copied().filter(|&p| self.tile(p) == Tile::Square).collect(); + let mut placed = 0; + let mut tries = 0; + while placed < count + crowd && tries < 20 * (count + crowd) { + tries += 1; + let pool = if placed < crowd { &square } else { &streets }; + let Some(&p) = pool.choose(self.rng) else { break }; + if !self.t.site.free(p) { + continue; + } + let q = self.quarter_kind(p); + let roll = self.rng.random_range(0.0..1.0); + let role = match q { + QuarterKind::Homes | QuarterKind::Poor if roll < 0.2 => Role::Child, + QuarterKind::Works | QuarterKind::Gate if roll < 0.25 => Role::Porter, + QuarterKind::Holy if roll < 0.3 => Role::Pilgrim, + _ => Role::Passerby, + }; + self.person(p, role, false); + placed += 1; + } + } +} diff --git a/src/town/words.rs b/src/town/words.rs new file mode 100644 index 0000000..251f2b2 --- /dev/null +++ b/src/town/words.rs @@ -0,0 +1,633 @@ +//! What things in a city are called, and what people there say. +//! +//! Names use the same templates as `names`: `{w}` is a new word in the +//! culture's tongue, `{n}` a number, `{r}` a Roman numeral. + +use super::{Kind, Land, Pond, Purpose, QuarterKind, Role, ShopKind, Thing, Tile}; +use crate::names::{Culture, pick}; +use crate::seed::Rng; +use crate::space::system::Terrain; + +fn fill(culture: Culture, templates: &'static [&'static str], rng: &mut Rng) -> String { + culture.expand(pick(templates, rng), rng) +} + +pub fn quarter_name(culture: Culture, kind: QuarterKind, rng: &mut Rng) -> String { + use QuarterKind::*; + let templates: &'static [&'static str] = match (culture, kind) { + (Culture::Qaari, Market) => &["the {w} Souk", "the Great Souk", "the Spice Souk"], + (Culture::Qaari, Gate) => &["the Caravan Yards", "the {w} Caravanserai", "the Gate of {w}"], + (Culture::Qaari, Homes) => &["the {w} Quarter", "the Lanes of {w}", "the Old Wells"], + (Culture::Qaari, Poor) => &["the Lower Wells", "the Dust Quarter", "the {w} Warren"], + (Culture::Qaari, Grand) => &["the Rise of {w}", "the Shaded Heights", "the Palace Rise"], + (Culture::Qaari, Holy) => { + &["the Shrine Steps", "the Deep Well Quarter", "the {w} Sanctuary"] + } + (Culture::Qaari, Works) => { + &["the Dyers' Lanes", "the Glassblowers' Quarter", "the Tanneries"] + } + (Culture::Qaari, Gardens) => &["the Palm Gardens", "the {w} Oasis", "the Date Groves"], + (Culture::Cant, Market) => &["the Exchange Floor", "Barter Deck {n}", "the {w} Market"], + (Culture::Cant, Gate) => &["the Intake", "Gate Sector {n}", "the Receiving Yards"], + (Culture::Cant, Homes) => &["Hab Sector {n}", "the {w} Habs", "Dormitory Row {r}"], + (Culture::Cant, Poor) => &["the Undercroft", "the Sump", "Sector {n}-Low"], + (Culture::Cant, Grand) => &["the Primarch's Tier", "the Upper Spire", "the {w} Tier"], + (Culture::Cant, Holy) => &["the Litany Steps", "the Engine Precinct", "the Fane Sector"], + (Culture::Cant, Works) => &["the Foundry Ward", "Pipe Row", "the {w} Works"], + (Culture::Cant, Gardens) => { + &["the Coolant Pools", "the Fungus Vaults", "the Moss Terraces"] + } + (Culture::Houses, Market) => &["the {w} Market", "the Marble Exchange", "the Silk Arcade"], + (Culture::Houses, Gate) => { + &["the Gate of {w}", "the Customs Quarter", "the Carriage Yards"] + } + (Culture::Houses, Homes) => &["the {w} Quarter", "Lantern Row", "the {w} Walk"], + (Culture::Houses, Poor) => &["the Stews", "Tallow Lane", "the {w} Rookery"], + (Culture::Houses, Grand) => &["the Archon's Hill", "the {w} Heights", "the Court Quarter"], + (Culture::Houses, Holy) => { + &["the Cathedral Close", "the {w} Cloisters", "the Choir Quarter"] + } + (Culture::Houses, Works) => &["the Perfumers' Quarter", "the Ateliers", "Glass Street"], + (Culture::Houses, Gardens) => &["the {w} Gardens", "the Pleasure Gardens", "the Orangery"], + (Culture::Drift, Market) => &["the Swap", "the {w} Bazaar", "the Hanging Market"], + (Culture::Drift, Gate) => &["the Moorings", "the {w} Berths", "the Dockside"], + (Culture::Drift, Homes) => &["the Bunks", "{w} Row", "the Hull Quarter"], + (Culture::Drift, Poor) => &["the Scrap", "the Leaky End", "the {w} Tangle"], + (Culture::Drift, Grand) => &["Captains' Row", "the High Masts", "the {w} Deck"], + (Culture::Drift, Holy) => &["the Lantern Quarter", "the Rememberings", "the {w} Vigil"], + (Culture::Drift, Works) => &["the Splice", "the Sail Lofts", "the {w} Yards"], + (Culture::Drift, Gardens) => { + &["the Green Tanks", "the {w} Hydroponics", "the Garden Hulls"] + } + }; + fill(culture, templates, rng) +} + +/// Who works in a workshop. +fn trades(culture: Culture) -> &'static [&'static str] { + match culture { + Culture::Qaari => &["weaver", "potter", "glassblower", "dyer", "coppersmith", "rugmaker"], + Culture::Cant => &["welder", "wire-drawer", "gear-cutter", "plater", "cogwright"], + Culture::Houses => &["perfumer", "jeweller", "tailor", "luthier", "mask-maker", "glazier"], + Culture::Drift => &["sailmaker", "splicer", "tinker", "chartmaker", "ropemaker"], + } +} + +/// What a building is called: "a house", "House Vastre", "the Qasr Khaseem". +pub fn building_name(kind: Kind, culture: Culture, rng: &mut Rng) -> String { + use Culture::*; + let templates: &'static [&'static str] = match (kind, culture) { + (Kind::Shop(shop), _) => return shop_name(shop, culture, rng), + (Kind::House, _) => &["a house"], + (Kind::Tenement, Qaari) => &["a courtyard tenement"], + (Kind::Tenement, Cant) => &["a hab-block"], + (Kind::Tenement, Houses) => &["a lodging house"], + (Kind::Tenement, Drift) => &["a bunkhouse"], + (Kind::Manor, Qaari) => &["the House of {w}", "the {w} Mansion"], + (Kind::Manor, Cant) => &["the {w} Habitation", "Residence {w}-{n}"], + (Kind::Manor, Houses) => &["House {w}", "the {w} Townhouse"], + (Kind::Manor, Drift) => &["the {w} Berth", "Captain {w}'s"], + (Kind::Palace, Qaari) => &["the Qasr {w}"], + (Kind::Palace, Cant) => &["the Primarch's Spire"], + (Kind::Palace, Houses) => &["the Archon's Palace"], + (Kind::Palace, Drift) => &["the Speaker's Hulk"], + (Kind::Temple, Qaari) => &["the Deep Well of {w}"], + (Kind::Temple, Cant) => &["the Fane of the Engine"], + (Kind::Temple, Houses) => &["the Basilica of {w}"], + (Kind::Temple, Drift) => &["the Lantern of the Lost"], + (Kind::Shrine, Qaari) => &["a well-shrine"], + (Kind::Shrine, Cant) => &["a litany-chapel"], + (Kind::Shrine, Houses) => &["a chapel"], + (Kind::Shrine, Drift) => &["a waystone shrine"], + (Kind::Workshop, _) => { + let trade = pick(trades(culture), rng); + return format!("a {trade}'s workshop"); + } + (Kind::Warehouse, Qaari) => &["a caravan store"], + (Kind::Warehouse, Cant) => &["a depot"], + (Kind::Warehouse, Houses) => &["a warehouse"], + (Kind::Warehouse, Drift) => &["a cargo shed"], + (Kind::Bathhouse, Qaari) => &["the {w} Baths", "a bathhouse"], + (Kind::Bathhouse, _) => &["the {w} Baths", "a bathhouse"], + (Kind::Guardhouse, Qaari) => &["a gatehouse"], + (Kind::Guardhouse, Cant) => &["an enforcer post"], + (Kind::Guardhouse, Houses) => &["a guardhouse"], + (Kind::Guardhouse, Drift) => &["the watch-house"], + }; + fill(culture, templates, rng) +} + +pub fn shop_name(kind: ShopKind, culture: Culture, rng: &mut Rng) -> String { + use Culture::*; + use ShopKind::*; + let templates: &'static [&'static str] = match (kind, culture) { + (Broker, Qaari) => &["the {w} Assay", "the Ore Souk of {w}"], + (Broker, Cant) => &["the Ore-Tithe of {w}", "Assay Station {n}"], + (Broker, Houses) => &["the {w} Exchange", "the Royal Assayer"], + (Broker, Drift) => &["{w}'s Scales", "the Weigh-In"], + (Doctor, Qaari) => &["the House of Salves", "the {w} Infirmary"], + (Doctor, Cant) => &["the Mending Shrine", "Repair Bay {n}"], + (Doctor, Houses) => &["Physician {w}'s", "the {w} Hospice"], + (Doctor, Drift) => &["the Patchwright", "the Sick Bay"], + (Scholar, Qaari) => &["the Sand Archive", "the House of {w}'s Scrolls"], + (Scholar, Cant) => &["the Index of Relics", "Archive {r}"], + (Scholar, Houses) => &["the {w} Athenaeum", "the {w} Collection"], + (Scholar, Drift) => &["the Rememberer", "the Log-Keeper"], + (Tavern, Qaari) => &["the {w} Cistern", "the Thirsty Dune", "the {w} Tea House"], + (Tavern, Cant) => &["the Oil Hall", "the Burnt Gasket", "Refreshment Unit {n}"], + (Tavern, Houses) => &["the {w} Salon", "the Gilded {w}", "the Masquerade"], + (Tavern, Drift) => &["the Slack Line", "the {w} Mooring", "the Last Burn"], + (Shipyard, Qaari) => &["the Caravan-Wrights", "the {w} Yards"], + (Shipyard, Cant) => &["Hull Foundry {w}", "Drydock {n}"], + (Shipyard, Houses) => &["the Royal Yards", "the {w} Shipwrights"], + (Shipyard, Drift) => &["the Splicers", "{w}'s Slip"], + (Armoury, Qaari) => &["the {w} Blade-Souk", "the House of Edges"], + (Armoury, Cant) => &["Armoury {r}", "the Arc-Smithy of {w}"], + (Armoury, Houses) => &["the {w} Armourers", "the Duellists' Gallery"], + (Armoury, Drift) => &["{w}'s Iron", "the Gun Locker"], + }; + fill(culture, templates, rng) +} + +/// What a ruler is called. +pub fn ruler_title(culture: Culture) -> &'static str { + match culture { + Culture::Qaari => "the Well-Warden", + Culture::Cant => "the Primarch", + Culture::Houses => "the Archon", + Culture::Drift => "the Speaker", + } +} + +/// "A guard", for people without a name. +pub fn role_name(role: Role, culture: Culture) -> &'static str { + match role { + Role::Passerby => "A passer-by", + Role::Child => "A child", + Role::Porter => "A porter", + Role::Pilgrim => "A pilgrim", + Role::Stallholder => "A stallholder", + Role::Guard => match culture { + Culture::Cant => "An enforcer", + _ => "A guard", + }, + Role::Keeper(_) => "The keeper", + Role::Resident => "Someone who lives here", + Role::Servant => "A servant", + Role::Ruler => ruler_title(culture), + Role::Courtier => "A courtier", + Role::Priest => match culture { + Culture::Qaari => "A well-priest", + Culture::Cant => "A tech-cantor", + Culture::Houses => "A priest", + Culture::Drift => "A lamplighter", + }, + Role::Worshipper => "A worshipper", + Role::Worker => "A worker", + Role::Patron => "A drinker", + Role::Patient => "A patient", + Role::Bather => "A bather", + Role::Watchman => "A watchman", + Role::Reader => "A reader", + } +} + +/// What someone says when you bump into them. +pub fn lines(role: Role, culture: Culture) -> &'static [&'static str] { + use Culture::*; + match (role, culture) { + (Role::Passerby, _) => chatter(culture), + (Role::Stallholder, _) => cries(culture), + (Role::Child, Qaari) => &[ + "Is that your ship? Is it faster than a sandstorm?", + "I can hold my breath through a whole dust-wind.", + "My brother says wrecks have ghosts in them.", + ], + (Role::Child, Cant) => &[ + "I am in my apprentice cycle. I'm not supposed to talk to you.", + "Do your engines pray? Ours do.", + "I counted all the bolts on the wall. There are four thousand and six.", + ], + (Role::Child, Houses) => &[ + "Mother says spacers are common. Are you common?", + "I'm going to be an Archon when I grow up. Or a pirate.", + "Bet you can't catch me!", + ], + (Role::Child, Drift) => &[ + "Have you been to the stars? Which one's the best?", + "I was born on a ship, you know. I'm only visiting.", + "Tag! You're it!", + ], + (Role::Porter, _) => &[ + "Mind your back! Heavy load!", + "Two more crates and I can eat.", + "Out of the way, it's on my head, not yours.", + "They pay by the load, not by the step. Shift.", + ], + (Role::Pilgrim, _) => &[ + "I've walked forty days to get here.", + "They say a prayer here is heard twice.", + "Have you come to pray too? You don't look it.", + "I promised my mother I'd come. She's been dead nine years.", + ], + (Role::Guard, Qaari) => &[ + "Keep your hands where the sun can see them.", + "No blades drawn in the souk, offworlder.", + "The gate shuts when the third wind blows. Be in, or be out.", + ], + (Role::Guard, Cant) => &[ + "Halt. Proceed. You were not stopped.", + "Unit. Your papers are in order. Probably.", + "Patrol cycle four hundred and twelve. All nominal.", + ], + (Role::Guard, Houses) => &[ + "Move along, spacer. Nothing to see but your betters.", + "The Archon's peace is kept here.", + "Draw steel in this city and you'll leave it lighter.", + ], + (Role::Guard, Drift) => &[ + "Easy, spacer. Nobody's looking for trouble.", + "Watch shift. Twelve hours of nothing, mostly.", + "If anyone asks, I was awake.", + ], + (Role::Keeper(_), _) => &["What can I do for you?"], + (Role::Resident, Qaari) => &[ + "You'll take water? Everyone who comes in takes water.", + "My grandmother built this house with her own hands.", + "Shut the door behind you, the sand gets in.", + "A guest! Sit, sit. Not there. There.", + ], + (Role::Resident, Cant) => &[ + "Unauthorised presence in a hab-unit. I'll allow it.", + "The walls hum at night. It's comforting.", + "Visitors are logged. You have been logged.", + "My shift starts in an hour. Be brief.", + ], + (Role::Resident, Houses) => &[ + "We weren't expecting callers. Do forgive the state of things.", + "Mind the portraits. Every one of them is watching you.", + "If you're selling something, we already have two.", + "Oh! A spacer. How thrilling. Please don't touch anything.", + ], + (Role::Resident, Drift) => &[ + "Come in, come in. Nobody locks a door in the Drift.", + "It's small, but it doesn't move. I'm still getting used to that.", + "Got any news from out there?", + "Sit anywhere. Everything's somebody's, nothing's anyone's.", + ], + (Role::Servant, _) => &[ + "The household is not receiving.", + "I'm to polish the silver before the bell.", + "You didn't see me sitting down.", + "Wipe your feet. Please. I just did that floor.", + ], + (Role::Ruler, Qaari) => &[ + "Every drop in this city is counted, offworlder, and I count them.", + "Sit in my shade. Few strangers are offered it.", + "The deep erg gives and the deep erg takes. I only keep the ledger.", + ], + (Role::Ruler, Cant) => &[ + "The Engine wills, and I implement. State your function.", + "You are a variable. I dislike variables.", + "Efficiency is devotion. You are wasting both.", + ], + (Role::Ruler, Houses) => &[ + "We so rarely receive spacers. How novel.", + "Kneel, if you know how. No? Then bow. No? Then stand there.", + "My ancestors held this city for eleven hundred years. I intend to beat that.", + ], + (Role::Ruler, Drift) => &[ + "I don't rule. Someone has to hold the tiller, that's all.", + "The council meets when it meets. Sit, if you like.", + "Every ship that docks here is family until it isn't.", + ], + (Role::Courtier, _) => &[ + "Petitions are heard at noon. It is never noon.", + "I've been waiting for an audience since the last eclipse.", + "Don't stand there. That's where the last ambassador stood.", + "Have you heard who dined with whom? No? Then what use are you?", + ], + (Role::Priest, Qaari) => &[ + "The deep well answers every question with silence. Silence is an answer.", + "Drink, and remember who dug the well before you.", + "Water remembers. That is why we pray over it.", + ], + (Role::Priest, Cant) => &[ + "Blessed be the torque. Blessed be the tolerance.", + "Every machine is a prayer that works.", + "The Engine turns. Will you turn with it?", + ], + (Role::Priest, Houses) => &[ + "The saints loved this city once. We are trying to remind them.", + "Light a candle, if you have a coin. Light two, if you have a sin.", + "Even the Archon kneels here. Briefly.", + ], + (Role::Priest, Drift) => &[ + "Every lantern here is someone who didn't come home.", + "We remember the lost so the void doesn't have to.", + "If you board wrecks, light one for whoever you find.", + ], + (Role::Worshipper, _) => &[ + "Shh.", + "...and keep the void from the hull, and the hull from the void...", + "I'm praying. Go away. Kindly.", + "Pray with us, or step softly.", + ], + (Role::Worker, _) => &[ + "Mind the edge, it's sharp.", + "Commissions take a month. Two if you're rude.", + "Every piece made by hand. My hands. Look at them.", + "Pass me that. No, the other one.", + ], + (Role::Patron, _) => &[ + "Buy a round and I'll tell you a story. Two rounds, a true one.", + "This is my stool. It knows me.", + "The wrecks out past the belt? Cursed, the lot of them.", + "One more and I'll go home. I said that last night too.", + "You've got the look of someone who boards wrecks.", + ], + (Role::Patient, _) => &[ + "Don't mind me. I'm dying, slightly.", + "The doctor says I'll mend. The doctor says that to everyone.", + "Got bitten by something aboard a wreck. Don't ask what.", + ], + (Role::Bather, _) => &[ + "Ah. Heaven is hot water.", + "Close the door, you're letting the steam out.", + "Best thing about a planet: baths.", + ], + (Role::Watchman, _) => &[ + "Nothing in here but crates. And me.", + "If you're thinking of stealing, think harder.", + "I count the crates every hour. Still forty-one.", + ], + (Role::Reader, _) => &[ + "Shh. Some of us are reading.", + "Did you know the old empire had nine words for vacuum?", + "I've been on this page for three days.", + ], + } +} + +/// What passers-by say when you bump into them. +pub fn chatter(culture: Culture) -> &'static [&'static str] { + match culture { + Culture::Qaari => &[ + "Water and shade to you, offworlder.", + "The caravan from the deep erg is three days late. Three!", + "Don't drink from the public cistern after dusk. Old law.", + "My cousin crews an ore hauler. He says the belt sings at night.", + "You smell of ship. Go to the tavern; everyone does.", + "The glass storms took the east road again.", + ], + Culture::Cant => &[ + "Unit. Your hull is unblessed. Have it seen to.", + "The litany runs at the third bell. Attendance is noted.", + "Every bolt remembers the hand that turned it.", + "Salvage is sacrament. Do not waste it.", + "I was a pump-servitor before my promotion. I miss the pumps.", + "Do not touch the pipes. The pipes are listening.", + ], + Culture::Houses => &[ + "Mind your boots on the marble, spacer.", + "A House is hosting again. Nobody was invited, so everybody will go.", + "They say the Archon's heir has taken up mining. Imagine.", + "The fountain was a gift. Everything here was a gift. Everything has a price.", + "Do you dance? No? Pity.", + "Never bow lower than the person you're bowing to.", + ], + Culture::Drift => &[ + "Hey, spacer. Long burn?", + "Planets make me dizzy. Too much down.", + "Trade you a song for a ration bar.", + "My mother was born between stars. So was I. It's strange, being here.", + "Somebody's always leaving. That's the Drift.", + "If you see a blue hull with a broken mast, that's my aunt. Wave.", + ], + } +} + +/// What stallholders call out. +pub fn cries(culture: Culture) -> &'static [&'static str] { + match culture { + Culture::Qaari => &[ + "Dates! Sun-dates, sweet as a promise!", + "Spiced locust, two for a ¤!", + "Rugs! Woven by hand, by grandmother's hand!", + "Water-skins, never leaked, never once!", + ], + Culture::Cant => &[ + "Bearings, blessed and greased!", + "Lubricant, consecrated this very morning!", + "Prayer-wire by the metre!", + "Spare parts for spare souls!", + ], + Culture::Houses => &[ + "Candied petals, fit for a court!", + "Silks from the Gardens!", + "Scent of the old capital, one drop a ¤!", + "Masks for the season's balls!", + ], + Culture::Drift => &[ + "Patches, hull or heart!", + "Songs, fresh from the void!", + "Ration bars, hardly expired!", + "Charts of places that might still be there!", + ], + } +} + +pub fn room_name(purpose: Purpose, culture: Culture) -> &'static str { + use Purpose::*; + match purpose { + Living => "the front room", + Bedroom => "a bedroom", + Kitchen => "the kitchen", + Store => "a storeroom", + Lodging => "someone's room", + Taproom => "the taproom", + Parlour => "the back room", + Cellar => "the cellar", + Counter => "the shop", + Ward => "the ward", + Stacks => "the stacks", + Assay => "the assay room", + Yard => "the yard", + Smithy => "the smithy", + Hall => "the hall", + Dining => "the dining room", + Library => "the library", + Bedchamber => "a bedchamber", + Gallery => "the gallery", + Throne => "the throne room", + Council => "the council chamber", + Treasury => "the treasury", + Nave => match culture { + Culture::Qaari => "the well-court", + Culture::Cant => "the engine hall", + Culture::Houses => "the nave", + Culture::Drift => "the lantern hall", + }, + Vestry => "the vestry", + Cell => "a cell", + Shrine => "the shrine", + Workroom => "the workshop", + Storehouse => "the store", + Office => "the office", + Changing => "the changing room", + Baths => "the baths", + Steam => "the steam room", + Guardroom => "the guardroom", + Bunks => "the bunk room", + } +} + +/// What bumping into a piece of furniture tells you. +pub fn thing_about(thing: Thing, culture: Culture) -> &'static str { + use Culture::*; + use Thing::*; + match (thing, culture) { + (Table, _) => "A table, ringed with old cups.", + (Chair | Pew | Bench | Throne, _) => "A seat.", + (Bed, Qaari) => "A sleeping mat on a low frame, piled with rugs.", + (Bed, Cant) => "A cot of welded mesh. Exactly as comfortable as required.", + (Bed, Houses) => "A canopied bed, its curtains heavy with dust.", + (Bed, Drift) => "A hammock-bed, rigged as if the floor might go away.", + (Hearth, Cant) => "A heating coil glows behind a grille.", + (Hearth, _) => "A fire crackles in the hearth.", + (Stove, _) => "A stove, still warm, smelling of whatever was cooked last.", + (Shelf, _) => "Shelves crammed with books, jars and keepsakes.", + (Chest, _) => "A chest. Locked, and none of your business.", + (Crate, _) => "Crates, stencilled with marks you can't read.", + (Barrel, _) => "A barrel. It sloshes.", + (Altar, Qaari) => "The sacred well. The water is so deep it's black.", + (Altar, Cant) => "The Engine: a vast machine that only turns. It hums in your teeth.", + (Altar, Houses) => "An altar of white stone, gold-leafed, crowded with candles.", + (Altar, Drift) => "A lantern-mast, a lantern hung on it for everyone lost in the void.", + (Pillar, _) => "A pillar, carved all the way up.", + (Statue, Qaari) => "A statue of a caravan-master, worn smooth by sandstorms.", + (Statue, Cant) => "A statue of a Fabricant, its face a smooth steel plate.", + (Statue, Houses) => "A marble statue of someone important, looking down on you.", + (Statue, Drift) => "A figurehead, salvaged from some old ship.", + (Plant, Qaari) => "A potted palm, carefully watered.", + (Plant, _) => "A potted plant, carefully watered.", + (Bath, _) => "Steaming water. It smells of minerals.", + (Anvil, _) => "An anvil, bright where the hammer falls.", + (Forge, _) => "A forge, roaring. The heat pushes you back.", + (Loom, _) => "A loom, strung with half a rug.", + (Workbench, _) => "A workbench, scattered with tools and offcuts.", + (Rack, _) => "A rack of pikes and stun-staves.", + (Lamp, Cant) => "A glow-tube on a stand, flickering.", + (Lamp, _) => "A lamp on a tall stand.", + (Engine, _) => "Half a ship's engine, opened up like a patient.", + } +} + +/// What bumping into something in the street tells you, if anything, in +/// a city of `culture` on `land`. For `Tile::Wild`, see `wild_about`. +pub fn tile_about(tile: Tile, culture: Culture, land: Land) -> Option<&'static str> { + use Culture::*; + Some(match (tile, culture) { + (Tile::Centrepiece, Qaari) => "The cistern's water is cool, dark and very still.", + (Tile::Centrepiece, Cant) => "The furnace roars. Prayers are welded to its flue.", + (Tile::Centrepiece, Houses) => { + "Coins glint at the bottom of the fountain. Wishes, or bribes." + } + (Tile::Centrepiece, Drift) => { + "A mast of welded hull-plate, hung with a hundred little bells." + } + (Tile::Statue, _) => thing_about(Thing::Statue, culture), + (Tile::Well, Qaari) => "A covered well, padlocked. Water isn't free here.", + (Tile::Well, Cant) => "A coolant tap, sealed with a litany-tag.", + (Tile::Well, Houses) => "A little fountain, a stone fish spitting into a basin.", + (Tile::Well, Drift) => "A water tank on legs, with a cup on a chain.", + (Tile::Water, _) => pond_about(land), + (Tile::Sea, _) => "The sea, grey-green and restless, all the way to the sky.", + (Tile::Tree, _) => tree_about(culture, land.terrain), + (Tile::Lamp, Cant) => "A glow-tube on a post, buzzing.", + (Tile::Lamp, _) => "A lamp post.", + (Tile::Decor, Drift) => "A heap of salvage, sorted by someone, for some reason.", + (Tile::Decor, _) => "A tangle of pipes, warm to the touch.", + (Tile::Roof, _) => return None, + _ => return None, + }) +} + +fn pond_about(land: Land) -> &'static str { + match (land.pond(), land.terrain) { + (Pond::Water, Terrain::Desert) => "A cistern pool, shaded and very still.", + (Pond::Water, _) => "A pond. Fish, or something like them, move under the surface.", + (Pond::Ice, Terrain::Ice) => "A pond frozen to the bottom, clear as glass.", + (Pond::Ice, _) => "A frozen pond. Something dark moves slowly under the ice.", + (Pond::Dry, _) => "A pond, dried to cracked mud. Someone's raked the cracks.", + (Pond::Lava, _) => "A pool of molten rock behind an iron rail. The heat is ferocious.", + } +} + +fn tree_about(culture: Culture, terrain: Terrain) -> &'static str { + match (terrain, culture) { + (Terrain::Desert, Culture::Qaari) => "A date palm, rattling in the hot wind.", + (Terrain::Desert, _) => "A date palm, its fronds grey with dust.", + (Terrain::Ice, _) => "A glass-pine. Its needles chime when the wind moves them.", + (Terrain::Ocean | Terrain::Jungle, Culture::Houses) => { + "A clipped yew, shaped like a bird. Something keeps trying to unclip it." + } + (Terrain::Ocean, _) => "A salt-pine, leaning away from the sea wind.", + (Terrain::Jungle, _) => "A strangler fig, wound round what used to be another tree.", + (Terrain::Barren | Terrain::Clouds, _) => { + "A spire of violet crystal, taller than you. It's growing, very slowly." + } + (Terrain::Forge, _) => "A fungus stalk as tall as a lamp post, warm and faintly pulsing.", + } +} + +/// What bumping into the `sort`th sort of thing outside the walls tells +/// you (see `Town::wild_sort`). +pub fn wild_about(terrain: Terrain, sort: usize) -> &'static str { + let [first, second] = match terrain { + Terrain::Desert => [ + "A cactus, fat with stored water and bristling with spines.", + "A boulder, scoured smooth by the sand.", + ], + Terrain::Ice => [ + "A spire of blue ice, taller than you and slowly leaning.", + "A boulder, glazed with ice.", + ], + Terrain::Ocean => { + ["Black rock, slick with spray.", "A heap of kelp, washed up and drying. It smells."] + } + Terrain::Jungle => [ + "Trees, crowded so close you can't push between them.", + "A wall of ferns and creepers, alive with clicking.", + ], + Terrain::Barren | Terrain::Clouds => [ + "A crag of grey rock, crusted with rust-red lichen.", + "A cluster of violet crystals, knee-high.", + ], + Terrain::Forge => { + ["A heap of black basalt, still warm.", "A vent, breathing hot, sulphurous air."] + } + }; + if sort == 0 { first } else { second } +} + +/// What the road out of a gate leads to. +pub fn beyond(terrain: Terrain) -> &'static str { + match terrain { + Terrain::Desert => "The road runs on into the dunes. There's nothing out there for you.", + Terrain::Ice => { + "The road runs on across the snowfields. There's nothing out there for you." + } + Terrain::Ocean => { + "The causeway runs on out to sea, to the fishing piers. There's nothing out there \ + for you." + } + Terrain::Jungle => "The road runs on under the canopy. There's nothing out there for you.", + Terrain::Barren | Terrain::Clouds => { + "The road runs on across the grey dust. There's nothing out there for you." + } + Terrain::Forge => { + "The road runs on between smoking lava fields. There's nothing out there for you." + } + } +} diff --git a/src/trade.rs b/src/trade.rs new file mode 100644 index 0000000..cfd92bf --- /dev/null +++ b/src/trade.rs @@ -0,0 +1,639 @@ +//! What things cost and what they fetch: ore and salvage at markets, +//! medpatches, scholars' fees and offers, weapons and armour at an +//! armourer's (and mending it), ship +//! fittings, bigger packs, fuel and tows, and the talk you can buy in a +//! tavern. Prices vary from place to place but stay the same at any one +//! place, visit after visit. + +use std::collections::HashSet; + +use rand::RngExt; +use rand::seq::SliceRandom; +use serde::{Deserialize, Serialize}; + +use crate::app::{Hold, Kept}; +use crate::dungeon::armour::{self, Armor}; +use crate::dungeon::arms::{self, Weapon}; +use crate::items::{Item, Salvage}; +use crate::names::{Artifact, Culture}; +use crate::scenes::sky::hash01; +use crate::seed; +use crate::space::galaxy::Galaxy; +use crate::space::locale::{ORES, Ore}; +use crate::space::system::{System, SystemKind}; + +/// A number from `low` to `high`, fixed for this place and `what`. +fn local(system: &System, place: usize, what: &str, low: f64, high: f64) -> f64 { + let k = hash01(seed::derive(system.seed, what, place as u64), 0, 0); + low + (high - low) * k +} + +/// What each ore fetches at a place: its value, give or take. +pub fn ore_prices(system: &System, place: usize) -> Vec<(&'static Ore, u32)> { + let market = seed::derive(system.seed, "market", place as u64); + ORES.iter() + .enumerate() + .map(|(i, ore)| { + let k = 0.7 + 0.7 * hash01(market, i as i64, 0); + (ore, (f64::from(ore.value) * k).round().max(1.0) as u32) + }) + .collect() +} + +/// Sells all the ore in the hold at these prices. Returns the tons sold and +/// what they fetched, or `None` if there was nothing to sell. +pub fn sell_ore(hold: &mut Hold, prices: &[(&'static Ore, u32)]) -> Option<(u32, u32)> { + let tons = hold.ore_tons(); + if tons == 0 { + return None; + } + let price = |name: &str| prices.iter().find(|(o, _)| o.name == name).map_or(0, |(_, p)| *p); + let earned = hold.ore.iter().map(|(name, t)| t * price(name)).sum(); + hold.ore.clear(); + hold.earn(earned); + Some((tons, earned)) +} + +/// What brokers pay for salvage, against what it's worth: from this to this, +/// depending on the place. +const SCRAP: (f64, f64) = (0.75, 1.2); +/// What armourers charge for arms and armour, against what they're worth. +const ARMS_SOLD: (f64, f64) = (0.9, 1.2); +/// What armourers pay, against what they charge. +const ARMS_BOUGHT: (f64, f64) = (0.45, 0.55); +/// What scholars pay for an artifact, against what it's worth. +const COLLECTED: (f64, f64) = (0.8, 1.25); + +fn middle((low, high): (f64, f64)) -> f64 { + (low + high) / 2.0 +} + +/// About what one of `item` would fetch from whoever buys that sort of +/// thing, at a place in the middle of the range: an armourer for arms and +/// armour, a broker for salvage, a scholar for an artifact once one's told +/// you what it is. `None` for what no one buys, and for an artifact no one +/// has named yet. +pub fn typical_offer(item: &Item) -> Option<u32> { + let arms = middle(ARMS_SOLD) * middle(ARMS_BOUGHT); + let worth = match item { + Item::Weapon(w) => f64::from(arms::value(w)) * arms, + Item::Armor(a) => armour::worth(a) * arms, + Item::Salvage(s) => f64::from(s.value) * middle(SCRAP), + Item::Artifact(a) if a.identified => f64::from(artifact_value(a)) * middle(COLLECTED), + _ => return None, + }; + Some(worth.round().max(1.0) as u32) +} + +/// What a broker here pays for one piece of salvage: what it's worth, give +/// or take. +pub fn salvage_offer(system: &System, place: usize, salvage: &Salvage) -> u32 { + let k = local(system, place, "scrap", SCRAP.0, SCRAP.1); + (f64::from(salvage.value) * k).round().max(1.0) as u32 +} + +/// Every stack of salvage you have, on you and aboard, with what all of it +/// would fetch here. +pub fn salvage_kept(hold: &Hold, system: &System, place: usize) -> Vec<(Kept, u32)> { + hold.kept() + .filter_map(|(at, s)| match &s.item { + Item::Salvage(v) => Some((at, salvage_offer(system, place, v) * s.count)), + _ => None, + }) + .collect() +} + +/// Sells the salvage kept `at` for what a broker here pays. Returns what it +/// was and what it fetched, or `None` if there's no salvage there. +pub fn sell_salvage( + hold: &mut Hold, + system: &System, + place: usize, + at: Kept, +) -> Option<(String, u32)> { + let stack = hold.kept_mut(at)?; + let Item::Salvage(v) = &stack.item else { return None }; + let earned = salvage_offer(system, place, v) * stack.count; + let count = stack.count; + let sold = hold.take_kept(at, count); + hold.earn(earned); + Some((sold.name(), earned)) +} + +/// Sells all the salvage you have, on you and aboard. Returns how many +/// pieces and what they fetched, or `None` if there was none. +pub fn sell_all_salvage(hold: &mut Hold, system: &System, place: usize) -> Option<(u32, u32)> { + let (mut pieces, mut earned) = (0, 0); + // From the back, so selling one doesn't move the others. + let kept: Vec<Kept> = salvage_kept(hold, system, place).into_iter().map(|(at, _)| at).collect(); + for at in kept.into_iter().rev() { + let count = hold.kept_mut(at).map_or(0, |s| s.count); + if let Some((_, paid)) = sell_salvage(hold, system, place, at) { + pieces += count; + earned += paid; + } + } + (pieces > 0).then_some((pieces, earned)) +} + +/// The most medpatches a doctor will sell you: they don't hold with +/// hoarding. +pub const MEDPATCH_LIMIT: u32 = 9; + +pub fn medpatch_price(system: &System, place: usize) -> u32 { + local(system, place, "medpatch", 8.0, 16.0).round() as u32 +} + +/// What a scholar charges to tell you what an artifact is. +pub fn identify_fee(system: &System, place: usize) -> u32 { + local(system, place, "scholar", 10.0, 25.0).round() as u32 +} + +/// What an artifact is worth, once you know what it is. Fixed by its name, +/// so the same artifact is worth the same anywhere. +pub fn artifact_value(artifact: &Artifact) -> u32 { + let h = seed::derive(0, &artifact.true_name, 0); + 40 + (h % 29) as u32 * 5 +} + +/// What this place's scholar will pay for an identified artifact. +pub fn artifact_offer(system: &System, place: usize, artifact: &Artifact) -> u32 { + let k = local(system, place, "collector", COLLECTED.0, COLLECTED.1); + (f64::from(artifact_value(artifact)) * k).round() as u32 +} + +/// What an armourer charges for weapons and pays for them, against what +/// they're worth (`arms::value`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Terms { + sells_at: f64, + buys_at: f64, +} + +impl Terms { + /// What they want for `item`, if it's a weapon or armour. + pub fn asking(self, item: &Item) -> Option<u32> { + self.price(item, self.sells_at) + } + + /// What they'd give you for `item`, if it's a weapon or armour. + pub fn offer(self, item: &Item) -> Option<u32> { + self.price(item, self.buys_at) + } + + fn price(self, item: &Item, k: f64) -> Option<u32> { + let worth = match item { + Item::Weapon(w) => f64::from(arms::value(w)), + Item::Armor(a) => armour::worth(a), + _ => return None, + }; + Some((worth * k).round().max(1.0) as u32) + } + + /// What they want to mend `a` whole: nothing if it's whole already, + /// and for one worn out about half what they'd sell it for. + pub fn mending(self, a: &Armor) -> u32 { + let k = armour::mending(a) * self.sells_at; + if a.durability >= a.max_durability { 0 } else { k.ceil().max(1.0) as u32 } + } +} + +/// The armourer's terms at a place: it charges about what a weapon or a +/// piece of armour's worth, give or take, and pays about half that. +pub fn armoury_terms(system: &System, place: usize) -> Terms { + let sells_at = local(system, place, "arms", ARMS_SOLD.0, ARMS_SOLD.1); + let buys_at = sells_at * local(system, place, "arms bought", ARMS_BOUGHT.0, ARMS_BOUGHT.1); + Terms { sells_at, buys_at } +} + +/// An armourer's rack, as it stands this fortnight: what it was stocked with, +/// less what you've bought, and with what you've sold it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Rack { + /// The system's seed, and the place in it. + pub system: u64, + pub place: usize, + /// Which fortnight it's stocked for (`fortnight`). Saves from when + /// racks were restocked every week call it `week`. + #[serde(alias = "week")] + pub fortnight: u64, + pub weapons: Vec<Weapon>, + /// Racks from before there was armour have none until they're + /// restocked. + #[serde(default)] + pub armour: Vec<Armor>, +} + +/// How many days a rack stays as it is before it's restocked. +pub const RESTOCK_DAYS: f64 = 14.0; + +/// The fortnight `day` is in, as far as racks go. +pub fn fortnight(day: f64) -> u64 { + (day.max(0.0) / RESTOCK_DAYS) as u64 +} + +/// What an armourer where `culture` lives stocks in `fortnight`: six to ten +/// weapons, and about one rack in five has a relic among them. +pub fn stock(system: &System, place: usize, culture: Culture, fortnight: u64) -> Vec<Weapon> { + let rack = seed::derive(system.seed, "rack", place as u64); + // Still "week", from when racks were restocked weekly. + let mut rng = seed::rng(seed::derive(rack, "week", fortnight)); + let n = rng.random_range(6..=10); + let relic = rng.random_bool(0.2); + (0..n).map(|i| arms::stocked(&mut rng, culture, relic && i == 0)).collect() +} + +/// The armour beside the weapons that fortnight: two to five pieces, mostly +/// of the sort `culture` makes, and now and then a relic. +pub fn stock_armour(system: &System, place: usize, culture: Culture, fortnight: u64) -> Vec<Armor> { + let rack = seed::derive(system.seed, "armour rack", place as u64); + let mut rng = seed::rng(seed::derive(rack, "week", fortnight)); + let n = rng.random_range(2..=5); + let relic = rng.random_bool(0.12); + (0..n).map(|i| armour::stocked(&mut rng, culture, relic && i == 0)).collect() +} + +/// The rack at `place` this fortnight, stocked afresh if the one you knew +/// is from another. Racks from other fortnights are forgotten. +pub fn rack<'a>( + racks: &'a mut Vec<Rack>, + system: &System, + place: usize, + culture: Culture, + day: f64, +) -> &'a mut Rack { + let fortnight = fortnight(day); + racks.retain(|r| r.fortnight == fortnight); + let i = match racks.iter().position(|r| r.system == system.seed && r.place == place) { + Some(i) => i, + None => { + let weapons = stock(system, place, culture, fortnight); + let armour = stock_armour(system, place, culture, fortnight); + racks.push(Rack { system: system.seed, place, fortnight, weapons, armour }); + racks.len() - 1 + } + }; + &mut racks[i] +} + +pub const ROUND_PRICE: u32 = 3; + +/// Cargo racks: each set adds this many tons of ore space. +pub const RACK_TONS: u32 = 20; +const RACK_COSTS: [u32; 3] = [150, 300, 500]; +/// A better lens makes the laser cut this much faster, per lens. +const LENS_BOOST: f64 = 0.6; +const LENS_COSTS: [u32; 2] = [200, 450]; + +/// An extra fuel tank: each adds this many jumps. +pub const TANK_JUMPS: u32 = 2; +const TANK_COSTS: [u32; 2] = [350, 700]; +/// The most fuel a ship can hold, with every tank fitted. +pub const MAX_FUEL: u32 = Hold::BASE_FUEL + TANK_JUMPS * TANK_COSTS.len() as u32; + +/// What the next extra tank costs, if there's room for one. +pub fn tank_cost(hold: &Hold) -> Option<u32> { + let fitted = hold.fuel_capacity.saturating_sub(Hold::BASE_FUEL) / TANK_JUMPS; + TANK_COSTS.get(fitted as usize).copied() +} + +/// What a jump's worth of fuel costs at a place. +pub fn fuel_price(system: &System, place: usize) -> u32 { + local(system, place, "fuel", 18.0, 34.0).round() as u32 +} + +/// Fills the tank with as much fuel as there's room for and you can pay +/// for. Returns how many jumps' worth you bought, and for how much. +pub fn buy_fuel(hold: &mut Hold, price: u32) -> (u32, u32) { + let room = hold.fuel_capacity.saturating_sub(hold.fuel); + let jumps = room.min(hold.scrip / price.max(1)); + hold.fuel += jumps; + hold.scrip -= jumps * price; + (jumps, jumps * price) +} + +/// What a tow costs, `jumps` jumps to somewhere that sells fuel. +pub fn tow_fee(jumps: usize) -> u32 { + 150 + 75 * jumps as u32 +} + +/// How it went at a counter, when some of what you earned went to your +/// debt: " 40 ¤ of it went to what you owe." or nothing. +pub fn debt_note(paid: u32, hold: &Hold) -> String { + match (paid, hold.debt) { + (0, _) => String::new(), + (p, 0) => format!(" {p} ¤ of it paid off your debt."), + (p, left) => format!(" {p} ¤ of it went to your debt; you still owe {left} ¤."), + } +} + +/// What the next set of racks costs, if there's room for more. +pub fn rack_cost(hold: &Hold) -> Option<u32> { + let fitted = (hold.ore_capacity - Hold::BASE_ORE_CAPACITY) / RACK_TONS; + RACK_COSTS.get(fitted as usize).copied() +} + +/// What the next lens costs, if there's a better one to fit. +pub fn lens_cost(lenses: u32) -> Option<u32> { + LENS_COSTS.get(lenses as usize).copied() +} + +/// How fast the laser cuts, 1 being as it came. +pub fn laser_power(lenses: u32) -> f64 { + 1.0 + LENS_BOOST * f64::from(lenses) +} + +/// Things people say in taverns, about the rest of the system: where the +/// wrecks are, where ore sells best. Shuffled for this visit. +pub fn rumours( + system: &System, + place: usize, + day: f64, + stripped: &HashSet<u64>, + visit: u64, + beyond: Vec<String>, +) -> Vec<String> { + let mut said = beyond; + for w in system.beacons(day, stripped) { + let near = &system.places[w.place].name; + let left = w.leaves - day; + said.push(match day - w.arrived { + ..3.0 => format!( + "A {} went dark near {near}, just now. Its beacon's crying fresh.", + w.description + ), + _ if left < 5.0 => format!( + "There's a {} near {near}, but its beacon's fading. Days left, if that.", + w.description + ), + _ => format!( + "A {} went dark near {near}. Its beacon's still crying, they say.", + w.description + ), + }); + } + // Where each of the three dearest ores sells best, other than here. + for (i, ore) in ORES.iter().enumerate().rev().take(3) { + let best = (0..system.places.len()) + .filter(|&p| p != place && sells_ore(system, p)) + .max_by_key(|&p| ore_prices(system, p)[i].1); + if let Some(p) = best { + let price = ore_prices(system, p)[i].1; + said.push(format!( + "{} pays {price} ¤ a ton for {}. Best in the system.", + capitalise(&system.places[p].name), + ore.name + )); + } + } + said.push(format!( + "The rocks of {} are thick with ore, if you've a laser and patience.", + system.belt.name + )); + for planet in system.planets.iter().filter(|p| p.kind.giant) { + said.push(format!( + "Nothing lands on {}. Cloud-skimmers dive it for fuel, and most come back.", + planet.name + )); + } + for station in &system.stations { + said.push(format!("{} buys ore too, and asks fewer questions.", capitalise(&station.name))); + } + said.shuffle(&mut seed::rng(seed::derive(system.seed, "rumours", visit))); + said +} + +/// What people say about the systems a lane joins to this one: which are +/// dead, which are rich, whose space you'd be in. +pub fn lane_rumours(galaxy: &Galaxy, star: usize) -> Vec<String> { + let here = &galaxy.sites[star]; + let mut said = Vec::new(); + for &n in &here.lanes { + let there = &galaxy.sites[n]; + let name = &there.name; + let people = there.culture.name(); + said.push(match there.kind { + SystemKind::Desolate => format!( + "No one lives in {name} any more. Fill your tank before you jump there, \ + or you'll be waiting on a tow." + ), + SystemKind::Graveyard => format!( + "{name}'s an old battlefield. Wrecks everywhere, and not all of them empty." + ), + SystemKind::Rubble => { + format!("The belt at {name} is the richest rock this side of the lanes.") + } + SystemKind::Seat => format!( + "{name}'s the seat of {people}. Everything's dearer there, and everyone's watching." + ), + SystemKind::Frontier => { + format!("Out at {name} there's hardly anyone. Fuel, though, if you ask nicely.") + } + SystemKind::Settled if there.culture != here.culture => { + format!("Jump to {name} and you're in the space of {people}. Mind your manners.") + } + SystemKind::Settled => format!("{name}'s quiet. Good markets, no trouble."), + }); + } + said +} + +/// Whether anyone at a place buys ore: wherever people live and you can +/// land or dock. Not a giant's cloud cities, which you can't reach. +pub fn sells_ore(system: &System, place: usize) -> bool { + system.landing(place).trades() +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::names; + use crate::space::system; + + #[test] + fn an_armourer_sells_dear_and_buys_cheap_and_restocks_fortnightly() { + let s = system::generate(3); + let terms = armoury_terms(&s, 0); + let mut racks = Vec::new(); + let weapons = rack(&mut racks, &s, 0, Culture::Cant, 2.0).weapons.clone(); + assert!((6..=10).contains(&weapons.len())); + for w in &weapons { + let item = Item::Weapon(w.clone()); + let (asking, offer) = (terms.asking(&item).unwrap(), terms.offer(&item).unwrap()); + assert!(offer * 3 < asking * 2 && offer * 5 > asking * 2, "{asking} vs {offer}"); + } + assert_eq!(terms.offer(&Item::Medpatch), None, "arms and armour only"); + // What you buy is gone from the rack until it's restocked. + rack(&mut racks, &s, 0, Culture::Cant, 2.0).weapons.remove(0); + assert_eq!(rack(&mut racks, &s, 0, Culture::Cant, 13.9).weapons, weapons[1..]); + assert_eq!(rack(&mut racks, &s, 0, Culture::Cant, 14.5).weapons.len(), { + stock(&s, 0, Culture::Cant, 1).len() + }); + assert_eq!(racks.len(), 1, "last fortnight's rack is forgotten"); + } + + #[test] + fn armour_hangs_beside_the_weapons_and_mending_goes_by_wear() { + let s = system::generate(3); + let terms = armoury_terms(&s, 0); + let mut racks = Vec::new(); + let armour = rack(&mut racks, &s, 0, Culture::Qaari, 2.0).armour.clone(); + assert!((2..=5).contains(&armour.len())); + let mut a = armour[0].clone(); + assert_eq!(a.durability, a.max_durability); + assert_eq!(terms.mending(&a), 0, "whole already"); + let item = |a: &Armor| Item::Armor(a.clone()); + let (whole, offer) = (terms.asking(&item(&a)).unwrap(), terms.offer(&item(&a)).unwrap()); + assert!(offer < whole); + a.durability = 0; + let ruined = terms.mending(&a); + assert!(ruined.abs_diff(whole / 2) <= 1, "half its price from ruined: {ruined} of {whole}"); + assert!(terms.offer(&item(&a)).unwrap() < offer, "worth less worn out"); + a.durability = a.max_durability - 1; + assert!((1..ruined).contains(&terms.mending(&a)), "a little wear, a little to mend"); + // Over the months, now and then a relic. + let relics = (0..100) + .flat_map(|fortnight| stock_armour(&s, 0, Culture::Houses, fortnight)) + .filter(|a| a.named) + .count(); + assert!((3..30).contains(&relics), "{relics} relics in 100 racks"); + } + + #[test] + fn a_typical_offer_is_somewhere_in_the_middle_of_what_places_pay() { + let s = system::generate(3); + let mut rng = seed::rng(1); + let w = Item::Weapon(arms::find(&mut rng, arms::Source::Armoury, false)); + let a = Item::Armor(armour::find(&mut rng, arms::Source::Armoury, false)); + for item in [w, a] { + let typical = typical_offer(&item).unwrap(); + let offers: Vec<u32> = + (0..40).map(|place| armoury_terms(&s, place).offer(&item).unwrap()).collect(); + let (lo, hi) = (*offers.iter().min().unwrap(), *offers.iter().max().unwrap()); + assert!((lo..=hi).contains(&typical), "{typical} not in {lo}..={hi}"); + } + let scrap = Salvage { title: "scrap".into(), value: 40, mass: 1000 }; + assert_eq!(typical_offer(&Item::Salvage(scrap)), Some(39)); + assert_eq!(typical_offer(&Item::Medpatch), None, "no one buys medpatches"); + let mut found = names::artifact(&mut rng); + assert_eq!(typical_offer(&Item::Artifact(found.clone())), None, "not until it's named"); + found.identified = true; + assert!(typical_offer(&Item::Artifact(found)).is_some()); + } + + #[test] + fn racks_lean_to_their_peoples_weapons_and_now_and_then_a_relic() { + let s = system::generate(5); + let racks: Vec<Vec<Weapon>> = + (0..60).map(|fortnight| stock(&s, 1, Culture::Drift, fortnight)).collect(); + let all: Vec<&Weapon> = racks.iter().flatten().collect(); + let guns = all.iter().filter(|w| w.is_gun()).count(); + assert!(guns * 3 > all.len() * 2, "the Drift sell mostly guns: {guns} of {}", all.len()); + let relics = racks.iter().filter(|r| r.iter().any(|w| w.named)).count(); + assert!((4..=24).contains(&relics), "{relics} racks of 60 with a relic"); + let worth = |w: &Weapon| arms::value(w); + let relic = all.iter().filter(|w| w.named).map(|w| worth(w)).min().unwrap(); + let plain = all.iter().filter(|w| !w.named).map(|w| worth(w)).max().unwrap(); + assert!(relic * 3 > plain, "relics are dear: {relic} against {plain}"); + } + + #[test] + fn selling_ore_pays_the_local_price() { + let s = system::generate(1); + let prices = ore_prices(&s, 0); + let mut hold = Hold::default(); + assert_eq!(sell_ore(&mut hold, &prices), None); + hold.ore.insert("iron ore", 3); + hold.ore.insert("iridium", 2); + let price = |name: &str| prices.iter().find(|(o, _)| o.name == name).unwrap().1; + let expected = 3 * price("iron ore") + 2 * price("iridium"); + assert_eq!(sell_ore(&mut hold, &prices), Some((5, expected))); + assert_eq!((hold.scrip, hold.ore_tons()), (expected, 0)); + } + + #[test] + fn prices_differ_between_places_but_not_between_visits() { + let s = system::generate(1); + let all: Vec<_> = (0..s.places.len()).map(|p| ore_prices(&s, p)[0].1).collect(); + assert!(all.iter().any(|&p| p != all[0])); + assert_eq!(ore_prices(&s, 1), ore_prices(&s, 1)); + assert_eq!(medpatch_price(&s, 2), medpatch_price(&s, 2)); + } + + #[test] + fn artifacts_are_worth_the_same_everywhere_give_or_take() { + let s = system::generate(1); + let a = names::artifact(&mut seed::rng(4)); + let v = artifact_value(&a); + assert!((40..=180).contains(&v)); + for place in 0..s.places.len() { + let offer = artifact_offer(&s, place, &a); + assert!(offer >= v * 4 / 5 && offer <= v * 5 / 4 + 1); + } + } + + #[test] + fn fittings_run_out() { + let mut hold = Hold::default(); + let mut spent = 0; + while let Some(cost) = rack_cost(&hold) { + spent += cost; + hold.ore_capacity += RACK_TONS; + } + assert_eq!(hold.ore_capacity, Hold::BASE_ORE_CAPACITY + 3 * RACK_TONS); + assert_eq!(spent, 950); + assert_eq!(lens_cost(2), None); + assert!(laser_power(2) > laser_power(0)); + while tank_cost(&hold).is_some() { + hold.fuel_capacity += TANK_JUMPS; + } + assert_eq!(hold.fuel_capacity, MAX_FUEL); + } + + #[test] + fn fuel_fills_as_far_as_money_goes() { + let mut hold = Hold { fuel: 1, scrip: 50, ..Hold::default() }; + assert_eq!(buy_fuel(&mut hold, 20), (2, 40)); + assert_eq!((hold.fuel, hold.scrip), (3, 10)); + hold.scrip = 500; + assert_eq!(buy_fuel(&mut hold, 20), (1, 20), "only as much as fits"); + assert_eq!(buy_fuel(&mut hold, 20), (0, 0)); + } + + #[test] + fn debts_are_paid_from_what_you_earn() { + let mut hold = Hold { scrip: 100, ..Hold::default() }; + assert_eq!(hold.charge(250), 150); + assert_eq!((hold.scrip, hold.debt), (0, 150)); + assert_eq!(hold.earn(100), 100); + assert_eq!((hold.scrip, hold.debt), (0, 50)); + assert_eq!(hold.earn(80), 50); + assert_eq!((hold.scrip, hold.debt), (30, 0)); + assert!(debt_note(50, &hold).contains("paid off")); + } + + #[test] + fn taverns_talk_about_wrecks_until_theyre_stripped() { + let s = system::generate(1); + let day = (0..200).map(f64::from).find(|&d| !s.wrecks_on(d).is_empty()).unwrap(); + let calling = s.wrecks_on(day); + let wreck = &calling[0]; + let mentions = |talk: &[String]| { + let about = |w: &system::Wreck| { + let near = format!("near {}", s.places[w.place].name); + talk.iter().filter(|r| r.contains(&w.description) && r.contains(&near)).count() + }; + calling.iter().map(about).sum::<usize>() + }; + let talk = rumours(&s, 0, day, &HashSet::new(), 0, Vec::new()); + assert!(talk.iter().any(|r| r.contains(&wreck.description))); + let everyone = mentions(&talk); + let talk = rumours(&s, 0, day, &HashSet::from([wreck.seed]), 0, Vec::new()); + assert!(mentions(&talk) < everyone, "no one talks of a wreck you've stripped"); + } +} |