diff options
| author | skylar <me@casper> | 2026-11-10 06:50:08 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-10 06:50:08 -0800 |
| commit | d36c54d996f3c14ae811312dbbe621e12a39edde (patch) | |
| tree | 1807134323479ac90bf69d28e410a7f70cb235a1 | |
Orrery: game loop, name generator and derelict dungeons
An ASCII sci-fi-fantasy game for the terminal, built on ratatui.
- Game loop with a fixed 30 Hz tick and a stack of scenes; the kitty
keyboard protocol is turned on where the terminal supports it, so later
real-time scenes can tell when a key is held.
- Stable hierarchical seeds: the whole universe derives from one u64,
with tests pinning the exact outputs so old seeds keep working.
- Name generator with four cultures, each with its own syllables and
templates, plus unidentified/true names for artifacts.
- Title screen with an animated orrery, and a name forge for browsing
generated names.
- Derelict ships to board: ship-shaped maps with an engine room, spine
corridor, side rooms and bridge; symmetric shadowcasting sight;
monsters that wake on sight and hunt; loot carried out through the
airlock goes into the hold.
- `orrery names` and `orrery ship` print the generators' output without
the TUI, for tuning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cargo.lock | 1818 | ||||
| -rw-r--r-- | Cargo.toml | 9 | ||||
| -rw-r--r-- | README.md | 59 | ||||
| -rw-r--r-- | rustfmt.toml | 4 | ||||
| -rw-r--r-- | src/app.rs | 110 | ||||
| -rw-r--r-- | src/cli.rs | 156 | ||||
| -rw-r--r-- | src/dungeon/crawl.rs | 416 | ||||
| -rw-r--r-- | src/dungeon/fov.rs | 207 | ||||
| -rw-r--r-- | src/dungeon/mod.rs | 156 | ||||
| -rw-r--r-- | src/dungeon/ship.rs | 452 | ||||
| -rw-r--r-- | src/main.rs | 43 | ||||
| -rw-r--r-- | src/names/artifact.rs | 69 | ||||
| -rw-r--r-- | src/names/mod.rs | 319 | ||||
| -rw-r--r-- | src/scenes/derelict.rs | 413 | ||||
| -rw-r--r-- | src/scenes/forge.rs | 154 | ||||
| -rw-r--r-- | src/scenes/mod.rs | 74 | ||||
| -rw-r--r-- | src/scenes/title.rs | 224 | ||||
| -rw-r--r-- | src/seed.rs | 93 | ||||
| -rw-r--r-- | src/term.rs | 50 |
20 files changed, 4827 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1ccdbbb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1818 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.10", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "finl_unicode" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ded33109e99472f809cb7495544506eea45e366725dacf2a383e184f660456" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror 2.0.21", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "mac_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381" +dependencies = [ + "nix 0.31.3", + "windows-sys", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "orrery" +version = "0.1.0" +dependencies = [ + "rand 0.10.3", + "rand_chacha", + "ratatui", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec53fd1d256900a9a771bf2720d42cef6f8bc91b4e25065fbd291e3e7222576" +dependencies = [ + "memchr", + "pest_stack_guard", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcf794fd301ddc20818a6b4de85ed507f4b45300245cec948982e69e12e3bd7" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3073afae5db334c25dea9fc3e230a4f191e920c1df080cb9c0b37c9ce1dbb41" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "pest_meta" +version = "2.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc7f614b3742541642af1685faaa910bf6333e3cd3729c2cccd118146a57327" +dependencies = [ + "pest", +] + +[[package]] +name = "pest_stack_guard" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac6154fd4418b8f0b82532c59c26f303f9ca59a64c883e8ce3971fd82733439" +dependencies = [ + "psm", + "stacker", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.8", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.10", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1e627e7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "orrery" +version = "0.1.0" +edition = "2024" + +[dependencies] +rand = "0.10.3" +rand_chacha = "0.10.0" +ratatui = "0.30.2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..b224202 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Orrery + +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. + +```sh +cargo run # play (a random universe) +cargo run -- --seed 1312 # play 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 test +``` + +## Derelicts + +Press `d` on the title screen to board a derelict. 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. + +## 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/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/term.rs` | Terminal setup, including the kitty keyboard protocol where supported | +| `src/cli.rs` | Command-line arguments and `orrery names` | + +## 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. +- **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. +- **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). + +## Next + +1. System flight and asteroid mining +2. Glue: galaxy map, ports, a market to sell salvage, scholars to identify + artifacts, quest templates diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..c5cfaaf --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +max_width = 100 +use_small_heuristics = "Max" +# Keep the name lists packed several to a line instead of one per line. +short_array_element_width_threshold = 24 diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..e3fe72a --- /dev/null +++ b/src/app.rs @@ -0,0 +1,110 @@ +//! The game loop and the scene stack. + +use std::io; +use std::time::{Duration, Instant}; + +use ratatui::DefaultTerminal; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; + +use crate::names::Artifact; +use crate::scenes::{Scene, Transition, title::Title}; +use crate::term::{self, KeyboardCaps}; + +/// Simulation rate. Real-time scenes (flight) move by this many steps a +/// second; turn-based scenes (dungeons) can ignore ticks and react to keys. +pub const TICKS_PER_SECOND: u32 = 30; +const TICK: Duration = Duration::from_nanos(1_000_000_000 / TICKS_PER_SECOND as u64); +/// 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; + +/// State every scene can see. +pub struct Ctx { + /// The universe seed. Everything procedural derives from it (see `seed`). + pub seed: u64, + /// Ticks since the game started. + pub ticks: u64, + pub keyboard: KeyboardCaps, + /// How many derelicts you've boarded. Each boarding is a different ship, + /// `derive(seed, "derelict", n)`. + pub boarded: u64, + pub hold: Hold, +} + +impl Ctx { + pub fn new(seed: u64, keyboard: KeyboardCaps) -> Ctx { + Ctx { seed, ticks: 0, keyboard, boarded: 0, hold: Hold::default() } + } +} + +/// What you've brought back from expeditions. +#[derive(Default)] +pub struct Hold { + pub artifacts: Vec<Artifact>, + /// Salvage, counted by what it'll sell for. + pub scrip: u32, +} + +pub fn run(seed: u64) -> io::Result<()> { + let (mut terminal, keyboard) = term::init()?; + let ctx = Ctx::new(seed, keyboard); + let result = run_loop(&mut terminal, ctx); + 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))]; + let mut next_tick = Instant::now() + TICK; + + while let Some(top) = stack.last() { + 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. + let mut timeout = next_tick.saturating_duration_since(Instant::now()); + while event::poll(timeout)? { + timeout = Duration::ZERO; + let Event::Key(key) = event::read()? else { + continue; + }; + 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 { + continue; + } + let Some(top) = stack.last_mut() else { break }; + let transition = top.key(key, &mut ctx); + apply(&mut stack, transition); + } + + 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); + next_tick += TICK; + caught_up += 1; + if caught_up == MAX_CATCH_UP { + next_tick = Instant::now() + TICK; + } + } + } + Ok(()) +} + +fn apply(stack: &mut Vec<Box<dyn Scene>>, transition: Transition) { + match transition { + Transition::None => {} + Transition::Push(scene) => stack.push(scene), + Transition::Pop => { + stack.pop(); + } + Transition::Quit => stack.clear(), + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..70e9156 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,156 @@ +//! Command-line arguments, `orrery names` and `orrery ship`. + +use crate::dungeon::Pos; +use crate::dungeon::crawl::Crawl; +use crate::names::{self, Culture}; +use crate::scenes::derelict::{item_look, tile_look}; +use crate::seed; + +pub const USAGE: &str = "\ +usage: orrery [--seed N] + orrery names [--culture NAME] [-n COUNT] [--seed N] + orrery ship [-n WHICH] [--seed N] + + --seed N universe seed; the same seed always makes the same 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) +"; + +pub enum Command { + Play { seed: Option<u64> }, + Names(NamesOpts), + Ship { seed: u64, which: u64 }, + Help, +} + +pub struct NamesOpts { + pub cultures: Vec<Culture>, + pub count: usize, + pub seed: u64, +} + +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); + 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")?), + "--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:?}"))?); + } + other => return Err(format!("unexpected argument {other:?}")), + } + } + match sub { + None => Ok(Command::Play { seed }), + Some("ship") => Ok(Command::Ship { + seed: seed.unwrap_or_else(seed::from_clock), + which: count.unwrap_or(1), + }), + _ => Ok(Command::Names(NamesOpts { + cultures: culture.map_or(Culture::ALL.to_vec(), |c| vec![c]), + count: count.unwrap_or(8) as usize, + seed: seed.unwrap_or_else(seed::from_clock), + })), + } +} + +/// Prints the `which`th derelict you'd board in universe `seed`, everything +/// on it revealed. +pub fn print_ship(seed: u64, which: u64) { + let crawl = Crawl::new(seed::derive(seed, "derelict", which)); + let map = &crawl.map; + println!( + "{}, a derelict {} (universe {seed}, derelict {which})\n", + crawl.name, crawl.description + ); + for y in 0..map.height { + let row: String = (0..map.width) + .map(|x| { + let p = Pos::new(x, y); + if p == crawl.player { + '@' + } 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 + } else { + tile_look(map.tile(p)).0 + } + }) + .collect(); + println!("{}", row.trim_end()); + } + 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!( + "monsters: {}", + crawl.monsters.iter().map(|m| m.kind.name).collect::<Vec<_>>().join(", ") + ); + println!("rooms: {}", rooms.join(", ")); +} + +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")) +} + +pub fn print_names(opts: &NamesOpts) { + println!("universe {} (pass --seed {} to get these again)", opts.seed, opts.seed); + for &culture in &opts.cultures { + // Each culture draws from its own seed, so `--culture` alone prints + // the same names as that culture's section of the full list. + let mut rng = seed::rng(seed::derive(opts.seed, culture.name(), 0)); + let people: Vec<_> = (0..opts.count).map(|_| culture.person(&mut rng)).collect(); + let places: Vec<_> = (0..opts.count).map(|_| culture.place(&mut rng)).collect(); + println!("\n{}: {}", culture.name().to_uppercase(), culture.blurb()); + println!(" {:<36}places", "people"); + for (person, place) in people.iter().zip(&places) { + println!(" {person:<36}{place}"); + } + } + if opts.cultures.len() == Culture::ALL.len() { + let mut rng = seed::rng(seed::derive(opts.seed, "artifacts", 0)); + println!("\nARTIFACTS: as found -> once identified"); + for _ in 0..opts.count { + let a = names::artifact(&mut rng); + println!(" {:<40}{}", a.unidentified, a.true_name); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(s: &str) -> Vec<String> { + s.split_whitespace().map(String::from).collect() + } + + #[test] + fn parses_play_and_names() { + assert!(matches!(parse(&args("")), Ok(Command::Play { seed: None }))); + assert!(matches!(parse(&args("--seed 42")), Ok(Command::Play { seed: Some(42) }))); + let Ok(Command::Names(opts)) = parse(&args("names --culture drift -n 3 --seed 9")) else { + panic!("names didn't parse"); + }; + assert_eq!((opts.cultures, opts.count, opts.seed), (vec![Culture::Drift], 3, 9)); + } + + #[test] + fn rejects_names_flags_when_playing() { + assert!(parse(&args("-n 3")).is_err()); + assert!(parse(&args("--culture cant")).is_err()); + assert!(parse(&args("names --culture nobody")).is_err()); + assert!(parse(&args("--seed lots")).is_err()); + } +} diff --git a/src/dungeon/crawl.rs b/src/dungeon/crawl.rs new file mode 100644 index 0000000..c5196b9 --- /dev/null +++ b/src/dungeon/crawl.rs @@ -0,0 +1,416 @@ +//! 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. + +use rand::RngExt; + +use super::fov::{self, Fov}; +use super::ship; +use super::{Map, Pos, Tile}; +use crate::names::Artifact; +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; +const LOG_LENGTH: usize = 50; + +pub struct MonsterKind { + pub name: &'static str, + pub glyph: char, + pub rgb: (u8, u8, u8), + pub hp: i32, + pub damage: (i32, i32), + /// As in "the rust-mite bites you". + pub verb: &'static str, + /// How common it is, relative to the others. + pub weight: u32, + /// At most one aboard any ship. + pub unique: bool, +} + +#[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 }, +]; + +pub struct Monster { + pub kind: &'static MonsterKind, + pub pos: Pos, + pub hp: i32, + /// Dormant until it comes into view; then it hunts you. + pub awake: bool, +} + +pub enum Item { + Artifact(Artifact), + Salvage { name: &'static str, value: u32 }, + Medpatch, +} + +pub struct Loot { + pub pos: Pos, + pub item: Item, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Outcome { + /// Back through the airlock, with everything carried. + Escaped, + Died { + killer: &'static str, + }, +} + +#[derive(Clone, Copy, Debug)] +pub enum Action { + Move(i32, i32), + Wait, + UseMedpatch, +} + +pub struct Crawl { + /// "the Vexant". + pub name: String, + /// "Cant hauler". + pub description: String, + pub map: Map, + pub player: Pos, + pub hp: i32, + 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, + 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>, + pub turns: u32, + pub outcome: Option<Outcome>, + /// 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, +} + +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 { + fov: fov::compute(&ship.map, ship.start, SIGHT_RADIUS), + name: ship.name, + description: ship.description, + map: ship.map, + player: ship.start, + hp: PLAYER_HP, + max_hp: PLAYER_HP, + monsters: ship.monsters, + loot: ship.loot, + artifacts: Vec::new(), + scrip: 0, + medpatches: 0, + seen: vec![false; cells], + log: Vec::new(), + turns: 0, + outcome: None, + rng: seed::rng(seed::derive(seed, "dice", 0)), + }; + crawl.say(format!("You board {}, a derelict {}.", crawl.name, crawl.description)); + crawl.look(); + 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) + } + + pub fn loot_at(&self, p: Pos) -> Option<&Loot> { + self.loot.iter().find(|l| l.pos == p) + } + + /// 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) + } + + /// 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; + } + let took_time = match action { + Action::Wait => true, + Action::Move(dx, dy) => self.step(dx, dy), + Action::UseMedpatch => self.use_medpatch(), + }; + if !took_time || self.outcome.is_some() { + return; + } + self.turns += 1; + self.monsters_act(); + self.look(); + } + + fn say(&mut self, message: String) { + self.log.push(message); + if self.log.len() > LOG_LENGTH { + self.log.remove(0); + } + } + + /// Recomputes what you can see, remembers it, and wakes anything that's + /// now in view (and so, by symmetry, can see you). + fn look(&mut self) { + self.fov = fov::compute(&self.map, self.player, SIGHT_RADIUS); + for p in self.map.positions() { + if self.fov.contains(p) { + let i = self.map.index(p); + self.seen[i] = true; + } + } + let mut woken = Vec::new(); + for m in &mut self.monsters { + if !m.awake && self.fov.contains(m.pos) { + m.awake = true; + woken.push(m.kind.name); + } + } + for name in woken { + self.say(format!("A {name} stirs and turns toward you.")); + } + } + + 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); + return true; + } + match self.map.tile(target) { + Tile::Door { open: false } => { + self.map.set(target, Tile::Door { open: true }); + self.say("The door grinds open.".into()); + true + } + Tile::Airlock => { + self.outcome = Some(Outcome::Escaped); + self.say("You cycle the airlock and cast off.".into()); + true + } + tile if tile.walkable() => { + self.player = target; + self.pick_up(); + 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()); + } + } + } + } + + fn use_medpatch(&mut self) -> bool { + if self.medpatches == 0 { + self.say("You have no medpatches.".into()); + return false; + } + if self.hp == self.max_hp { + self.say("You're not hurt.".into()); + return false; + } + self.medpatches -= 1; + let healed = MEDPATCH_HEAL.min(self.max_hp - self.hp); + self.hp += healed; + self.say(format!("The medpatch stings, then numbs (+{healed}).")); + true + } + + fn attack(&mut self, i: usize) { + let name = self.monsters[i].kind.name; + self.monsters[i].awake = true; + if !self.rng.random_bool(PLAYER_HIT) { + self.say(format!("Your torch misses the {name}.")); + 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.")); + } else { + self.say(format!("You burn the {name} ({damage}).")); + } + } + + /// 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. + 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 { + continue; + } + if monster.pos.steps_to(self.player) == 1 { + self.monster_attacks(i); + 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; + } + } + } + } + + fn monster_attacks(&mut self, i: usize) { + let kind = self.monsters[i].kind; + if !self.rng.random_bool(MONSTER_HIT) { + self.say(format!("The {} misses you.", kind.name)); + return; + } + let damage = self.rng.random_range(kind.damage.0..=kind.damage.1); + 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 }); + self.say(format!("You die aboard {}.", self.name)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A bot that wanders at random, for shaking out panics and broken rules. + fn wander(crawl: &mut Crawl, rng: &mut Rng, turns: usize) { + const ACTIONS: [Action; 10] = [ + Action::Move(-1, -1), + Action::Move(0, -1), + Action::Move(1, -1), + Action::Move(-1, 0), + Action::Move(1, 0), + Action::Move(-1, 1), + Action::Move(0, 1), + Action::Move(1, 1), + Action::Wait, + Action::UseMedpatch, + ]; + for _ in 0..turns { + crawl.act(ACTIONS[rng.random_range(..ACTIONS.len())]); + } + } + + #[test] + fn random_play_keeps_the_rules() { + for seed in 0..40 { + let mut crawl = Crawl::new(seed); + wander(&mut crawl, &mut seed::rng(seed), 600); + 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!(crawl.map.tile(m.pos).walkable(), "seed {seed}: {} in a wall", m.kind.name); + assert_ne!(m.pos, crawl.player); + } + if crawl.outcome.is_none() { + assert!(crawl.fov.contains(crawl.player)); + } + } + } + + #[test] + fn same_seed_same_moves_same_story() { + let play = || { + let mut crawl = Crawl::new(7); + wander(&mut crawl, &mut seed::rng(70), 300); + crawl.log + }; + assert_eq!(play(), play()); + } + + #[test] + fn the_airlock_takes_you_home() { + let mut crawl = Crawl::new(3); + let airlock = crawl.player.neighbours().find(|&n| crawl.map.tile(n) == Tile::Airlock); + let airlock = airlock.expect("the start is beside the airlock"); + let (dx, dy) = (airlock.x - crawl.player.x, airlock.y - crawl.player.y); + crawl.act(Action::Move(dx, dy)); + assert_eq!(crawl.outcome, Some(Outcome::Escaped)); + } + + #[test] + fn fighting_kills_things() { + 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 (dx, dy) = (pos.x - crawl.player.x, pos.y - crawl.player.y); + for _ in 0..50 { + crawl.act(Action::Move(dx, dy)); + if crawl.monsters.is_empty() { + break; + } + } + assert!(crawl.monsters.is_empty(), "a rust-mite survived 50 swings"); + assert!(crawl.log.iter().any(|m| m.contains("slag"))); + } + + #[test] + fn walking_into_walls_takes_no_time() { + let mut crawl = Crawl::new(11); + let wall = crawl + .player + .neighbours() + .find(|&n| matches!(crawl.map.tile(n), Tile::Wall | Tile::Hull)); + let wall = wall.unwrap(); + crawl.act(Action::Move(wall.x - crawl.player.x, wall.y - crawl.player.y)); + assert_eq!(crawl.turns, 0); + } +} diff --git a/src/dungeon/fov.rs b/src/dungeon/fov.rs new file mode 100644 index 0000000..81f8c07 --- /dev/null +++ b/src/dungeon/fov.rs @@ -0,0 +1,207 @@ +//! Field of view by symmetric shadowcasting, after Albert Ford's write-up at +//! <https://www.albertford.com/shadowcasting/>. +//! +//! Symmetric means that if you can see a floor cell, someone standing there +//! can see you. Monsters wake when they come into view, so this keeps +//! ambushes fair: nothing notices you that you couldn't have seen first. + +use super::{Map, Pos}; + +/// The cells visible from one spot. +pub struct Fov { + width: i32, + visible: Vec<bool>, +} + +impl Fov { + pub fn contains(&self, p: Pos) -> bool { + p.x >= 0 + && p.y >= 0 + && p.x < self.width + && self.visible.get((p.y * self.width + p.x) as usize).copied().unwrap_or(false) + } +} + +/// Everything visible from `origin` within `radius` cells. Walls are visible +/// (that's how you see a room's shape); cells behind them aren't. +pub fn compute(map: &Map, origin: Pos, radius: i32) -> Fov { + let mut fov = Fov { width: map.width, visible: vec![false; (map.width * map.height) as usize] }; + let mut reveal = |p: Pos| { + if map.contains(p) { + fov.visible[map.index(p)] = true; + } + }; + reveal(origin); + for quadrant in Quadrant::ALL { + let mut rows = vec![Row { depth: 1, start: Slope::new(-1, 1), end: Slope::new(1, 1) }]; + while let Some(mut row) = rows.pop() { + if row.depth > radius { + continue; + } + let mut prev_blocked = None; + for col in row.min_col()..=row.max_col() { + let p = quadrant.cell(origin, row.depth, col); + let blocked = map.tile(p).blocks_sight(); + let in_radius = col * col + row.depth * row.depth <= radius * radius + radius; + if in_radius && (blocked || row.is_symmetric(col)) { + reveal(p); + } + if prev_blocked == Some(true) && !blocked { + row.start = Slope::left_edge(row.depth, col); + } + if prev_blocked == Some(false) && blocked { + let mut next = row.next(); + next.end = Slope::left_edge(row.depth, col); + rows.push(next); + } + prev_blocked = Some(blocked); + } + if prev_blocked == Some(false) { + rows.push(row.next()); + } + } + } + fov +} + +#[derive(Clone, Copy)] +enum Quadrant { + North, + East, + South, + West, +} + +impl Quadrant { + const ALL: [Quadrant; 4] = [Quadrant::North, Quadrant::East, Quadrant::South, Quadrant::West]; + + /// The cell `depth` rows out from `origin` and `col` columns across. + fn cell(self, origin: Pos, depth: i32, col: i32) -> Pos { + match self { + Quadrant::North => origin.offset(col, -depth), + Quadrant::South => origin.offset(col, depth), + Quadrant::East => origin.offset(depth, col), + Quadrant::West => origin.offset(-depth, col), + } + } +} + +/// An exact fraction, so shadows land on the same cells every time. +#[derive(Clone, Copy)] +struct Slope { + num: i32, + /// Always positive. + den: i32, +} + +impl Slope { + const fn new(num: i32, den: i32) -> Slope { + Slope { num, den } + } + + /// The slope through the left edge of the cell at (`depth`, `col`). + const fn left_edge(depth: i32, col: i32) -> Slope { + Slope::new(2 * col - 1, 2 * depth) + } +} + +#[derive(Clone, Copy)] +struct Row { + depth: i32, + start: Slope, + end: Slope, +} + +impl Row { + /// `depth * start`, rounded with ties going up. + fn min_col(&self) -> i32 { + (2 * self.depth * self.start.num + self.start.den).div_euclid(2 * self.start.den) + } + + /// `depth * end`, rounded with ties going down. + fn max_col(&self) -> i32 { + -(self.end.den - 2 * self.depth * self.end.num).div_euclid(2 * self.end.den) + } + + /// Whether the centre of the cell at `col` is inside this row's slopes. + fn is_symmetric(&self, col: i32) -> bool { + col * self.start.den >= self.depth * self.start.num + && col * self.end.den <= self.depth * self.end.num + } + + fn next(&self) -> Row { + Row { depth: self.depth + 1, ..*self } + } +} + +#[cfg(test)] +mod tests { + use rand::RngExt; + + use super::*; + use crate::dungeon::Tile; + use crate::seed; + + fn map_from(rows: &[&str]) -> (Map, Pos) { + let mut map = Map::new(rows[0].len() as i32, rows.len() as i32, Tile::Floor); + let mut at = Pos::new(0, 0); + for (y, row) in rows.iter().enumerate() { + for (x, c) in row.chars().enumerate() { + let p = Pos::new(x as i32, y as i32); + match c { + '#' => map.set(p, Tile::Wall), + '@' => at = p, + _ => {} + } + } + } + (map, at) + } + + #[test] + fn walls_cast_shadows() { + let (map, at) = map_from(&[ + "#########", // + "#.......#", "#...@...#", "#..###..#", "#.......#", "#########", + ]); + let fov = compute(&map, at, 10); + assert!(fov.contains(Pos::new(1, 1)), "open floor is visible"); + assert!(fov.contains(Pos::new(4, 3)), "the wall itself is visible"); + assert!(!fov.contains(Pos::new(4, 4)), "the cell right behind the wall is hidden"); + assert!(fov.contains(Pos::new(1, 3)), "cells beside the wall stay visible"); + } + + #[test] + fn radius_limits_sight() { + let (map, _) = map_from(&[&".".repeat(30)]); + let fov = compute(&map, Pos::new(0, 0), 5); + assert!(fov.contains(Pos::new(5, 0))); + assert!(!fov.contains(Pos::new(6, 0))); + } + + /// The property the module promises: seeing is mutual between floor cells. + #[test] + fn sight_is_symmetric() { + let mut rng = seed::rng(4); + for _ in 0..20 { + let mut map = Map::new(24, 16, Tile::Floor); + for p in map.positions().collect::<Vec<_>>() { + if rng.random_bool(0.25) { + map.set(p, Tile::Wall); + } + } + let floors: Vec<Pos> = + map.positions().filter(|&p| map.tile(p) == Tile::Floor).collect(); + let views: Vec<Fov> = floors.iter().map(|&p| compute(&map, p, 8)).collect(); + for (i, &a) in floors.iter().enumerate() { + for (j, &b) in floors.iter().enumerate() { + assert_eq!( + views[i].contains(b), + views[j].contains(a), + "{a:?} and {b:?} disagree about seeing each other" + ); + } + } + } + } +} diff --git a/src/dungeon/mod.rs b/src/dungeon/mod.rs new file mode 100644 index 0000000..d3227a5 --- /dev/null +++ b/src/dungeon/mod.rs @@ -0,0 +1,156 @@ +//! Turn-based dungeons: the map, field of view, derelict-ship generation, +//! and the rules of a crawl. Drawing lives in `scenes::derelict`. + +pub mod crawl; +pub mod fov; +pub mod ship; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Pos { + pub x: i32, + pub y: i32, +} + +impl Pos { + pub const fn new(x: i32, y: i32) -> Pos { + Pos { x, y } + } + + pub const fn offset(self, dx: i32, dy: i32) -> Pos { + Pos::new(self.x + dx, self.y + dy) + } + + /// Steps from here to `other` when diagonal moves are allowed. + pub fn steps_to(self, other: Pos) -> i32 { + (self.x - other.x).abs().max((self.y - other.y).abs()) + } + + /// The eight cells around this one. + pub fn neighbours(self) -> impl Iterator<Item = Pos> { + const DIRS: [(i32, i32); 8] = + [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]; + DIRS.into_iter().map(move |(dx, dy)| self.offset(dx, dy)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tile { + /// Outside the ship. + Void, + /// The outer skin of the ship. + Hull, + /// An inside wall. + Wall, + Floor, + /// Floor strewn with wreckage. Walkable; it just looks different. + Debris, + Door { + open: bool, + }, + /// The way out: the airlock your own ship is docked to. + Airlock, +} + +impl Tile { + /// Whether you can step onto it. Closed doors open when bumped instead, + /// and stepping into the airlock leaves the ship. + pub fn walkable(self) -> bool { + matches!(self, Tile::Floor | Tile::Debris | Tile::Door { open: true }) + } + + pub fn blocks_sight(self) -> bool { + matches!( + self, + Tile::Void | Tile::Hull | Tile::Wall | Tile::Door { open: false } | Tile::Airlock + ) + } +} + +/// A named area of the map, shown to the player while they stand in it. +pub struct Room { + pub name: &'static str, +} + +pub struct Map { + pub width: i32, + pub height: i32, + tiles: Vec<Tile>, + room_of: Vec<Option<u16>>, + pub rooms: Vec<Room>, +} + +impl Map { + pub fn new(width: i32, height: i32, fill: Tile) -> Map { + let cells = (width * height) as usize; + Map { + width, + height, + tiles: vec![fill; cells], + room_of: vec![None; cells], + rooms: Vec::new(), + } + } + + pub fn contains(&self, p: Pos) -> bool { + p.x >= 0 && p.y >= 0 && p.x < self.width && p.y < self.height + } + + /// Index into a per-cell `Vec` laid out like the map. Panics off the map. + pub fn index(&self, p: Pos) -> usize { + assert!(self.contains(p), "{p:?} is off the map"); + (p.y * self.width + p.x) as usize + } + + /// The tile at `p`; everything off the map is `Void`. + pub fn tile(&self, p: Pos) -> Tile { + if self.contains(p) { self.tiles[self.index(p)] } else { Tile::Void } + } + + pub fn set(&mut self, p: Pos, tile: Tile) { + let i = self.index(p); + self.tiles[i] = tile; + } + + pub fn room_at(&self, p: Pos) -> Option<&Room> { + if !self.contains(p) { + return None; + } + self.room_of[self.index(p)].map(|r| &self.rooms[usize::from(r)]) + } + + pub fn add_room(&mut self, name: &'static str) -> u16 { + self.rooms.push(Room { name }); + (self.rooms.len() - 1) as u16 + } + + pub fn set_room(&mut self, p: Pos, room: Option<u16>) { + let i = self.index(p); + self.room_of[i] = room; + } + + pub fn positions(&self) -> impl Iterator<Item = Pos> + use<> { + let (w, h) = (self.width, self.height); + (0..h).flat_map(move |y| (0..w).map(move |x| Pos::new(x, y))) + } + + /// Steps from `from` to every cell reachable through walkable tiles and + /// closed doors, moving diagonally too; `None` where unreachable. + pub fn distances_from(&self, from: Pos) -> Vec<Option<u32>> { + let mut dist = vec![None; self.tiles.len()]; + let mut queue = std::collections::VecDeque::from([from]); + dist[self.index(from)] = Some(0); + while let Some(p) = queue.pop_front() { + let d = dist[self.index(p)].unwrap(); + for n in p.neighbours() { + let tile = self.tile(n); + if (tile.walkable() || tile == (Tile::Door { open: false })) + && dist[self.index(n)].is_none() + { + dist[self.index(n)] = Some(d + 1); + queue.push_back(n); + } + } + } + dist + } +} diff --git a/src/dungeon/ship.rs b/src/dungeon/ship.rs new file mode 100644 index 0000000..dc9b0b8 --- /dev/null +++ b/src/dungeon/ship.rs @@ -0,0 +1,452 @@ +//! Derelict ships. +//! +//! A derelict is a hull silhouette, mirrored top to bottom, lying along the +//! x axis with its engines at the left and its nose at the right. A spine +//! corridor runs down the middle from the engine room to the bridge, and the +//! space either side of it is cut into sections, each holding a room above +//! the spine and one below: +//! +//! ```text +//! ########## +//! #####.....#..####### +//! #...#.....#..+.....##### +//! #...###+#######+######..### +//! #.............................## +//! #...###+######+#######..### +//! #...#......#.......##### +//! ############ ###### +//! engine room middle bridge +//! ``` +//! +//! Every room has a door onto the spine (or onto the room between it and the +//! spine), so the whole ship is connected; a few extra doors between +//! neighbouring rooms make loops to run around. + +use rand::RngExt; +use rand::seq::IndexedRandom; + +use super::crawl::{Item, Loot, MONSTERS, Monster}; +use super::{Map, Pos, Tile}; +use crate::names::{self, Culture}; +use crate::seed::{self, Rng}; + +pub struct Derelict { + /// "the Vexant". + pub name: String, + /// "Cant hauler". + pub description: String, + pub map: Map, + /// Where the player comes aboard, just inside the airlock. + pub start: Pos, + pub monsters: Vec<Monster>, + pub loot: Vec<Loot>, +} + +struct RoomKind { + name: &'static str, + /// How often it turns up, relative to the other middle rooms. + weight: u32, + /// Chance of an artifact. + artifact: f64, + /// How many pieces of salvage: anywhere from .0 to .1. + salvage: (u32, u32), + /// Chance of a medpatch. + medpatch: 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 }; + +#[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 }, +]; + +/// 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), +]; + +/// A room as generated, before its contents are placed. +struct Plan { + kind: &'static RoomKind, + cells: Vec<Pos>, +} + +pub fn generate(seed: u64) -> Derelict { + let mut rng = seed::rng(seed); + let culture = Culture::ALL[rng.random_range(..Culture::ALL.len())]; + let name = format!("the {}", culture.word(&mut rng)); + let length: i32 = rng.random_range(44..=88); + let max_half: i32 = rng.random_range(5..=9); + let class = match length { + ..56 => "cutter", + 56..72 => "hauler", + _ if rng.random_bool(0.5) => "pilgrim-barge", + _ => "reliquary-ship", + }; + let description = format!("{} {class}", culture.adjective()); + + let hull = Hull::new(&mut rng, length, max_half); + let mut map = hull.map(); + let plans = carve_rooms(&mut map, &hull, &mut rng); + let start = place_airlock(&mut map, &hull, &mut rng); + + for p in map.positions().collect::<Vec<_>>() { + if map.tile(p) == Tile::Floor && p != start && rng.random_bool(0.06) { + map.set(p, Tile::Debris); + } + } + + // Everything is connected by construction, but if a hull's odd shape + // ever cuts a room in two, wall off the part that can't be reached + // rather than strand loot or monsters there. + let dist = map.distances_from(start); + let reachable = |map: &Map, p: Pos| dist[map.index(p)].is_some(); + for p in map.positions().collect::<Vec<_>>() { + let open = matches!(map.tile(p), Tile::Floor | Tile::Debris | Tile::Door { .. }); + if open && !reachable(&map, p) { + map.set(p, Tile::Wall); + map.set_room(p, None); + } + } + let free = |map: &Map, p: Pos| { + p != start && matches!(map.tile(p), Tile::Floor | Tile::Debris) && reachable(map, p) + }; + + let mut loot = Vec::new(); + 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; + if rng.random_bool(kind.artifact) { + let pos = *cells.choose(&mut rng).unwrap(); + loot.push(Loot { pos, item: 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 value = (f64::from(base) * rng.random_range(0.7..1.3)).round() as u32; + loot.push(Loot { pos, item: Item::Salvage { name, value } }); + } + let medpatches = if kind.medpatch >= 1.0 { + rng.random_range(1..=2) + } else { + u32::from(rng.random_bool(kind.medpatch)) + }; + for _ in 0..medpatches { + loot.push(Loot { pos: *cells.choose(&mut rng).unwrap(), item: 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(_))) { + 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)) }); + } + + let lairs: Vec<Pos> = map + .positions() + .filter(|&p| free(&map, p) && dist[map.index(p)].is_some_and(|d| d >= 7)) + .collect(); + let mut monsters: Vec<Monster> = Vec::new(); + for _ in 0..(length / 11 + rng.random_range(0..=2)) { + let Some(&pos) = lairs.choose(&mut rng) else { break }; + if monsters.iter().any(|m| m.pos == pos) { + continue; + } + let kind = MONSTERS.choose_weighted(&mut rng, |k| k.weight).unwrap(); + if kind.unique && monsters.iter().any(|m| m.kind.name == kind.name) { + continue; + } + monsters.push(Monster { kind, pos, hp: kind.hp, awake: false }); + } + + Derelict { name, description, map, start, monsters, loot } +} + +struct Hull { + /// Half-height of the hull at each x along the ship, stern first. + half: Vec<i32>, + /// How many columns of `half` are engine block, and how many nose. + engine: i32, + nose: i32, + max_half: i32, +} + +impl Hull { + fn new(rng: &mut Rng, length: i32, max_half: i32) -> Hull { + let engine = rng.random_range(4..=7); + let nose = (f64::from(length) * rng.random_range(0.2..0.32)) as i32; + let body = length - engine - nose; + + // A broad engine block, then a body of two to four blocky sections, + // each at least 4 high so the rooms either side of the spine fit. + let mut half = vec![rng.random_range(max_half - 2..=max_half); engine as usize]; + let sections = rng.random_range(2..=4); + for s in 0..sections { + let width = body * (s + 1) / sections - body * s / sections; + let h = rng.random_range(4.max(max_half * 3 / 5)..=max_half); + half.extend(std::iter::repeat_n(h, width as usize)); + } + // A nose tapering to a point; the exponent sets how blunt it is. + let last = f64::from(*half.last().unwrap()); + let bluntness = rng.random_range(1.0..2.5); + for i in 0..nose { + let t = f64::from(i + 1) / f64::from(nose); + half.push(((last * (1.0 - t.powf(bluntness))).round() as i32).max(1)); + } + Hull { half, engine, nose, max_half } + } + + fn length(&self) -> i32 { + self.half.len() as i32 + } + + /// The row the spine runs along. + fn spine(&self) -> i32 { + self.max_half + 1 + } + + /// Map x runs from 0 to length + 1, so there's a column of space at each end. + fn inside(&self, p: Pos) -> bool { + (1..=self.length()).contains(&p.x) + && (p.y - self.spine()).abs() <= self.half[(p.x - 1) as usize] + } + + /// Cells inside the hull become `Wall` (rooms are carved out of it + /// later); the edge of the inside becomes `Hull`. + fn map(&self) -> Map { + let mut map = Map::new(self.length() + 2, 2 * self.max_half + 3, Tile::Void); + for p in map.positions() { + if self.inside(p) { + let edge = p.neighbours().any(|n| !self.inside(n)); + map.set(p, if edge { Tile::Hull } else { Tile::Wall }); + } + } + map + } +} + +fn carve(map: &mut Map, p: Pos, room: u16) { + if map.tile(p) == Tile::Wall { + map.set(p, Tile::Floor); + map.set_room(p, Some(room)); + } +} + +fn door(map: &mut Map, p: Pos, rng: &mut Rng) { + // A quarter of doors were left open, or jammed that way. + map.set(p, Tile::Door { open: rng.random_bool(0.25) }); +} + +fn carve_rooms(map: &mut Map, hull: &Hull, rng: &mut Rng) -> Vec<Plan> { + let (length, cy) = (hull.length(), hull.spine()); + let mut plans = Vec::new(); + + // The engine room and bridge span the ship's full height, so the spine + // runs straight into them. Carve them first so the spine's cells there + // belong to them (carving only turns walls into floor). + let engine_end = hull.engine; + let bridge_start = length - hull.nose + 1; + for (kind, xs) in [(&ENGINE_ROOM, 2..=engine_end), (&BRIDGE, bridge_start..=length - 1)] { + let room = map.add_room(kind.name); + let cells: Vec<Pos> = + map.positions().filter(|p| xs.contains(&p.x) && map.tile(*p) == Tile::Wall).collect(); + for &p in &cells { + carve(map, p, room); + } + plans.push(Plan { kind, cells }); + } + let spine = map.add_room("the spine corridor"); + for x in 2..length { + carve(map, Pos::new(x, cy), spine); + } + + // Cut the middle into sections 3-8 wide, with a wall column between each. + let (lo, hi) = (engine_end + 2, bridge_start - 2); + let mut sections = Vec::new(); + let mut x = lo; + while x <= hi { + let mut end = (x + rng.random_range(2..=7)).min(hi); + if hi - end < 4 { + end = hi; + } + sections.push((x, end)); + x = end + 2; + } + + for &(sa, sb) in §ions { + for side in [-1, 1] { + // `depth` counts rows out from the spine; row 1 is the wall + // between spine and rooms. + let at = |x: i32, depth: i32| Pos::new(x, cy + side * depth); + let cells: Vec<(Pos, i32)> = (sa..=sb) + .flat_map(|x| (2..=hull.max_half).map(move |d| (at(x, d), d))) + .filter(|&(p, _)| map.tile(p) == Tile::Wall) + .collect(); + let door_xs: Vec<i32> = + (sa..=sb).filter(|&x| map.tile(at(x, 2)) == Tile::Wall).collect(); + if cells.len() < 4 || door_xs.is_empty() { + continue; + } + + // Deep enough sections sometimes hold two rooms, one behind the + // other, with a wall and a door between them. + let deepest = cells.iter().map(|&(_, d)| d).max().unwrap(); + let mut split = None; + if deepest >= 6 && rng.random_bool(0.5) { + let s = rng.random_range(4..=deepest - 2); + let between: Vec<i32> = (sa..=sb) + .filter(|&x| { + map.tile(at(x, s - 1)) == Tile::Wall && map.tile(at(x, s + 1)) == Tile::Wall + }) + .collect(); + if let Some(&x) = between.choose(rng) { + split = Some((s, x)); + } + } + + let rooms: Vec<Vec<Pos>> = match split { + None => vec![cells.iter().map(|&(p, _)| p).collect()], + Some((s, _)) => vec![ + cells.iter().filter(|&&(_, d)| d < s).map(|&(p, _)| p).collect(), + cells.iter().filter(|&&(_, d)| d > s).map(|&(p, _)| p).collect(), + ], + }; + for room_cells in rooms { + let kind = ROOMS.choose_weighted(rng, |k| k.weight).unwrap(); + let room = map.add_room(kind.name); + for &p in &room_cells { + carve(map, p, room); + } + plans.push(Plan { kind, cells: room_cells }); + } + if let Some((s, x)) = split { + door(map, at(x, s), rng); + } + door(map, at(*door_xs.choose(rng).unwrap(), 1), rng); + } + } + + // Now and then, a door through the wall column between neighbouring + // rooms, so there's more than one way around. + let walls = std::iter::once(engine_end + 1).chain(sections.iter().map(|&(_, sb)| sb + 1)); + for x in walls.collect::<Vec<_>>() { + for side in [-1, 1] { + let spots: Vec<Pos> = (2..=hull.max_half) + .map(|d| Pos::new(x, cy + side * d)) + .filter(|&p| { + map.tile(p) == Tile::Wall + && map.tile(p.offset(-1, 0)) == Tile::Floor + && map.tile(p.offset(1, 0)) == Tile::Floor + }) + .collect(); + if !spots.is_empty() && rng.random_bool(0.3) { + door(map, *spots.choose(rng).unwrap(), rng); + } + } + } + plans +} + +/// Puts the airlock on a flat stretch of outer hull beside a middle room and +/// returns the cell just inside it. Falls back to the stern, where the spine +/// always meets the hull. +fn place_airlock(map: &mut Map, hull: &Hull, rng: &mut Rng) -> Pos { + let cy = hull.spine(); + let middle = hull.engine + 2..=hull.length() - hull.nose - 1; + let spots: Vec<(Pos, Pos)> = map + .positions() + .filter_map(|p| { + let side = (p.y - cy).signum(); + let inward = p.offset(0, -side); + let flat = + map.tile(p.offset(-1, 0)) == Tile::Hull && map.tile(p.offset(1, 0)) == Tile::Hull; + (side != 0 + && middle.contains(&p.x) + && map.tile(p) == Tile::Hull + && map.tile(p.offset(0, side)) == Tile::Void + && map.tile(inward) == Tile::Floor + && flat) + .then_some((p, inward)) + }) + .collect(); + let (airlock, start) = spots.choose(rng).copied().unwrap_or((Pos::new(1, cy), Pos::new(2, cy))); + map.set(airlock, Tile::Airlock); + start +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derelicts_are_sound() { + for seed in 0..300 { + let d = generate(seed); + let map = &d.map; + assert!(map.width <= 90 && map.height <= 21, "seed {seed}: too big"); + assert_eq!(map.tile(d.start), Tile::Floor, "seed {seed}: start isn't floor"); + assert!( + d.start.neighbours().any(|n| map.tile(n) == Tile::Airlock), + "seed {seed}: no airlock beside the start" + ); + + let dist = map.distances_from(d.start); + for p in map.positions() { + if map.tile(p).walkable() || matches!(map.tile(p), Tile::Door { .. }) { + assert!(dist[map.index(p)].is_some(), "seed {seed}: {p:?} is unreachable"); + } + } + for m in &d.monsters { + assert!(map.tile(m.pos).walkable(), "seed {seed}: monster in a wall"); + assert!(dist[map.index(m.pos)] >= Some(7), "seed {seed}: monster too near start"); + } + for l in &d.loot { + 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!(map.rooms.len() >= 4, "seed {seed}: only {} rooms", map.rooms.len()); + assert!(d.monsters.iter().filter(|m| m.kind.unique).count() <= 1); + } + } + + #[test] + fn same_seed_same_ship() { + let (a, b) = (generate(99), generate(99)); + assert_eq!(a.name, b.name); + assert_eq!(a.start, b.start); + assert!(a.map.positions().all(|p| a.map.tile(p) == b.map.tile(p))); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e2b74d3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,43 @@ +//! Orrery: an ASCII sci-fi-fantasy game. +//! +//! `orrery` plays; `orrery names` and `orrery ship` print what the +//! generators make, without the TUI. + +mod app; +mod cli; +mod dungeon; +mod names; +mod scenes; +mod seed; +mod term; + +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(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("orrery: {e}"); + ExitCode::FAILURE + } + }, + Ok(cli::Command::Names(opts)) => { + cli::print_names(&opts); + ExitCode::SUCCESS + } + Ok(cli::Command::Ship { seed, which }) => { + cli::print_ship(seed, which); + ExitCode::SUCCESS + } + Ok(cli::Command::Help) => { + print!("{}", cli::USAGE); + ExitCode::SUCCESS + } + Err(msg) => { + eprintln!("orrery: {msg}\n\n{}", cli::USAGE); + ExitCode::from(2) + } + } +} diff --git a/src/names/artifact.rs b/src/names/artifact.rs new file mode 100644 index 0000000..af24b3d --- /dev/null +++ b/src/names/artifact.rs @@ -0,0 +1,69 @@ +//! Artifact names: what a thing looks like when you find it, and what it's +//! called once a scholar has told you what it is. + +use rand::RngExt; + +use super::{Culture, pick}; +use crate::seed::Rng; + +#[derive(Clone, Debug)] +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, +} + +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 object = pick(OBJECTS, rng); + let culture = Culture::ALL[rng.random_range(..Culture::ALL.len())]; + let true_name = match rng.random_range(0..5) { + 0 => format!("the {} {object} of {}", pick(MATERIALS, rng), culture.word(rng)), + 1 => format!("the {object} of {}", culture.place(rng)), + 2 => format!("{}'s {object}", culture.word(rng)), + 3 => format!("the {} {object}", pick(EPITHETS, rng)), + _ => format!("the {object} {}", pick(CLAUSES, rng)), + }; + Artifact { unidentified, true_name } +} + +const QUALITIES: &[&str] = &[ + "humming", "warm", "weightless", "faintly singing", "ice-cold", "flickering", "whispering", + "slowly turning", "perfectly still", "pulsing", "dust-caked", "oddly heavy", "tarnished", + "ticking", "breathing", +]; + +const COLOURS: &[&str] = &[ + "ochre", "verdigris", "bone-white", "umber", "cerulean", "oxblood", "iridescent", "smoke-grey", + "jet-black", "saffron", "rust-red", "pearlescent", "cobalt", +]; + +const SHAPES: &[&str] = &[ + "lattice", "spindle", "disc", "knot", "sphere", "prism", "coil", "shard", "ring", "cube", + "cylinder", "helix", "lens", "rod", "tessera", +]; + +const MATERIALS: &[&str] = &[ + "Saltglass", "Black-Iron", "Lacquered", "Bone", "Star-Ivory", "Verdigris", "Voidsteel", + "Smoked-Glass", "Amber", "Obsidian", "Gilt", "Quicksilver", +]; + +const OBJECTS: &[&str] = &[ + "Orrery", "Reliquary", "Astrolabe", "Censer", "Lattice", "Mask", "Crown", "Key", "Sigil", + "Codex", "Lens", "Spindle", "Compass", "Chalice", "Engine", "Bell", "Heart", "Mirror", "Loom", +]; + +const EPITHETS: &[&str] = &[ + "Weeping", "Ninth", "Hollow", "Unspoken", "Last", "Sleeping", "Burning", "Patient", "Seventh", + "Silent", "Drowned", "Unfinished", "Forgotten", "Inverted", +]; + +const CLAUSES: &[&str] = &[ + "That Remembers", "That Was Promised", "Which Does Not Turn", "That Sings at Dusk", + "That Counts the Dead", "That Opens Inward", "That Waits", "of Unmaking", "of Seven Silences", + "Beneath the Sun", +]; diff --git a/src/names/mod.rs b/src/names/mod.rs new file mode 100644 index 0000000..719455f --- /dev/null +++ b/src/names/mod.rs @@ -0,0 +1,319 @@ +//! Name generation. +//! +//! Each culture has its own syllables, so names from one region sound related +//! to each other and different from the next region's. A word is a start +//! syllable, maybe some middles, and an end; templates such as `"{w} ul-{w}"` +//! turn words into people and places. +//! +//! The lists are meant to be tuned by ear: edit them, then run +//! `cargo run -- names --culture qaari -n 40` to hear the result. +//! +//! Within a culture, syllables are written so that every join works: in most +//! cultures starts and middles end in a vowel and middles and ends begin with +//! a consonant, so they always meet vowel-to-consonant. `pronounceable` throws +//! out whatever still comes out badly. + +mod artifact; + +pub use artifact::{Artifact, artifact}; + +use std::fmt::Write as _; + +use rand::RngExt; +use rand::seq::IndexedRandom; + +use crate::seed::Rng; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Culture { + Qaari, + Cant, + Houses, + Drift, +} + +struct Phonology { + starts: &'static [&'static str], + middles: &'static [&'static str], + ends: &'static [&'static str], + /// How many middle syllables a word gets: anywhere from .0 to .1. + middles_per_word: (u32, u32), + /// Templates for people. `{w}` is a new word, `{n}` a number from 2 to + /// 99, `{r}` a Roman numeral. Repeat a template to make it more common. + people: &'static [&'static str], + /// Templates for places, filled in the same way. + places: &'static [&'static str], +} + +const QAARI: Phonology = Phonology { + starts: &[ + "qa", "kha", "sha", "za", "ta", "ha", "ya", "ma", "i", "u", "a", "su", "ze", "ru", "khe", + "qe", "na", "ba", + ], + middles: &[ + "'a", "'i", "'u", "ssa", "ra", "lu", "shi", "the", "ka", "ma", "zi", "rru", "da", "hi", + ], + ends: &[ + "run", "vel", "seem", "taq", "rim", "zar", "dul", "keth", "mah", "resh", "noun", "sir", + "qal", "riya", "'at", "'im", "n", "r", "sh", "dah", "zim", + ], + middles_per_word: (0, 1), + people: &["{w} {w}", "{w} ul-{w}", "{w} ul-{w}", "{w} of the {w} Erg"], + places: &["{w}", "{w}", "{w} {w}", "Erg {w}", "the {w} Wells", "{w} Hollow"], +}; + +// The Cant joins the other way round: starts end in a consonant, ends begin +// with a vowel. That's where the hard stops come from. +const CANT: Phonology = Phonology { + starts: &[ + "kor", "vex", "drak", "tor", "brann", "ix", "zed", "kal", "grim", "stak", "vor", "dex", + "kryn", "thul", "garr", "oct", "rhun", + ], + middles: &["ax", "en", "ul", "ir", "ot", "ek"], + ends: &["ex", "on", "ax", "ik", "us", "ant", "ek", "ost", "ar", "ion", "um", "a", "o", "is"], + middles_per_word: (0, 1), + people: &["{w}-{n}", "{w}-{n}", "Cantor {w}-{n}", "{w} {w}", "{w} {r}", "Fabricant {w}"], + places: &["{w}-{n}", "{w} Spire", "Foundry {w}", "{w} {r}", "the {w} Engine"], +}; + +const HOUSES: Phonology = Phonology { + starts: &[ + "va", "so", "i", "ca", "me", "au", "ve", "o", "se", "the", "ly", "ama", "eu", "ari", "ce", + "di", "lu", + ], + middles: &["ste", "ri", "la", "mo", "ce", "ve", "ra", "the", "li", "ssa", "da"], + ends: &[ + "mond", "stre", "var", "rion", "nelle", "nard", "rine", "lis", "thane", "sande", "vere", + "lior", "dane", "rath", "ssandre", "mire", + ], + middles_per_word: (0, 1), + people: &["{w} {w}", "{w} {w}", "{w} of House {w}", "Archon {w} {w}", "{w} {w} the Younger"], + places: &["{w}", "{w}'s Reach", "{w} Prime", "New {w}", "{w} {w}", "the {w} Gardens"], +}; + +const DRIFT: Phonology = Phonology { + starts: &["nu", "o", "y", "ve", "li", "sa", "e", "mi", "no", "u", "ae", "ilo", "wa", "si"], + middles: &["va", "lo", "so", "ne", "lla", "sse", "ri", "wa", "le"], + ends: &[ + "nn", "ss", "l", "lei", "ra", "vael", "th", "na", "ssa", "ren", "lue", "wyn", "s", "lith", + "mae", + ], + middles_per_word: (0, 1), + people: &["{w}", "{w}", "{w} {w}", "{w} of the {w}"], + places: &["{w}", "the {w} Drift", "{w} Anchorage", "{w} Deep"], +}; + +const ROMAN: &[&str] = &["II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"]; + +impl Culture { + pub const ALL: [Culture; 4] = [Culture::Qaari, Culture::Cant, Culture::Houses, Culture::Drift]; + + pub fn name(self) -> &'static str { + match self { + Culture::Qaari => "the Qaari", + Culture::Cant => "the Cant", + Culture::Houses => "the High Houses", + Culture::Drift => "the Drift", + } + } + + /// For describing things: "a Cant hauler", "a High House cutter". + pub fn adjective(self) -> &'static str { + match self { + Culture::Qaari => "Qaari", + Culture::Cant => "Cant", + Culture::Houses => "High House", + Culture::Drift => "Drift", + } + } + + pub fn blurb(self) -> &'static str { + match self { + Culture::Qaari => "dune-sea clans: guttural, sun-cracked, apostrophes", + Culture::Cant => "derelict machine-cult: hard stops, serial numbers", + Culture::Houses => "old nobility: ornate, liquid, Latinate", + Culture::Drift => "void-born spacers: soft, breathy, doubled letters", + } + } + + /// Accepts the short names the CLI uses: qaari, cant, houses, drift. + pub fn parse(s: &str) -> Option<Culture> { + match s.to_ascii_lowercase().as_str() { + "qaari" => Some(Culture::Qaari), + "cant" => Some(Culture::Cant), + "houses" => Some(Culture::Houses), + "drift" => Some(Culture::Drift), + _ => None, + } + } + + fn phonology(self) -> &'static Phonology { + match self { + Culture::Qaari => &QAARI, + Culture::Cant => &CANT, + Culture::Houses => &HOUSES, + Culture::Drift => &DRIFT, + } + } + + /// One capitalised word: "Khaseem", "Drakulon", "Ithane", "Nuvael". + pub fn word(self, rng: &mut Rng) -> String { + let mut candidate = String::new(); + for _ in 0..100 { + candidate = self.syllables(rng).concat(); + if pronounceable(&candidate) { + break; + } + } + capitalise(&candidate) + } + + /// Picks a word's syllables, avoiding stutters like "the-the" or "qa-qal" + /// (a syllable that opens with the same two letters as the one before). + fn syllables(self, rng: &mut Rng) -> Vec<&'static str> { + let p = self.phonology(); + let (min, max) = p.middles_per_word; + let middles = rng.random_range(min..=max) as usize; + let mut parts = vec![pick(p.starts, rng)]; + for i in 0..=middles { + let list = if i < middles { p.middles } else { p.ends }; + let prev = parts[parts.len() - 1]; + let mut next = pick(list, rng); + for _ in 0..10 { + if prev.len() < 2 || !next.starts_with(&prev[..2]) { + break; + } + next = pick(list, rng); + } + parts.push(next); + } + parts + } + + pub fn person(self, rng: &mut Rng) -> String { + self.expand(pick(self.phonology().people, rng), rng) + } + + pub fn place(self, rng: &mut Rng) -> String { + self.expand(pick(self.phonology().places, rng), rng) + } + + fn expand(self, template: &str, rng: &mut Rng) -> String { + let mut out = String::new(); + let mut rest = template; + while let Some(open) = rest.find('{') { + out.push_str(&rest[..open]); + let close = open + rest[open..].find('}').expect("unclosed { in a name template"); + match &rest[open + 1..close] { + "w" => out.push_str(&self.word(rng)), + "n" => write!(out, "{}", rng.random_range(2..100)).unwrap(), + "r" => out.push_str(pick(ROMAN, rng)), + slot => panic!("unknown slot {{{slot}}} in name template {template:?}"), + } + rest = &rest[close + 1..]; + } + out.push_str(rest); + out + } +} + +pub(crate) fn pick(list: &'static [&'static str], rng: &mut Rng) -> &'static str { + list.choose(rng).expect("name lists are never empty") +} + +/// Rejects words that are too short, too long, or have clusters no one could +/// say. The limits are by ear; loosen them if a culture wants harsher words. +fn pronounceable(word: &str) -> bool { + let letters = word.chars().filter(|c| c.is_ascii_alphabetic()).count(); + if !(3..=11).contains(&letters) + || word.starts_with('\'') + || word.ends_with('\'') + || word.matches('\'').count() > 1 + { + return false; + } + let (mut vowels, mut consonants, mut same) = (0, 0, 0); + let mut prev = None; + for c in word.chars() { + same = if Some(c) == prev { same + 1 } else { 1 }; + prev = Some(c); + match c { + '\'' => (vowels, consonants) = (0, 0), + 'a' | 'e' | 'i' | 'o' | 'u' | 'y' => (vowels, consonants) = (vowels + 1, 0), + _ => (vowels, consonants) = (0, consonants + 1), + } + if vowels > 2 || consonants > 3 || same > 2 { + return false; + } + } + true +} + +fn capitalise(word: &str) -> String { + let mut chars = word.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::seed; + + #[test] + fn same_seed_same_names() { + for culture in Culture::ALL { + let a: Vec<_> = (0..20).map(|i| culture.person(&mut seed::rng(i))).collect(); + let b: Vec<_> = (0..20).map(|i| culture.person(&mut seed::rng(i))).collect(); + assert_eq!(a, b); + } + } + + #[test] + fn words_are_well_formed() { + for culture in Culture::ALL { + let mut rng = seed::rng(1); + for _ in 0..5000 { + let w = culture.word(&mut rng); + assert!(w.starts_with(|c: char| c.is_ascii_uppercase()), "{culture:?}: {w:?}"); + assert!(pronounceable(&w.to_lowercase()), "{culture:?}: {w:?}"); + } + } + } + + /// Mostly checks that each syllable list joins up: if a culture's words + /// keep failing `pronounceable`, `word` falls back to unpronounceable ones. + #[test] + fn most_candidates_are_pronounceable() { + for culture in Culture::ALL { + let mut rng = seed::rng(2); + let mut ok = 0; + for _ in 0..1000 { + ok += usize::from(pronounceable(&culture.syllables(&mut rng).concat())); + } + assert!(ok > 700, "{culture:?}: only {ok}/1000 candidates were pronounceable"); + } + } + + #[test] + fn every_template_expands() { + let mut rng = seed::rng(3); + for culture in Culture::ALL { + let p = culture.phonology(); + for template in p.people.iter().chain(p.places) { + let name = culture.expand(template, &mut rng); + assert!(!name.contains('{'), "{template:?} gave {name:?}"); + } + } + } + + #[test] + fn parses_every_culture() { + for culture in Culture::ALL { + let short = culture.name().rsplit(' ').next().unwrap(); + assert_eq!(Culture::parse(short), Some(culture)); + } + } +} diff --git a/src/scenes/derelict.rs b/src/scenes/derelict.rs new file mode 100644 index 0000000..fc18f50 --- /dev/null +++ b/src/scenes/derelict.rs @@ -0,0 +1,413 @@ +//! 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. + +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}; + +use super::{Scene, Transition}; +use crate::app::Ctx; +use crate::dungeon::crawl::{Action, Crawl, Item, Outcome, SIGHT_RADIUS}; +use crate::dungeon::{Pos, Tile}; +use crate::seed; + +const PANEL_WIDTH: u16 = 34; +const LOG_HEIGHT: u16 = 7; + +type Rgb = (u8, u8, u8); +/// Cells you've seen but can't see now. +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); + +pub struct Derelict { + crawl: Crawl, + /// Asking whether to leave by the emergency hatch. + abandoning: bool, + /// Once the crawl is over: a title and lines for the closing summary. + ending: Option<(&'static str, Vec<String>)>, +} + +impl Derelict { + pub fn new(seed: u64) -> Self { + Derelict { crawl: Crawl::new(seed), abandoning: false, ending: None } + } + + /// Called once, when the crawl ends: banks what you carried if you got + /// out, and writes the summary. + fn finish(&mut self, outcome: Outcome, ctx: &mut Ctx) { + 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))); + lines.push(String::new()); + lines.push(format!( + "Your hold now carries {} and {} ¤.", + count(ctx.hold.artifacts.len(), "artifact"), + ctx.hold.scrip + )); + 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(), + ], + }; + let title = match outcome { + Outcome::Escaped => " SAFE ABOARD YOUR SHIP ", + Outcome::Died { .. } => " PULLED BACK FROM THE EDGE ", + }; + self.ending = Some((title, lines)); + } + + fn render_map(&self, buf: &mut Buffer, area: Rect) { + 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)); + 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); + if let Some(cell) = buf.cell_mut((area.x + sx, area.y + sy)) { + cell.set_char(glyph).set_style(style); + } + } + } + } + + /// 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. + fn look(&self, p: Pos) -> (char, Style) { + let c = &self.crawl; + let tile = c.map.tile(p); + if c.fov.contains(p) { + if p == c.player { + return ('@', Style::new().fg(Color::White).bold()); + } + 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 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); + return (glyph, Style::new().fg(rgb(colour, light.max(0.8))).bold()); + } + let (glyph, colour) = tile_look(tile); + return (glyph, Style::new().fg(rgb(colour, light))); + } + if c.seen(p) { + if let Some(loot) = c.loot_at(p) { + let (glyph, colour) = item_look(&loot.item); + return (glyph, Style::new().fg(rgb(colour, 0.45))); + } + return (tile_look(tile).0, Style::new().fg(rgb(REMEMBERED, 1.0))); + } + match tile { + Tile::Hull | Tile::Airlock => { + (tile_look(tile).0, Style::new().fg(rgb(SILHOUETTE, 1.0))) + } + Tile::Void if is_star(p) => ('.', Style::new().fg(rgb(STAR, 1.0))), + _ => (' ', Style::new()), + } + } + + fn render_panel(&self, frame: &mut Frame, area: Rect) { + let c = &self.crawl; + let block = Block::bordered() + .border_style(Style::new().dark_gray()) + .title(Line::from(" DERELICT ").bold()) + .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 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))]), + 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)); + } + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info); + + // 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<_>>(), + ) + }; + 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, + ); + } + + fn render_log(&self, frame: &mut Frame, area: Rect) { + let log = &self.crawl.log; + let shown = usize::from(area.height.saturating_sub(1)); + let first = log.len().saturating_sub(shown); + let lines: Vec<Line> = log[first..] + .iter() + .enumerate() + .map(|(i, message)| { + let age = log.len() - first - 1 - i; + let line = Line::from(message.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).block(block), area); + } +} + +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, + }; + } + // 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; + } + _ => return Transition::None, + }; + self.crawl.act(action); + if let Some(outcome) = self.crawl.outcome { + self.finish(outcome, ctx); + } + 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.render_map(frame.buffer_mut(), map); + self.render_log(frame, log); + self.render_panel(frame, panel); + + if self.abandoning { + 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(), + ], + ); + } else if let Some((title, lines)) = &self.ending { + let mut lines = lines.clone(); + lines.extend([String::new(), "[any key]".into()]); + popup(frame, title, lines); + } + } +} + +/// 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 { + if map_size <= view_size { + -(view_size - map_size) / 2 + } else { + (focus - view_size / 2).clamp(0, map_size - view_size) + } +} + +pub(crate) fn tile_look(tile: Tile) -> (char, Rgb) { + match tile { + Tile::Void => (' ', (0, 0, 0)), + Tile::Hull => ('#', (122, 134, 160)), + Tile::Wall => ('#', (152, 148, 136)), + Tile::Floor => ('.', (124, 118, 104)), + Tile::Debris => (',', (160, 126, 92)), + Tile::Door { open: false } => ('+', (216, 170, 86)), + Tile::Door { open: true } => ('\'', (216, 170, 86)), + Tile::Airlock => ('Θ', (90, 222, 200)), + } +} + +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)), + } +} + +fn rgb((r, g, b): Rgb, light: f64) -> Color { + let scale = |c: u8| (f64::from(c) * light).round() as u8; + Color::Rgb(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); + seed::derive(cell, "star", 0).is_multiple_of(41) +} + +fn health_bar(hp: i32, max: i32, width: i32) -> Span<'static> { + let filled = (hp * width + max - 1) / max; + 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(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") } +} + +fn popup(frame: &mut Frame, title: &str, lines: Vec<String>) { + 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 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); + let block = Block::bordered() + .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); +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::term::KeyboardCaps; + + fn ctx() -> Ctx { + Ctx::new(1, KeyboardCaps { key_release: false }) + } + + fn press(scene: &mut Derelict, code: KeyCode, ctx: &mut Ctx) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + fn screen(scene: &Derelict, ctx: &Ctx) -> String { + let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); + terminal.draw(|frame| scene.render(frame, ctx)).unwrap(); + terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect() + } + + /// The vi key that moves you onto the airlock from the starting cell. + fn key_toward_airlock(scene: &Derelict) -> KeyCode { + let c = &scene.crawl; + let airlock = c.player.neighbours().find(|&n| c.map.tile(n) == Tile::Airlock).unwrap(); + KeyCode::Char(match (airlock.x - c.player.x, airlock.y - c.player.y) { + (-1, -1) => 'y', + (0, -1) => 'k', + (1, -1) => 'u', + (-1, 0) => 'h', + (1, 0) => 'l', + (-1, 1) => 'b', + (0, 1) => 'j', + _ => 'n', + }) + } + + #[test] + fn escaping_banks_what_you_carry() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + scene.crawl.scrip = 55; + 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!(matches!(press(&mut scene, KeyCode::Char(' '), &mut ctx), Transition::Pop)); + } + + #[test] + fn abandoning_leaves_it_behind() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + scene.crawl.scrip = 55; + press(&mut scene, KeyCode::Esc, &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); + } + + #[test] + fn any_other_key_keeps_exploring() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(matches!(press(&mut scene, KeyCode::Char('x'), &mut ctx), Transition::None)); + assert!(!screen(&scene, &ctx).contains("ABANDON SHIP?")); + } +} diff --git a/src/scenes/forge.rs b/src/scenes/forge.rs new file mode 100644 index 0000000..f856c8f --- /dev/null +++ b/src/scenes/forge.rs @@ -0,0 +1,154 @@ +//! The name forge: browse what the name generator makes for each culture. +//! +//! Each forging is `derive(universe, "forge", n)`, so stepping back with ← +//! regenerates exactly what you saw before. + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Wrap}; + +use super::{Scene, Transition}; +use crate::app::Ctx; +use crate::names::{self, Artifact, Culture}; +use crate::seed; + +const PER_LIST: usize = 4; +const ARTIFACTS: usize = 5; + +struct Column { + culture: Culture, + people: Vec<String>, + places: Vec<String>, +} + +pub struct Forge { + forging: u64, + columns: Vec<Column>, + artifacts: Vec<Artifact>, +} + +impl Forge { + pub fn new(ctx: &Ctx) -> Self { + let mut forge = Forge { forging: 0, columns: Vec::new(), artifacts: Vec::new() }; + forge.forge(ctx.seed); + forge + } + + fn forge(&mut self, universe: u64) { + let mut rng = seed::rng(seed::derive(universe, "forge", self.forging)); + self.columns = Culture::ALL + .iter() + .map(|&culture| Column { + culture, + people: (0..PER_LIST).map(|_| culture.person(&mut rng)).collect(), + places: (0..PER_LIST).map(|_| culture.place(&mut rng)).collect(), + }) + .collect(); + self.artifacts = (0..ARTIFACTS).map(|_| names::artifact(&mut rng)).collect(); + } +} + +fn colour(culture: Culture) -> Color { + match culture { + Culture::Qaari => Color::Yellow, + Culture::Cant => Color::Red, + Culture::Houses => Color::Magenta, + Culture::Drift => Color::Cyan, + } +} + +fn render_column(frame: &mut Frame, column: &Column, area: Rect) { + let tint = colour(column.culture); + let heading = |text: &'static str| Line::from(text).fg(tint).dim(); + let mut lines = vec![ + Line::from(column.culture.blurb()).dark_gray().italic(), + Line::default(), + heading("PEOPLE"), + ]; + lines.extend(column.people.iter().map(|n| Line::from(n.as_str()))); + lines.push(Line::default()); + lines.push(heading("PLACES")); + lines.extend(column.places.iter().map(|n| Line::from(n.as_str()))); + + let block = Block::bordered() + .border_style(Style::new().fg(tint).dim()) + .title(Line::from(format!(" {} ", column.culture.name())).fg(tint).bold()); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); +} + +impl Scene for Forge { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + match key.code { + KeyCode::Char(' ') | KeyCode::Right | KeyCode::Enter => { + self.forging += 1; + self.forge(ctx.seed); + } + KeyCode::Left | KeyCode::Backspace if self.forging > 0 => { + self.forging -= 1; + self.forge(ctx.seed); + } + KeyCode::Esc => return Transition::Pop, + KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') => return Transition::Pop, + _ => {} + } + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let [header, cultures, artifacts, footer] = Layout::vertical([ + Constraint::Length(2), + Constraint::Fill(1), + Constraint::Length(ARTIFACTS as u16 + 2), + Constraint::Length(1), + ]) + .areas(frame.area()); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::from(" THE NAME FORGE").bold(), + Span::from(format!(" forging {} of universe {}", self.forging, ctx.seed)) + .dark_gray(), + ])), + header, + ); + + // Four across on wide terminals, two by two otherwise. + let cells: Vec<Rect> = if cultures.width >= 120 { + Layout::horizontal([Constraint::Fill(1); 4]).split(cultures).to_vec() + } else { + Layout::vertical([Constraint::Fill(1); 2]) + .split(cultures) + .iter() + .flat_map(|row| Layout::horizontal([Constraint::Fill(1); 2]).split(*row).to_vec()) + .collect() + }; + for (column, cell) in self.columns.iter().zip(cells) { + render_column(frame, column, cell); + } + + let width = self.artifacts.iter().map(|a| a.unidentified.len()).max().unwrap_or(0); + let lines: Vec<Line> = self + .artifacts + .iter() + .map(|a| { + Line::from(vec![ + Span::from(format!("{:<width$}", a.unidentified)).italic().gray(), + Span::from(" → ").dark_gray(), + Span::from(a.true_name.as_str()).light_yellow(), + ]) + }) + .collect(); + let block = Block::bordered() + .border_style(Style::new().dark_gray()) + .title(Line::from(" ARTIFACTS: as found → once identified ").bold()); + frame.render_widget(Paragraph::new(lines).block(block), artifacts); + + frame.render_widget( + Paragraph::new(" [space/→] forge again [←] previous [esc] back").dark_gray(), + footer, + ); + } +} diff --git a/src/scenes/mod.rs b/src/scenes/mod.rs new file mode 100644 index 0000000..60db647 --- /dev/null +++ b/src/scenes/mod.rs @@ -0,0 +1,74 @@ +//! Scenes: the title screen, the name forge, and derelicts to board; +//! flight and ports later. + +pub mod derelict; +pub mod forge; +pub mod title; + +use ratatui::Frame; +use ratatui::crossterm::event::KeyEvent; + +use crate::app::Ctx; + +/// What a scene wants to happen after it handles a key or a tick. +pub enum Transition { + None, + /// Open a scene on top of this one. Popping it comes back here. + Push(Box<dyn Scene>), + /// Close this scene and return to the one beneath it. + Pop, + Quit, +} + +/// One screen of the game. The game keeps a stack of them; only the top one +/// gets keys and ticks and is drawn. +pub trait Scene { + /// A key was pressed, or auto-repeated while held. + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition; + + /// One fixed step of game time; there are `app::TICKS_PER_SECOND` of + /// these a second, whatever the frame rate. Turn-based scenes can ignore it. + fn tick(&mut self, _ctx: &mut Ctx) -> Transition { + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx); +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::{KeyCode, KeyModifiers}; + + use super::*; + use crate::term::KeyboardCaps; + + pub fn test_ctx() -> Ctx { + Ctx::new(1, KeyboardCaps { key_release: false }) + } + + /// Draws every scene at several sizes, down to absurdly small ones, to + /// catch layout arithmetic that panics. + #[test] + fn scenes_render_at_any_size() { + let mut ctx = test_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)), + Box::new(forge::Forge::new(&ctx)), + Box::new(derelict::Derelict::new(1)), + Box::new(abandoning), + ]; + for (width, height) in [(160, 50), (80, 24), (40, 12), (5, 3), (1, 1)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + for scene in &scenes { + for ticks in [0, 12_345] { + ctx.ticks = ticks; + terminal.draw(|frame| scene.render(frame, &ctx)).unwrap(); + } + } + } + } +} diff --git a/src/scenes/title.rs b/src/scenes/title.rs new file mode 100644 index 0000000..7c75888 --- /dev/null +++ b/src/scenes/title.rs @@ -0,0 +1,224 @@ +//! Title screen: a turning orrery over a drifting starfield. +//! +//! 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. + +use std::f64::consts::TAU; + +use rand::RngExt; +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::symbols::Marker; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::widgets::canvas::{Canvas, Points}; + +use super::derelict::Derelict; +use super::forge::Forge; +use super::{Scene, Transition}; +use crate::app::{Ctx, TICKS_PER_SECOND}; +use crate::seed; + +const LOGO: [&str; 4] = [ + r" ___ ___ ___ ___ ___ __ __", + r" / _ \ | _ \ | _ \ | __| | _ \ \ \ / /", + r"| (_) | | / | / | _| | / \ V / ", + r" \___/ |_|_\ |_|_\ |___| |_|_\ |_| ", +]; + +/// Star layers: drift speed in screen-widths per second, glyph, colour. +/// Nearer layers move faster, which reads as depth. +const STAR_LAYERS: [(f64, char, Color); 3] = + [(0.004, '.', Color::DarkGray), (0.010, '·', Color::Gray), (0.025, '*', Color::White)]; + +/// The orrery is seen from above at an angle, so circular orbits look like +/// ellipses this many times as tall as they are wide. +const TILT: f64 = 0.4; + +const PLANET_COLOURS: [Color; 6] = [ + Color::LightRed, + Color::LightBlue, + Color::LightGreen, + Color::LightMagenta, + Color::LightCyan, + Color::Rgb(205, 133, 63), +]; + +struct Star { + x: f64, + y: f64, + layer: usize, +} + +struct Planet { + /// Orbit radius as a fraction of the largest orbit that fits. + orbit: f64, + /// Seconds per revolution. + period: f64, + phase: f64, + glyph: &'static str, + colour: Color, +} + +pub struct Title { + stars: Vec<Star>, + planets: Vec<Planet>, +} + +impl Title { + pub fn new(ctx: &Ctx) -> Self { + let mut rng = seed::rng(seed::derive(ctx.seed, "title", 0)); + let stars = (0..160) + .map(|_| Star { + x: rng.random(), + y: rng.random(), + layer: rng.random_range(0..STAR_LAYERS.len()), + }) + .collect(); + let count = rng.random_range(3..=5); + let planets = (0..count) + .map(|i| { + let orbit = (i as f64 + rng.random_range(0.6..1.0)) / count as f64; + Planet { + orbit, + // Kepler's third law, roughly: outer planets are slower. + period: 45.0 * orbit.powf(1.5), + phase: rng.random_range(0.0..TAU), + glyph: if rng.random_bool(0.5) { "o" } else { "•" }, + colour: PLANET_COLOURS[rng.random_range(..PLANET_COLOURS.len())], + } + }) + .collect(); + Title { stars, planets } + } + + fn draw_stars(&self, buf: &mut Buffer, area: Rect, seconds: f64) { + for star in &self.stars { + let (speed, glyph, colour) = STAR_LAYERS[star.layer]; + let x = (star.x - speed * seconds).rem_euclid(1.0); + let col = area.x + (x * f64::from(area.width)) as u16; + let row = area.y + (star.y * f64::from(area.height)) as u16; + if let Some(cell) = buf.cell_mut((col, row)) { + cell.set_char(glyph).set_fg(colour); + } + } + } + + fn draw_orrery(&self, frame: &mut Frame, area: Rect, seconds: f64) { + // Braille gives each cell 2x4 dots. With these bounds one unit is one + // dot, and dots are about square, so geometry here needs no aspect + // correction (unlike plain cells, which are twice as tall as wide). + let half_w = f64::from(area.width); + let half_h = 2.0 * f64::from(area.height); + let max_radius = (half_w * 0.95).min(half_h * 0.9 / TILT); + + let canvas = Canvas::default() + .marker(Marker::Braille) + .x_bounds([-half_w, half_w]) + .y_bounds([-half_h, half_h]) + .paint(|c| { + for planet in &self.planets { + let r = planet.orbit * max_radius; + let steps = (r * 2.0) as usize + 16; + let ring: Vec<(f64, f64)> = (0..steps) + .map(|i| { + let a = TAU * i as f64 / steps as f64; + (r * a.cos(), r * TILT * a.sin()) + }) + .collect(); + c.draw(&Points { coords: &ring, color: Color::DarkGray }); + } + c.print(0.0, 0.0, Span::styled("☼", Style::new().fg(Color::Yellow).bold())); + for planet in &self.planets { + let r = planet.orbit * max_radius; + let a = planet.phase + TAU * seconds / planet.period; + let glyph = Span::styled(planet.glyph, Style::new().fg(planet.colour).bold()); + c.print(r * a.cos(), r * TILT * a.sin(), glyph); + } + }); + frame.render_widget(canvas, area); + } +} + +impl Scene for Title { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + match key.code { + KeyCode::Char(c) => match c.to_ascii_lowercase() { + 'd' => { + ctx.boarded += 1; + let ship = seed::derive(ctx.seed, "derelict", ctx.boarded); + Transition::Push(Box::new(Derelict::new(ship))) + } + 'n' => Transition::Push(Box::new(Forge::new(ctx))), + 'q' => Transition::Quit, + _ => Transition::None, + }, + KeyCode::Esc => Transition::Quit, + _ => Transition::None, + } + } + + 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 [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(1), + ]) + .areas(area); + + self.draw_orrery(frame, sky, seconds); + + 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" }; + let text = format!( + "your hold: {} artifact{plural}, {} ¤ of salvage", + h.artifacts.len(), + h.scrip + ); + 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" + }; + frame.render_widget( + Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray(), + footer, + ); + } +} diff --git a/src/seed.rs b/src/seed.rs new file mode 100644 index 0000000..b7491e7 --- /dev/null +++ b/src/seed.rs @@ -0,0 +1,93 @@ +//! Stable, hierarchical seeds. +//! +//! The universe is a pure function of one `u64`. Each thing derives its seed +//! from its parent's plus a label and an index: +//! +//! ```text +//! let system = seed::derive(universe, "system", 42); +//! let wreck = seed::derive(system, "derelict", 3); +//! ``` +//! +//! so any one thing can be regenerated without generating everything before +//! it, and a save file only needs the root seed plus what the player changed. +//! +//! Everything here must give the same answer forever: a change silently +//! reshuffles every universe. That's why this doesn't use std's +//! `DefaultHasher` (allowed to change between Rust releases) or rand's +//! `seed_from_u64` (allowed to change between rand releases), and why the +//! tests pin exact outputs. + +use rand::SeedableRng; +use rand_chacha::ChaCha8Rng; + +/// The RNG for all procedural generation. ChaCha's output is specified and +/// rand_chacha promises not to change it, unlike rand's `StdRng`. +pub type Rng = ChaCha8Rng; + +/// SplitMix64's finalizer: scrambles every input bit into every output bit. +const fn mix(mut z: u64) -> u64 { + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) +} + +/// FNV-1a: a tiny hash for turning labels into numbers. +const fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + let mut i = 0; + while i < bytes.len() { + hash ^= bytes[i] as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + i += 1; + } + hash +} + +/// The seed of the `index`th `label` inside `parent`. +pub const fn derive(parent: u64, label: &str, index: u64) -> u64 { + mix(mix(parent ^ fnv1a(label.as_bytes())).wrapping_add(mix(index ^ 0x9e37_79b9_7f4a_7c15))) +} + +/// An RNG for generating whatever `seed` identifies. +pub fn rng(seed: u64) -> Rng { + let mut bytes = [0u8; 32]; + let mut state = seed; + for chunk in bytes.as_chunks_mut::<8>().0 { + state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); + *chunk = mix(state).to_le_bytes(); + } + Rng::from_seed(bytes) +} + +/// A fresh universe seed for when the player doesn't pick one. +pub fn from_clock() -> u64 { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos() as u64); + // Keep seeds short enough to read aloud and retype. + mix(nanos ^ u64::from(std::process::id())) % 1_000_000_000 +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::Rng as _; + + /// If this fails, every existing universe just changed. Only update the + /// numbers if you mean to break old seeds and saves. + #[test] + fn outputs_never_change() { + assert_eq!(derive(0, "system", 0), PINNED_DERIVE); + assert_eq!(rng(0).next_u64(), PINNED_RNG); + } + const PINNED_DERIVE: u64 = 11_894_545_496_673_509_984; + const PINNED_RNG: u64 = 13_804_888_775_535_289_832; + + #[test] + fn labels_and_indices_give_different_seeds() { + let a = derive(7, "system", 0); + assert_ne!(a, derive(7, "system", 1)); + assert_ne!(a, derive(7, "derelict", 0)); + assert_ne!(a, derive(8, "system", 0)); + } +} diff --git a/src/term.rs b/src/term.rs new file mode 100644 index 0000000..b11bbdc --- /dev/null +++ b/src/term.rs @@ -0,0 +1,50 @@ +//! Terminal setup and teardown. + +use std::io::{self, stdout}; + +use ratatui::DefaultTerminal; +use ratatui::crossterm::event::{ + KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, +}; +use ratatui::crossterm::{execute, terminal}; + +/// What this terminal can tell us about the keyboard. +#[derive(Clone, Copy, Debug)] +pub struct KeyboardCaps { + /// The terminal reports key releases (the kitty keyboard protocol), so a + /// scene can know a key is being held. Without it, holding a key looks + /// like one press, a ~0.5 s pause, then auto-repeats, and real-time + /// controls need a fallback such as a throttle you step up and down. + pub key_release: bool, +} + +/// Raw mode, alternate screen, and the kitty keyboard protocol where available. +pub fn init() -> io::Result<(DefaultTerminal, KeyboardCaps)> { + let terminal = ratatui::try_init()?; + let key_release = terminal::supports_keyboard_enhancement().unwrap_or(false); + if key_release { + execute!( + stdout(), + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + ) + )?; + // ratatui's panic hook restores raw mode and the alternate screen but + // not these flags, which would leave the shell receiving kitty-encoded + // keys. Pop them first, then hand over to ratatui's hook. + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = execute!(stdout(), PopKeyboardEnhancementFlags); + previous(info); + })); + } + Ok((terminal, KeyboardCaps { key_release })) +} + +pub fn restore(caps: KeyboardCaps) { + if caps.key_release { + let _ = execute!(stdout(), PopKeyboardEnhancementFlags); + } + ratatui::restore(); +} |