summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock1818
-rw-r--r--Cargo.toml9
-rw-r--r--README.md59
-rw-r--r--rustfmt.toml4
-rw-r--r--src/app.rs110
-rw-r--r--src/cli.rs156
-rw-r--r--src/dungeon/crawl.rs416
-rw-r--r--src/dungeon/fov.rs207
-rw-r--r--src/dungeon/mod.rs156
-rw-r--r--src/dungeon/ship.rs452
-rw-r--r--src/main.rs43
-rw-r--r--src/names/artifact.rs69
-rw-r--r--src/names/mod.rs319
-rw-r--r--src/scenes/derelict.rs413
-rw-r--r--src/scenes/forge.rs154
-rw-r--r--src/scenes/mod.rs74
-rw-r--r--src/scenes/title.rs224
-rw-r--r--src/seed.rs93
-rw-r--r--src/term.rs50
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 &sections {
+ 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();
+}