From e7aac6d1dd976fc7b57b6e8862dc737c6c96ba9e Mon Sep 17 00:00:00 2001 From: AI Agent Date: Sat, 14 Mar 2026 14:24:03 -0600 Subject: [PATCH] Add admin system, registration gate, mudtool database editor, and test checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add is_admin flag to player DB schema with migration for existing databases - Add server_settings table for key-value config (registration_open, etc.) - Add 10 in-game admin commands: promote, demote, kick, teleport, registration, announce, heal, info, setattitude, list — all gated behind admin flag - Registration gate: new players rejected when registration_open=false, existing players can still reconnect - Add mudtool binary with CLI mode (players/settings/attitudes CRUD) and interactive ratatui TUI with tabbed interface for database management - Restructure to lib.rs + main.rs so mudtool shares DB code with server - Add TESTING.md with comprehensive pre-commit checklist and smoke test script - Stats and who commands show [ADMIN] badge; help shows admin section for admins Made-with: Cursor --- Cargo.lock | 1232 +++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 4 +- TESTING.md | 157 ++++++ src/admin.rs | 633 +++++++++++++++++++++++ src/bin/mudtool.rs | 625 ++++++++++++++++++++++ src/commands.rs | 939 +++++++++++++++++++++++++++------ src/db.rs | 112 +++- src/game.rs | 206 ++++++-- src/lib.rs | 9 + src/main.rs | 22 +- src/ssh.rs | 267 +++++++--- 11 files changed, 3895 insertions(+), 311 deletions(-) create mode 100644 TESTING.md create mode 100644 src/admin.rs create mode 100644 src/bin/mudtool.rs create mode 100644 src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 54b1545..fdb1956 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -105,6 +111,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "argon2" version = "0.5.3" @@ -117,6 +129,15 @@ dependencies = [ "password-hash", ] +[[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.0" @@ -129,6 +150,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -146,6 +173,27 @@ dependencies = [ "sha2", ] +[[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.11.0" @@ -195,6 +243,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -207,6 +261,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -278,12 +341,35 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[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 = "core-foundation-sys" version = "0.8.7" @@ -299,6 +385,49 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "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-bigint" version = "0.5.5" @@ -321,6 +450,16 @@ dependencies = [ "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 = "ctr" version = "0.9.2" @@ -354,7 +493,41 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", ] [[package]] @@ -371,9 +544,15 @@ checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -385,6 +564,37 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[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.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -397,6 +607,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -436,6 +655,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -466,7 +691,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -508,6 +733,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -520,6 +754,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[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 = "ff" version = "0.13.1" @@ -536,18 +780,53 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[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.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[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.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "futures" version = "0.3.32" @@ -604,7 +883,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -660,6 +939,31 @@ dependencies = [ "wasm-bindgen", ] +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -687,7 +991,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -695,6 +999,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -705,6 +1014,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[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" @@ -768,6 +1083,18 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.13.0" @@ -776,6 +1103,17 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", ] [[package]] @@ -788,6 +1126,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "internal-russh-forked-ssh-key" version = "0.6.11+upstream-0.6.7" @@ -821,6 +1172,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[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.17" @@ -848,7 +1208,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -861,6 +1221,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[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.0" @@ -870,6 +1247,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" @@ -893,6 +1276,33 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -908,6 +1318,25 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "md5" version = "0.7.0" @@ -920,6 +1349,27 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[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.1.1" @@ -927,16 +1377,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] [[package]] name = "mudserver" -version = "0.1.0" +version = "0.2.0" dependencies = [ + "crossterm 0.28.1", "env_logger", "log", + "ratatui", "rusqlite", "russh", "serde", @@ -951,10 +1404,21 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.0", "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]] @@ -984,6 +1448,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[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.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1014,6 +1495,15 @@ dependencies = [ "libm", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1032,6 +1522,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[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 = "p256" version = "0.13.2" @@ -1082,7 +1581,7 @@ dependencies = [ "futures", "log", "rand", - "thiserror", + "thiserror 1.0.69", "tokio", "windows", ] @@ -1140,6 +1639,101 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[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", +] + +[[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.117", +] + +[[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" @@ -1228,6 +1822,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1237,6 +1837,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -1264,6 +1874,18 @@ 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.5" @@ -1291,7 +1913,92 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.0", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", ] [[package]] @@ -1300,7 +2007,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1350,7 +2057,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1383,7 +2090,7 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1399,7 +2106,7 @@ checksum = "ca3ee9363fcf66d434d8015d9ae7d879681206981534c21bfdff8a7e34f52cca" dependencies = [ "aes", "base64ct", - "bitflags", + "bitflags 2.11.0", "block-padding", "byteorder", "bytes", @@ -1416,7 +2123,7 @@ dependencies = [ "enum_dispatch", "futures", "generic-array", - "getrandom", + "getrandom 0.2.17", "hex-literal", "hmac", "home", @@ -1445,7 +2152,7 @@ dependencies = [ "spki", "ssh-encoding", "subtle", - "thiserror", + "thiserror 1.0.69", "tokio", "typenum", "zeroize", @@ -1485,12 +2192,44 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "salsa20" version = "0.10.2" @@ -1564,7 +2303,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1617,6 +2356,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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" @@ -1637,6 +2397,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -1704,12 +2470,56 @@ dependencies = [ "sha2", ] +[[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.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[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.117" @@ -1721,13 +2531,85 @@ dependencies = [ "unicode-ident", ] +[[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.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "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", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1738,9 +2620,41 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tokio" version = "1.50.0" @@ -1766,7 +2680,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1816,12 +2730,47 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[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.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -1844,6 +2793,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -1856,12 +2817,39 @@ 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.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -1908,7 +2896,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1921,6 +2909,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.91" @@ -1931,6 +2953,78 @@ dependencies = [ "wasm-bindgen", ] +[[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" @@ -1997,7 +3091,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2008,7 +3102,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2019,7 +3113,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2226,6 +3320,94 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zerocopy" version = "0.8.42" @@ -2243,7 +3425,7 @@ checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 85a2fbe..933170a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mudserver" -version = "0.1.0" +version = "0.2.0" edition = "2021" [dependencies] @@ -10,5 +10,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" rusqlite = { version = "0.35", features = ["bundled"] } +ratatui = "0.30" +crossterm = "0.28" log = "0.4" env_logger = "0.11" diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..87f1f08 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,157 @@ +# Pre-Commit Test Checklist + +Run through these checks before every commit to ensure consistent feature coverage. + +## Build +- [ ] `cargo build` succeeds with no errors +- [ ] `cargo build --bin mudtool` succeeds + +## Server Startup +- [ ] Server starts: `RUST_LOG=info ./target/debug/mudserver` +- [ ] World loads all rooms, NPCs, objects, races, classes (check log output) +- [ ] Database opens (or creates) successfully + +## Character Creation +- [ ] New player SSH → gets chargen flow (race + class selection) +- [ ] Chargen accepts both number and name input +- [ ] After chargen, player appears in spawn room with correct stats +- [ ] Player saved to DB after creation + +## Player Persistence +- [ ] Reconnecting player skips chargen, sees "Welcome back!" +- [ ] Room, stats, inventory, equipment all restored from DB +- [ ] Verify with: `sqlite3 mudserver.db "SELECT * FROM players;"` + +## Movement & Navigation +- [ ] `go north`, `n`, `south`, `s`, etc. all work +- [ ] Invalid direction shows error +- [ ] Room view shows: NPCs (colored by attitude), objects, exits, other players + +## NPC Interaction +- [ ] `examine ` shows description, stats, attitude label +- [ ] `talk ` shows greeting dialogue +- [ ] `talk ` shows snarl message +- [ ] Dead NPCs don't appear in room view + +## Combat +- [ ] `attack ` initiates combat +- [ ] Can't attack friendly/neutral NPCs +- [ ] Combat rounds show damage dealt and received +- [ ] NPC death: awards XP, shifts attitude -10, shifts faction -5 +- [ ] Player death: respawns at spawn room with full HP +- [ ] NPCs respawn after configured time +- [ ] Combat lockout: can only attack/flee/look/quit during combat +- [ ] `flee` exits combat + +## Items +- [ ] `take ` picks up takeable objects +- [ ] `drop ` places item in room +- [ ] `equip ` works, old gear returns to inventory +- [ ] `use ` heals and removes item +- [ ] `inventory` shows equipped + bag items + +## Attitude System +- [ ] Per-player NPC attitudes stored in DB +- [ ] `examine` shows attitude label per-player +- [ ] Killing NPC shifts attitude (individual -10, faction -5) +- [ ] Verify: `sqlite3 mudserver.db "SELECT * FROM npc_attitudes;"` + +## Admin System +- [ ] Non-admin can't use `admin` commands (gets error) +- [ ] Set admin via mudtool: `mudtool players set-admin true` +- [ ] `admin help` shows admin command list +- [ ] `admin promote ` grants admin (verify in DB) +- [ ] `admin demote ` revokes admin +- [ ] `admin kick ` disconnects target player +- [ ] `admin teleport ` warps to room (shows room list on invalid) +- [ ] `admin registration off` blocks new player creation +- [ ] `admin registration on` re-enables it +- [ ] `admin announce ` broadcasts to all players +- [ ] `admin heal` heals self; `admin heal ` heals target +- [ ] `admin info ` shows detailed stats + attitudes +- [ ] `admin setattitude ` modifies attitude +- [ ] `admin list` shows all players with online/offline status + +## Registration Gate +- [ ] With registration open (default), new players can create characters +- [ ] With registration off, new SSH connections get rejection message +- [ ] Existing players can still log in when registration is closed + +## MUD Tool - CLI +- [ ] `mudtool players list` shows all players +- [ ] `mudtool players show ` shows details +- [ ] `mudtool players set-admin true` works +- [ ] `mudtool players delete ` removes player + attitudes +- [ ] `mudtool settings list` shows settings +- [ ] `mudtool settings set registration_open false` works +- [ ] `mudtool attitudes list ` shows attitudes +- [ ] `mudtool attitudes set ` works + +## MUD Tool - TUI +- [ ] `mudtool tui` launches interactive interface +- [ ] Tab/1/2/3 switches between Players, Settings, Attitudes tabs +- [ ] Arrow keys navigate rows +- [ ] 'a' toggles admin on Players tab +- [ ] 'd' prompts delete confirmation on Players tab +- [ ] Enter edits value on Settings and Attitudes tabs +- [ ] ←→ switches player on Attitudes tab +- [ ] 'q' exits TUI + +## Quick Smoke Test Script + +```bash +# Start server in background +RUST_LOG=info ./target/debug/mudserver & +SERVER_PID=$! +sleep 2 + +# Test 1: New player creation + basic commands +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF' +1 +1 +look +stats +go north +talk barkeep +go south +go south +examine thief +attack thief +flee +quit +EOF + +# Test 2: Persistence - reconnect +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF' +look +stats +quit +EOF + +# Test 3: Admin via mudtool +./target/debug/mudtool players list +./target/debug/mudtool players set-admin smoketest true +./target/debug/mudtool players show smoketest +./target/debug/mudtool settings set registration_open false +./target/debug/mudtool settings list + +# Test 4: Admin commands in-game +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF' +admin help +admin list +admin registration on +admin info smoketest +quit +EOF + +# Test 5: Registration gate +./target/debug/mudtool settings set registration_open false +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 newplayer@localhost <<'EOF' +quit +EOF + +# Cleanup +./target/debug/mudtool settings set registration_open true +./target/debug/mudtool players delete smoketest +kill $SERVER_PID +``` diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..1059f04 --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,633 @@ +use russh::CryptoVec; + +use crate::ansi; +use crate::commands::{BroadcastMsg, CommandResult, KickTarget}; +use crate::game::SharedState; + +fn simple(msg: &str) -> CommandResult { + CommandResult { + output: msg.to_string(), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } +} + +pub async fn execute_admin(args: &str, player_id: usize, state: &SharedState) -> CommandResult { + let (subcmd, subargs) = match args.split_once(' ') { + Some((c, a)) => (c.to_lowercase(), a.trim().to_string()), + None => (args.to_lowercase(), String::new()), + }; + + match subcmd.as_str() { + "help" | "h" | "" => admin_help(), + "promote" => admin_promote(&subargs, state).await, + "demote" => admin_demote(&subargs, state).await, + "kick" => admin_kick(&subargs, player_id, state).await, + "teleport" | "tp" => admin_teleport(&subargs, player_id, state).await, + "registration" | "reg" => admin_registration(&subargs, state).await, + "announce" => admin_announce(&subargs, player_id, state).await, + "heal" => admin_heal(&subargs, player_id, state).await, + "info" => admin_info(&subargs, state).await, + "setattitude" | "setatt" => admin_setattitude(&subargs, state).await, + "list" | "ls" => admin_list(player_id, state).await, + _ => simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("Unknown admin command: '{subcmd}'. Use 'admin help'.")) + )), + } +} + +fn admin_help() -> CommandResult { + let mut out = format!("\r\n{}\r\n", ansi::bold("=== Admin Commands ===")); + let cmds = [ + ("admin promote ", "Grant admin privileges"), + ("admin demote ", "Revoke admin privileges"), + ("admin kick ", "Disconnect a player"), + ("admin teleport ", "Teleport to a room"), + ("admin registration on|off", "Toggle new player registration"), + ("admin announce ", "Broadcast to all players"), + ("admin heal [player]", "Fully heal self or another player"), + ("admin info ", "View detailed player info"), + ( + "admin setattitude ", + "Set NPC attitude", + ), + ("admin list", "List all players (online + saved)"), + ]; + for (c, d) in cmds { + out.push_str(&format!( + " {:<42} {}\r\n", + ansi::color(ansi::YELLOW, c), + ansi::color(ansi::DIM, d) + )); + } + CommandResult { + output: out, + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } +} + +async fn admin_promote(target: &str, state: &SharedState) -> CommandResult { + if target.is_empty() { + return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin promote "))); + } + let st = state.lock().await; + if st.db.set_admin(target, true) { + // Also update in-memory if online + for conn in st.players.values() { + if conn.player.name == target { + // Can't mutate through shared ref, but DB is updated. + // They'll get the flag on next login. Notify them. + let msg = CryptoVec::from( + format!( + "\r\n{}\r\n{}", + ansi::system_msg("*** You have been granted admin privileges. ***"), + ansi::prompt() + ) + .as_bytes(), + ); + return CommandResult { + output: format!( + "{}\r\n", + ansi::system_msg(&format!("{target} has been promoted to admin.")) + ), + broadcasts: vec![BroadcastMsg { + channel: conn.channel, + handle: conn.handle.clone(), + data: msg, + }], + kick_targets: Vec::new(), + quit: false, + }; + } + } + simple(&format!( + "{}\r\n", + ansi::system_msg(&format!( + "{target} promoted to admin (offline, effective next login)." + )) + )) + } else { + simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("Player '{target}' not found in database.")) + )) + } +} + +async fn admin_demote(target: &str, state: &SharedState) -> CommandResult { + if target.is_empty() { + return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin demote "))); + } + let st = state.lock().await; + if st.db.set_admin(target, false) { + simple(&format!( + "{}\r\n", + ansi::system_msg(&format!("{target} has been demoted from admin.")) + )) + } else { + simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("Player '{target}' not found in database.")) + )) + } +} + +async fn admin_kick(target: &str, player_id: usize, state: &SharedState) -> CommandResult { + if target.is_empty() { + return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin kick "))); + } + let mut st = state.lock().await; + let low = target.to_lowercase(); + + let target_id = st + .players + .iter() + .find(|(_, c)| c.player.name.to_lowercase() == low) + .map(|(&id, _)| id); + + let tid = match target_id { + Some(id) => id, + None => { + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("Player '{target}' is not online.")) + )) + } + }; + + if tid == player_id { + return simple(&format!("{}\r\n", ansi::error_msg("You can't kick yourself."))); + } + + let kick_msg = CryptoVec::from( + format!( + "\r\n{}\r\n", + ansi::error_msg("*** You have been kicked by an admin. ***") + ) + .as_bytes(), + ); + + let conn = st.remove_player(tid); + match conn { + Some(c) => { + let name = c.player.name.clone(); + let room_id = c.player.room_id.clone(); + + let departure = CryptoVec::from( + format!( + "\r\n{}\r\n{}", + ansi::system_msg(&format!("{name} has been kicked.")), + ansi::prompt() + ) + .as_bytes(), + ); + let mut bcast: Vec = st + .players_in_room(&room_id, player_id) + .iter() + .map(|p| BroadcastMsg { + channel: p.channel, + handle: p.handle.clone(), + data: departure.clone(), + }) + .collect(); + // Send kick message to the target before closing + bcast.push(BroadcastMsg { + channel: c.channel, + handle: c.handle.clone(), + data: kick_msg, + }); + + CommandResult { + output: format!( + "{}\r\n", + ansi::system_msg(&format!("Kicked {name} from the server.")) + ), + broadcasts: bcast, + kick_targets: vec![KickTarget { + channel: c.channel, + handle: c.handle.clone(), + }], + quit: false, + } + } + None => simple(&format!("{}\r\n", ansi::error_msg("Failed to kick player."))), + } +} + +async fn admin_teleport(room_id: &str, player_id: usize, state: &SharedState) -> CommandResult { + if room_id.is_empty() { + return simple(&format!( + "{}\r\n", + ansi::error_msg("Usage: admin teleport ") + )); + } + let mut st = state.lock().await; + if st.world.get_room(room_id).is_none() { + let rooms: Vec<&String> = st.world.rooms.keys().collect(); + let mut sorted = rooms; + sorted.sort(); + let list = sorted + .iter() + .map(|r| ansi::color(ansi::CYAN, r)) + .collect::>() + .join(", "); + return simple(&format!( + "{}\r\nAvailable rooms: {}\r\n", + ansi::error_msg(&format!("Room '{room_id}' not found.")), + list + )); + } + + let old_rid = st + .players + .get(&player_id) + .map(|c| c.player.room_id.clone()) + .unwrap_or_default(); + let pname = st + .players + .get(&player_id) + .map(|c| c.player.name.clone()) + .unwrap_or_default(); + + // Departure broadcast + let leave = CryptoVec::from( + format!( + "\r\n{}\r\n{}", + ansi::system_msg(&format!("{pname} vanishes in a flash of light.")), + ansi::prompt() + ) + .as_bytes(), + ); + let mut bcast: Vec = st + .players_in_room(&old_rid, player_id) + .iter() + .map(|c| BroadcastMsg { + channel: c.channel, + handle: c.handle.clone(), + data: leave.clone(), + }) + .collect(); + + if let Some(c) = st.players.get_mut(&player_id) { + c.player.room_id = room_id.to_string(); + } + + // Arrival broadcast + let arrive = CryptoVec::from( + format!( + "\r\n{}\r\n{}", + ansi::system_msg(&format!("{pname} appears in a flash of light.")), + ansi::prompt() + ) + .as_bytes(), + ); + for c in st.players_in_room(room_id, player_id) { + bcast.push(BroadcastMsg { + channel: c.channel, + handle: c.handle.clone(), + data: arrive.clone(), + }); + } + + st.save_player_to_db(player_id); + let view = crate::commands::render_room_view(room_id, player_id, &st); + + CommandResult { + output: format!( + "{}\r\n{}", + ansi::system_msg(&format!("Teleported to {room_id}.")), + view + ), + broadcasts: bcast, + kick_targets: Vec::new(), + quit: false, + } +} + +async fn admin_registration(args: &str, state: &SharedState) -> CommandResult { + let st = state.lock().await; + match args.to_lowercase().as_str() { + "on" | "true" | "open" => { + st.db.set_setting("registration_open", "true"); + simple(&format!( + "{}\r\n", + ansi::system_msg("Registration is now OPEN.") + )) + } + "off" | "false" | "closed" => { + st.db.set_setting("registration_open", "false"); + simple(&format!( + "{}\r\n", + ansi::system_msg("Registration is now CLOSED.") + )) + } + _ => { + let current = st + .db + .get_setting("registration_open") + .unwrap_or_else(|| "true".into()); + simple(&format!( + "Registration is currently: {}\r\nUsage: admin registration on|off\r\n", + ansi::bold(¤t) + )) + } + } +} + +async fn admin_announce(msg: &str, player_id: usize, state: &SharedState) -> CommandResult { + if msg.is_empty() { + return simple(&format!( + "{}\r\n", + ansi::error_msg("Usage: admin announce ") + )); + } + let st = state.lock().await; + let announcement = CryptoVec::from( + format!( + "\r\n{}\r\n {}\r\n{}", + ansi::color(ansi::YELLOW, "*** ANNOUNCEMENT ***"), + ansi::bold(msg), + ansi::prompt() + ) + .as_bytes(), + ); + + let bcast: Vec = st + .players + .iter() + .filter(|(&id, _)| id != player_id) + .map(|(_, c)| BroadcastMsg { + channel: c.channel, + handle: c.handle.clone(), + data: announcement.clone(), + }) + .collect(); + + CommandResult { + output: format!( + "{}\r\n", + ansi::system_msg(&format!("Announced to {} player(s).", bcast.len())) + ), + broadcasts: bcast, + kick_targets: Vec::new(), + quit: false, + } +} + +async fn admin_heal(args: &str, player_id: usize, state: &SharedState) -> CommandResult { + let mut st = state.lock().await; + + if args.is_empty() { + if let Some(c) = st.players.get_mut(&player_id) { + c.player.stats.hp = c.player.stats.max_hp; + let hp = c.player.stats.max_hp; + let _ = c; + st.save_player_to_db(player_id); + return simple(&format!( + "{}\r\n", + ansi::system_msg(&format!("You have been fully healed. HP: {hp}/{hp}")) + )); + } + return simple(&format!("{}\r\n", ansi::error_msg("Error."))); + } + + let low = args.to_lowercase(); + let target_id = st + .players + .iter() + .find(|(_, c)| c.player.name.to_lowercase() == low) + .map(|(&id, _)| id); + + match target_id { + Some(tid) => { + if let Some(c) = st.players.get_mut(&tid) { + c.player.stats.hp = c.player.stats.max_hp; + let name = c.player.name.clone(); + let hp = c.player.stats.max_hp; + let notify = CryptoVec::from( + format!( + "\r\n{}\r\n{}", + ansi::system_msg(&format!("An admin has fully healed you. HP: {hp}/{hp}")), + ansi::prompt() + ) + .as_bytes(), + ); + let bcast = vec![BroadcastMsg { + channel: c.channel, + handle: c.handle.clone(), + data: notify, + }]; + let _ = c; + st.save_player_to_db(tid); + return CommandResult { + output: format!( + "{}\r\n", + ansi::system_msg(&format!("Healed {name}. HP: {hp}/{hp}")) + ), + broadcasts: bcast, + kick_targets: Vec::new(), + quit: false, + }; + } + simple(&format!("{}\r\n", ansi::error_msg("Error."))) + } + None => simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("Player '{args}' is not online.")) + )), + } +} + +async fn admin_info(target: &str, state: &SharedState) -> CommandResult { + if target.is_empty() { + return simple(&format!( + "{}\r\n", + ansi::error_msg("Usage: admin info ") + )); + } + let st = state.lock().await; + + // Check online first + let online = st + .players + .values() + .find(|c| c.player.name.to_lowercase() == target.to_lowercase()); + + if let Some(conn) = online { + let p = &conn.player; + let s = &p.stats; + let mut out = format!("\r\n{} {}\r\n", ansi::bold(&p.name), ansi::color(ansi::GREEN, "(online)")); + out.push_str(&format!( + " Race: {} | Class: {} | Admin: {}\r\n", + p.race_id, p.class_id, p.is_admin + )); + out.push_str(&format!( + " HP: {}/{} | ATK: {} | DEF: {} | Level: {} | XP: {}/{}\r\n", + s.hp, s.max_hp, s.attack, s.defense, s.level, s.xp, s.xp_to_next + )); + out.push_str(&format!(" Room: {}\r\n", p.room_id)); + out.push_str(&format!( + " Inventory: {} item(s) | Weapon: {} | Armor: {}\r\n", + p.inventory.len(), + p.equipped_weapon + .as_ref() + .map(|w| w.name.as_str()) + .unwrap_or("none"), + p.equipped_armor + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or("none"), + )); + let attitudes = st.db.load_attitudes(&p.name); + if !attitudes.is_empty() { + out.push_str(&format!(" Attitudes ({}):\r\n", attitudes.len())); + for att in &attitudes { + let label = crate::world::Attitude::from_value(att.value).label(); + out.push_str(&format!(" {}: {} ({})\r\n", att.npc_id, att.value, label)); + } + } + return CommandResult { + output: out, + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + }; + } + + // Check DB + if let Some(saved) = st.db.load_player(target) { + let mut out = format!( + "\r\n{} {}\r\n", + ansi::bold(&saved.name), + ansi::color(ansi::DIM, "(offline)") + ); + out.push_str(&format!( + " Race: {} | Class: {} | Admin: {}\r\n", + saved.race_id, saved.class_id, saved.is_admin + )); + out.push_str(&format!( + " HP: {}/{} | ATK: {} | DEF: {} | Level: {} | XP: {}\r\n", + saved.hp, saved.max_hp, saved.attack, saved.defense, saved.level, saved.xp + )); + out.push_str(&format!(" Room: {}\r\n", saved.room_id)); + let attitudes = st.db.load_attitudes(&saved.name); + if !attitudes.is_empty() { + out.push_str(&format!(" Attitudes ({}):\r\n", attitudes.len())); + for att in &attitudes { + let label = crate::world::Attitude::from_value(att.value).label(); + out.push_str(&format!(" {}: {} ({})\r\n", att.npc_id, att.value, label)); + } + } + return CommandResult { + output: out, + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + }; + } + + simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("Player '{target}' not found.")) + )) +} + +async fn admin_setattitude(args: &str, state: &SharedState) -> CommandResult { + let parts: Vec<&str> = args.splitn(3, ' ').collect(); + if parts.len() < 3 { + return simple(&format!( + "{}\r\n", + ansi::error_msg("Usage: admin setattitude ") + )); + } + let player_name = parts[0]; + let npc_id = parts[1]; + let value: i32 = match parts[2].parse() { + Ok(v) => v, + Err(_) => { + return simple(&format!( + "{}\r\n", + ansi::error_msg("Value must be a number (-100 to 100).") + )) + } + }; + let value = value.clamp(-100, 100); + + let st = state.lock().await; + if st.db.load_player(player_name).is_none() { + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("Player '{player_name}' not found.")) + )); + } + st.db.save_attitude(player_name, npc_id, value); + let label = crate::world::Attitude::from_value(value).label(); + + simple(&format!( + "{}\r\n", + ansi::system_msg(&format!( + "Set {npc_id} attitude toward {player_name} to {value} ({label})." + )) + )) +} + +async fn admin_list(_player_id: usize, state: &SharedState) -> CommandResult { + let st = state.lock().await; + let all_saved = st.db.list_all_players(); + let online_names: Vec = st.players.values().map(|c| c.player.name.clone()).collect(); + + let mut out = format!("\r\n{}\r\n", ansi::bold("=== All Players ===")); + out.push_str(&format!( + " {:<20} {:<10} {:<10} {:<5} {:<12} {:<6} {}\r\n", + ansi::color(ansi::DIM, "Name"), + ansi::color(ansi::DIM, "Race"), + ansi::color(ansi::DIM, "Class"), + ansi::color(ansi::DIM, "Lvl"), + ansi::color(ansi::DIM, "HP"), + ansi::color(ansi::DIM, "Admin"), + ansi::color(ansi::DIM, "Status"), + )); + + for p in &all_saved { + let status = if online_names.contains(&p.name) { + ansi::color(ansi::GREEN, "online") + } else { + ansi::color(ansi::DIM, "offline") + }; + let admin_str = if p.is_admin { "YES" } else { "no" }; + let name_str = if online_names.contains(&p.name) { + ansi::player_name(&p.name) + } else { + p.name.clone() + }; + out.push_str(&format!( + " {:<20} {:<10} {:<10} {:<5} {:<12} {:<6} {}\r\n", + name_str, + p.race_id.split(':').last().unwrap_or(&p.race_id), + p.class_id.split(':').last().unwrap_or(&p.class_id), + p.level, + format!("{}/{}", p.hp, p.max_hp), + admin_str, + status, + )); + } + out.push_str(&format!( + "{}\r\n", + ansi::system_msg(&format!( + "{} total, {} online", + all_saved.len(), + online_names.len() + )) + )); + + CommandResult { + output: out, + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } +} diff --git a/src/bin/mudtool.rs b/src/bin/mudtool.rs new file mode 100644 index 0000000..1aed7b3 --- /dev/null +++ b/src/bin/mudtool.rs @@ -0,0 +1,625 @@ +use std::io; +use std::path::PathBuf; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::ExecutableCommand; +use ratatui::prelude::*; +use ratatui::widgets::*; + +use mudserver::db::{GameDb, NpcAttitudeRow, SavedPlayer, SqliteDb}; +use mudserver::world::Attitude; + +fn main() { + let args: Vec = std::env::args().collect(); + let mut db_path = PathBuf::from("./mudserver.db"); + let mut cmd_args: Vec = Vec::new(); + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--db" | "-d" => { + i += 1; + db_path = PathBuf::from(args.get(i).expect("--db requires a path")); + } + "--help" | "-h" => { + print_help(); + return; + } + _ => cmd_args.push(args[i].clone()), + } + i += 1; + } + + let db = match SqliteDb::open(&db_path) { + Ok(db) => db, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + }; + + if cmd_args.is_empty() { + print_help(); + return; + } + + match cmd_args[0].as_str() { + "tui" => run_tui(db), + "players" => cmd_players(&db, &cmd_args[1..]), + "settings" => cmd_settings(&db, &cmd_args[1..]), + "attitudes" => cmd_attitudes(&db, &cmd_args[1..]), + _ => { + eprintln!("Unknown command: {}", cmd_args[0]); + print_help(); + } + } +} + +fn print_help() { + eprintln!("mudtool - MUD Server Database Manager"); + eprintln!(); + eprintln!("Usage: mudtool [--db ] [args...]"); + eprintln!(); + eprintln!("Commands:"); + eprintln!(" tui Interactive TUI editor"); + eprintln!(" players list List all players"); + eprintln!(" players show Show player details"); + eprintln!(" players set-admin Set admin flag"); + eprintln!(" players delete Delete a player"); + eprintln!(" settings list List all settings"); + eprintln!(" settings get Get a setting value"); + eprintln!(" settings set Set a setting value"); + eprintln!(" attitudes list List NPC attitudes"); + eprintln!(" attitudes set Set attitude value"); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --db, -d Database path (default: ./mudserver.db)"); +} + +// ============ CLI Commands ============ + +fn cmd_players(db: &SqliteDb, args: &[String]) { + if args.is_empty() { + eprintln!("Usage: mudtool players [args]"); + return; + } + match args[0].as_str() { + "list" | "ls" => { + let players = db.list_all_players(); + if players.is_empty() { + println!("No players found."); + return; + } + println!("{:<20} {:<12} {:<12} {:<5} {:<10} {:<20} {}", "NAME", "RACE", "CLASS", "LVL", "HP", "ROOM", "ADMIN"); + println!("{}", "-".repeat(90)); + for p in &players { + println!("{:<20} {:<12} {:<12} {:<5} {:<10} {:<20} {}", + p.name, short_id(&p.race_id), short_id(&p.class_id), + p.level, format!("{}/{}", p.hp, p.max_hp), p.room_id, + if p.is_admin { "YES" } else { "no" }); + } + println!("\n{} player(s) total.", players.len()); + } + "show" => { + let name = args.get(1).map(|s| s.as_str()).unwrap_or(""); + if name.is_empty() { eprintln!("Usage: mudtool players show "); return; } + match db.load_player(name) { + Some(p) => { + println!("Player: {}", p.name); + println!(" Race: {} | Class: {}", p.race_id, p.class_id); + println!(" Level: {} | XP: {}", p.level, p.xp); + println!(" HP: {}/{} | ATK: {} | DEF: {}", p.hp, p.max_hp, p.attack, p.defense); + println!(" Room: {}", p.room_id); + println!(" Admin: {}", p.is_admin); + println!(" Inventory: {}", p.inventory_json); + if let Some(ref w) = p.equipped_weapon_json { println!(" Weapon: {w}"); } + if let Some(ref a) = p.equipped_armor_json { println!(" Armor: {a}"); } + let attitudes = db.load_attitudes(name); + if !attitudes.is_empty() { + println!(" Attitudes:"); + for att in &attitudes { + println!(" {}: {} ({})", att.npc_id, att.value, Attitude::from_value(att.value).label()); + } + } + } + None => eprintln!("Player '{name}' not found."), + } + } + "set-admin" => { + let name = args.get(1).map(|s| s.as_str()).unwrap_or(""); + let val = args.get(2).map(|s| s.as_str()).unwrap_or(""); + if name.is_empty() || val.is_empty() { eprintln!("Usage: mudtool players set-admin "); return; } + let is_admin = matches!(val, "true" | "1" | "yes"); + if db.set_admin(name, is_admin) { + println!("Set {name} admin = {is_admin}"); + } else { + eprintln!("Player '{name}' not found."); + } + } + "delete" => { + let name = args.get(1).map(|s| s.as_str()).unwrap_or(""); + if name.is_empty() { eprintln!("Usage: mudtool players delete "); return; } + if db.load_player(name).is_some() { + db.delete_player(name); + println!("Deleted player '{name}' and their attitudes."); + } else { + eprintln!("Player '{name}' not found."); + } + } + _ => eprintln!("Unknown subcommand: players {}", args[0]), + } +} + +fn cmd_settings(db: &SqliteDb, args: &[String]) { + if args.is_empty() { + eprintln!("Usage: mudtool settings [args]"); + return; + } + match args[0].as_str() { + "list" | "ls" => { + let settings = db.list_settings(); + if settings.is_empty() { println!("No settings configured."); return; } + println!("{:<30} {}", "KEY", "VALUE"); + println!("{}", "-".repeat(50)); + for (k, v) in &settings { println!("{:<30} {v}", k); } + } + "get" => { + let key = args.get(1).map(|s| s.as_str()).unwrap_or(""); + if key.is_empty() { eprintln!("Usage: mudtool settings get "); return; } + match db.get_setting(key) { + Some(v) => println!("{key} = {v}"), + None => println!("{key} is not set (will use default)."), + } + } + "set" => { + let key = args.get(1).map(|s| s.as_str()).unwrap_or(""); + let val = args.get(2).map(|s| s.as_str()).unwrap_or(""); + if key.is_empty() || val.is_empty() { eprintln!("Usage: mudtool settings set "); return; } + db.set_setting(key, val); + println!("Set {key} = {val}"); + } + _ => eprintln!("Unknown subcommand: settings {}", args[0]), + } +} + +fn cmd_attitudes(db: &SqliteDb, args: &[String]) { + if args.is_empty() { + eprintln!("Usage: mudtool attitudes [args]"); + return; + } + match args[0].as_str() { + "list" | "ls" => { + let player = args.get(1).map(|s| s.as_str()).unwrap_or(""); + if player.is_empty() { eprintln!("Usage: mudtool attitudes list "); return; } + let attitudes = db.load_attitudes(player); + if attitudes.is_empty() { println!("No attitudes for '{player}'."); return; } + println!("Attitudes for {player}:"); + println!("{:<30} {:<8} {}", "NPC", "VALUE", "LABEL"); + println!("{}", "-".repeat(50)); + for att in &attitudes { + println!("{:<30} {:<8} {}", att.npc_id, att.value, Attitude::from_value(att.value).label()); + } + } + "set" => { + let player = args.get(1).map(|s| s.as_str()).unwrap_or(""); + let npc = args.get(2).map(|s| s.as_str()).unwrap_or(""); + let val = args.get(3).and_then(|s| s.parse::().ok()); + if player.is_empty() || npc.is_empty() || val.is_none() { + eprintln!("Usage: mudtool attitudes set "); + return; + } + let v = val.unwrap().clamp(-100, 100); + db.save_attitude(player, npc, v); + println!("Set {npc} attitude toward {player} = {v} ({})", Attitude::from_value(v).label()); + } + _ => eprintln!("Unknown subcommand: attitudes {}", args[0]), + } +} + +fn short_id(id: &str) -> &str { + id.split(':').last().unwrap_or(id) +} + +// ============ TUI ============ + +struct App { + db: SqliteDb, + tab: usize, + running: bool, + + players: Vec, + player_state: TableState, + + settings: Vec<(String, String)>, + setting_state: TableState, + + attitude_players: Vec, + attitude_player_idx: usize, + attitudes: Vec, + attitude_state: TableState, + + mode: AppMode, + input_buf: String, + status: String, +} + +#[derive(PartialEq)] +enum AppMode { + Normal, + EditSetting { key: String }, + EditAttitude { npc_id: String }, + ConfirmDelete { name: String }, +} + +impl App { + fn new(db: SqliteDb) -> Self { + let mut app = App { + db, + tab: 0, + running: true, + players: Vec::new(), + player_state: TableState::default(), + settings: Vec::new(), + setting_state: TableState::default(), + attitude_players: Vec::new(), + attitude_player_idx: 0, + attitudes: Vec::new(), + attitude_state: TableState::default(), + mode: AppMode::Normal, + input_buf: String::new(), + status: String::new(), + }; + app.refresh_all(); + app + } + + fn refresh_all(&mut self) { + self.players = self.db.list_all_players(); + if !self.players.is_empty() && self.player_state.selected().is_none() { + self.player_state.select(Some(0)); + } + self.settings = self.db.list_settings(); + if !self.settings.is_empty() && self.setting_state.selected().is_none() { + self.setting_state.select(Some(0)); + } + self.attitude_players = self.players.iter().map(|p| p.name.clone()).collect(); + self.refresh_attitudes(); + } + + fn refresh_attitudes(&mut self) { + if let Some(name) = self.attitude_players.get(self.attitude_player_idx) { + self.attitudes = self.db.load_attitudes(name); + } else { + self.attitudes.clear(); + } + if !self.attitudes.is_empty() && self.attitude_state.selected().is_none() { + self.attitude_state.select(Some(0)); + } + } + + fn selected_player(&self) -> Option<&SavedPlayer> { + self.player_state.selected().and_then(|i| self.players.get(i)) + } +} + +fn run_tui(db: SqliteDb) { + enable_raw_mode().unwrap(); + io::stdout().execute(EnterAlternateScreen).unwrap(); + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend).unwrap(); + + let mut app = App::new(db); + + while app.running { + terminal.draw(|f| ui(f, &mut app)).unwrap(); + if event::poll(Duration::from_millis(100)).unwrap() { + if let Event::Key(key) = event::read().unwrap() { + handle_key(&mut app, key); + } + } + } + + disable_raw_mode().unwrap(); + io::stdout().execute(LeaveAlternateScreen).unwrap(); +} + +fn ui(f: &mut Frame, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(3), + ]) + .split(f.area()); + + // Tab bar + let tabs = Tabs::new(vec!["[1] Players", "[2] Settings", "[3] Attitudes"]) + .select(app.tab) + .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) + .block(Block::default().borders(Borders::ALL).title(" MUD Tool ")); + f.render_widget(tabs, chunks[0]); + + // Content + match app.tab { + 0 => render_players(f, app, chunks[1]), + 1 => render_settings(f, app, chunks[1]), + 2 => render_attitudes(f, app, chunks[1]), + _ => {} + } + + // Status bar + let status_text = if app.mode != AppMode::Normal { + match &app.mode { + AppMode::EditSetting { key } => format!("Edit {key}: {}_", app.input_buf), + AppMode::EditAttitude { npc_id } => format!("Set {npc_id} value: {}_", app.input_buf), + AppMode::ConfirmDelete { name } => format!("Delete '{name}'? (y/n)"), + _ => String::new(), + } + } else if !app.status.is_empty() { + app.status.clone() + } else { + match app.tab { + 0 => "↑↓ nav | a toggle admin | d delete | 1/2/3 tabs | q quit".into(), + 1 => "↑↓ nav | Enter edit | n new | d delete | 1/2/3 tabs | q quit".into(), + 2 => "↑↓ nav | Enter edit | ←→ player | 1/2/3 tabs | q quit".into(), + _ => String::new(), + } + }; + let status = Paragraph::new(status_text) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(status, chunks[2]); +} + +fn render_players(f: &mut Frame, app: &mut App, area: Rect) { + let header = Row::new(vec!["Name", "Race", "Class", "Lvl", "HP", "Room", "Admin"]) + .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + + let rows: Vec = app.players.iter().map(|p| { + Row::new(vec![ + p.name.clone(), + short_id(&p.race_id).to_string(), + short_id(&p.class_id).to_string(), + p.level.to_string(), + format!("{}/{}", p.hp, p.max_hp), + p.room_id.clone(), + if p.is_admin { "YES".into() } else { "no".into() }, + ]) + }).collect(); + + let widths = [ + Constraint::Min(18), Constraint::Length(10), Constraint::Length(10), + Constraint::Length(5), Constraint::Length(10), Constraint::Min(18), + Constraint::Length(6), + ]; + let table = Table::new(rows, widths) + .header(header) + .row_highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) + .highlight_symbol("▸ ") + .block(Block::default().borders(Borders::ALL).title(format!(" Players ({}) ", app.players.len()))); + + f.render_stateful_widget(table, area, &mut app.player_state); +} + +fn render_settings(f: &mut Frame, app: &mut App, area: Rect) { + let header = Row::new(vec!["Key", "Value"]) + .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + + let rows: Vec = app.settings.iter().map(|(k, v)| { + Row::new(vec![k.clone(), v.clone()]) + }).collect(); + + let widths = [Constraint::Min(30), Constraint::Min(30)]; + let table = Table::new(rows, widths) + .header(header) + .row_highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) + .highlight_symbol("▸ ") + .block(Block::default().borders(Borders::ALL).title(format!(" Settings ({}) ", app.settings.len()))); + + f.render_stateful_widget(table, area, &mut app.setting_state); +} + +fn render_attitudes(f: &mut Frame, app: &mut App, area: Rect) { + let player_name = app.attitude_players.get(app.attitude_player_idx) + .cloned().unwrap_or_else(|| "(none)".into()); + + let header = Row::new(vec!["NPC", "Value", "Attitude"]) + .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + + let rows: Vec = app.attitudes.iter().map(|att| { + let label = Attitude::from_value(att.value).label().to_string(); + Row::new(vec![att.npc_id.clone(), att.value.to_string(), label]) + }).collect(); + + let widths = [Constraint::Min(30), Constraint::Length(8), Constraint::Length(12)]; + let title = format!(" Attitudes for: {} (←→ {}/{}) ", player_name, + app.attitude_player_idx + 1, app.attitude_players.len().max(1)); + let table = Table::new(rows, widths) + .header(header) + .row_highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) + .highlight_symbol("▸ ") + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_stateful_widget(table, area, &mut app.attitude_state); +} + +fn handle_key(app: &mut App, key: event::KeyEvent) { + // Handle input modes first + match &app.mode { + AppMode::EditSetting { .. } | AppMode::EditAttitude { .. } => { + match key.code { + KeyCode::Enter => { + let val = app.input_buf.clone(); + match std::mem::replace(&mut app.mode, AppMode::Normal) { + AppMode::EditSetting { key } => { + app.db.set_setting(&key, &val); + app.status = format!("Set {key} = {val}"); + } + AppMode::EditAttitude { npc_id } => { + if let Ok(v) = val.parse::() { + let v = v.clamp(-100, 100); + if let Some(pname) = app.attitude_players.get(app.attitude_player_idx) { + app.db.save_attitude(pname, &npc_id, v); + app.status = format!("Set {npc_id} = {v}"); + } + } else { + app.status = "Invalid number.".into(); + } + } + _ => {} + } + app.input_buf.clear(); + app.refresh_all(); + } + KeyCode::Esc => { + app.mode = AppMode::Normal; + app.input_buf.clear(); + app.status.clear(); + } + KeyCode::Backspace => { app.input_buf.pop(); } + KeyCode::Char(c) => app.input_buf.push(c), + _ => {} + } + return; + } + AppMode::ConfirmDelete { .. } => { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + if let AppMode::ConfirmDelete { name } = std::mem::replace(&mut app.mode, AppMode::Normal) { + app.db.delete_player(&name); + app.status = format!("Deleted {name}."); + app.player_state.select(Some(0)); + app.refresh_all(); + } + } + _ => { + app.mode = AppMode::Normal; + app.status = "Cancelled.".into(); + } + } + return; + } + AppMode::Normal => {} + } + + // Normal mode + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + app.running = false; + return; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => app.running = false, + KeyCode::Char('1') => { app.tab = 0; app.status.clear(); } + KeyCode::Char('2') => { app.tab = 1; app.status.clear(); } + KeyCode::Char('3') => { app.tab = 2; app.status.clear(); } + KeyCode::Tab => { app.tab = (app.tab + 1) % 3; app.status.clear(); } + KeyCode::Up => nav_up(app), + KeyCode::Down => nav_down(app), + KeyCode::Left => { + if app.tab == 2 && !app.attitude_players.is_empty() { + app.attitude_player_idx = app.attitude_player_idx.checked_sub(1) + .unwrap_or(app.attitude_players.len() - 1); + app.attitude_state.select(Some(0)); + app.refresh_attitudes(); + } + } + KeyCode::Right => { + if app.tab == 2 && !app.attitude_players.is_empty() { + app.attitude_player_idx = (app.attitude_player_idx + 1) % app.attitude_players.len(); + app.attitude_state.select(Some(0)); + app.refresh_attitudes(); + } + } + KeyCode::Char('a') if app.tab == 0 => { + if let Some(p) = app.selected_player() { + let name = p.name.clone(); + let new_val = !p.is_admin; + app.db.set_admin(&name, new_val); + app.status = format!("{name} admin = {new_val}"); + app.refresh_all(); + } + } + KeyCode::Char('d') if app.tab == 0 => { + if let Some(p) = app.selected_player() { + app.mode = AppMode::ConfirmDelete { name: p.name.clone() }; + } + } + KeyCode::Char('d') if app.tab == 1 => { + if let Some(idx) = app.setting_state.selected() { + if let Some((key, _)) = app.settings.get(idx) { + let key = key.clone(); + // Delete by setting empty then removing via raw SQL isn't in trait, just set "" + app.db.set_setting(&key, ""); + app.status = format!("Cleared {key}"); + app.refresh_all(); + } + } + } + KeyCode::Enter => { + match app.tab { + 1 => { + if let Some(idx) = app.setting_state.selected() { + if let Some((key, val)) = app.settings.get(idx) { + app.mode = AppMode::EditSetting { key: key.clone() }; + app.input_buf = val.clone(); + } + } + } + 2 => { + if let Some(idx) = app.attitude_state.selected() { + if let Some(att) = app.attitudes.get(idx) { + app.mode = AppMode::EditAttitude { npc_id: att.npc_id.clone() }; + app.input_buf = att.value.to_string(); + } + } + } + _ => {} + } + } + KeyCode::Char('n') if app.tab == 1 => { + app.mode = AppMode::EditSetting { key: "new_key".into() }; + app.input_buf.clear(); + app.status = "Enter value for 'new_key' (rename key later with CLI):".into(); + } + _ => {} + } +} + +fn nav_up(app: &mut App) { + let state = match app.tab { + 0 => &mut app.player_state, + 1 => &mut app.setting_state, + 2 => &mut app.attitude_state, + _ => return, + }; + let len = match app.tab { + 0 => app.players.len(), + 1 => app.settings.len(), + 2 => app.attitudes.len(), + _ => 0, + }; + if len == 0 { return; } + let i = state.selected().unwrap_or(0); + state.select(Some(if i == 0 { len - 1 } else { i - 1 })); +} + +fn nav_down(app: &mut App) { + let state = match app.tab { + 0 => &mut app.player_state, + 1 => &mut app.setting_state, + 2 => &mut app.attitude_state, + _ => return, + }; + let len = match app.tab { + 0 => app.players.len(), + 1 => app.settings.len(), + 2 => app.attitudes.len(), + _ => 0, + }; + if len == 0 { return; } + let i = state.selected().unwrap_or(0); + state.select(Some((i + 1) % len)); +} diff --git a/src/commands.rs b/src/commands.rs index 8b6b771..a7b35e1 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,6 +1,7 @@ use russh::server::Session; use russh::{ChannelId, CryptoVec}; +use crate::admin; use crate::ansi; use crate::combat; use crate::game::{CombatState, SharedState}; @@ -12,27 +13,48 @@ pub struct BroadcastMsg { pub data: CryptoVec, } +pub struct KickTarget { + pub channel: ChannelId, + pub handle: russh::server::Handle, +} + pub struct CommandResult { pub output: String, pub broadcasts: Vec, + pub kick_targets: Vec, pub quit: bool, } const DIR_ALIASES: &[(&str, &str)] = &[ - ("n","north"),("s","south"),("e","east"),("w","west"),("u","up"),("d","down"), + ("n", "north"), + ("s", "south"), + ("e", "east"), + ("w", "west"), + ("u", "up"), + ("d", "down"), ]; fn resolve_dir(input: &str) -> &str { - for &(a, f) in DIR_ALIASES { if input == a { return f; } } + for &(a, f) in DIR_ALIASES { + if input == a { + return f; + } + } input } pub async fn execute( - input: &str, player_id: usize, state: &SharedState, - session: &mut Session, channel: ChannelId, + input: &str, + player_id: usize, + state: &SharedState, + session: &mut Session, + channel: ChannelId, ) -> Result { let input = input.trim(); - if input.is_empty() { send(session, channel, &ansi::prompt())?; return Ok(true); } + if input.is_empty() { + send(session, channel, &ansi::prompt())?; + return Ok(true); + } let (cmd, args) = match input.split_once(' ') { Some((c, a)) => (c.to_lowercase(), a.trim().to_string()), @@ -40,40 +62,73 @@ pub async fn execute( }; // Combat lockout - { let st = state.lock().await; - if let Some(conn) = st.players.get(&player_id) { - if conn.combat.is_some() && !matches!(cmd.as_str(), "attack"|"a"|"flee"|"look"|"l"|"quit"|"exit") { - drop(st); - send(session, channel, &format!("{}\r\n{}", ansi::error_msg("You're in combat! Use 'attack', 'flee', or 'look'."), ansi::prompt()))?; - return Ok(true); + { + let st = state.lock().await; + if let Some(conn) = st.players.get(&player_id) { + if conn.combat.is_some() + && !matches!( + cmd.as_str(), + "attack" | "a" | "flee" | "look" | "l" | "quit" | "exit" + ) + { + drop(st); + send( + session, + channel, + &format!( + "{}\r\n{}", + ansi::error_msg("You're in combat! Use 'attack', 'flee', or 'look'."), + ansi::prompt() + ), + )?; + return Ok(true); + } } - }} + } let result = match cmd.as_str() { - "look"|"l" => cmd_look(player_id, state).await, + "look" | "l" => cmd_look(player_id, state).await, "go" => cmd_go(player_id, &args, state).await, - "north"|"south"|"east"|"west"|"up"|"down"|"n"|"s"|"e"|"w"|"u"|"d" => - cmd_go(player_id, resolve_dir(&cmd), state).await, - "say"|"'" => cmd_say(player_id, &args, state).await, + "north" | "south" | "east" | "west" | "up" | "down" | "n" | "s" | "e" | "w" | "u" + | "d" => cmd_go(player_id, resolve_dir(&cmd), state).await, + "say" | "'" => cmd_say(player_id, &args, state).await, "who" => cmd_who(player_id, state).await, - "take"|"get" => cmd_take(player_id, &args, state).await, + "take" | "get" => cmd_take(player_id, &args, state).await, "drop" => cmd_drop(player_id, &args, state).await, - "inventory"|"inv"|"i" => cmd_inventory(player_id, state).await, - "equip"|"eq" => cmd_equip(player_id, &args, state).await, + "inventory" | "inv" | "i" => cmd_inventory(player_id, state).await, + "equip" | "eq" => cmd_equip(player_id, &args, state).await, "use" => cmd_use(player_id, &args, state).await, - "examine"|"ex"|"x" => cmd_examine(player_id, &args, state).await, + "examine" | "ex" | "x" => cmd_examine(player_id, &args, state).await, "talk" => cmd_talk(player_id, &args, state).await, - "attack"|"a" => cmd_attack(player_id, &args, state).await, + "attack" | "a" => cmd_attack(player_id, &args, state).await, "flee" => cmd_flee(player_id, state).await, - "stats"|"st" => cmd_stats(player_id, state).await, - "help"|"h"|"?" => cmd_help(), - "quit"|"exit" => CommandResult { output: format!("{}\r\n", ansi::system_msg("Farewell, adventurer...")), broadcasts: Vec::new(), quit: true }, - _ => simple(&format!("{}\r\n", ansi::error_msg(&format!("Unknown command: '{cmd}'. Type 'help' for commands.")))), + "stats" | "st" => cmd_stats(player_id, state).await, + "admin" => cmd_admin(player_id, &args, state).await, + "help" | "h" | "?" => cmd_help(player_id, state).await, + "quit" | "exit" => CommandResult { + output: format!("{}\r\n", ansi::system_msg("Farewell, adventurer...")), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: true, + }, + _ => simple(&format!( + "{}\r\n", + ansi::error_msg(&format!( + "Unknown command: '{cmd}'. Type 'help' for commands." + )) + )), }; send(session, channel, &result.output)?; - for msg in result.broadcasts { let _ = msg.handle.data(msg.channel, msg.data).await; } - if result.quit { return Ok(false); } + for msg in result.broadcasts { + let _ = msg.handle.data(msg.channel, msg.data).await; + } + for kick in result.kick_targets { + let _ = kick.handle.close(kick.channel).await; + } + if result.quit { + return Ok(false); + } send(session, channel, &ansi::prompt())?; Ok(true) } @@ -91,52 +146,102 @@ fn attitude_color(att: Attitude) -> &'static str { } } -fn render_room_view(room_id: &str, player_id: usize, st: &crate::game::GameState) -> String { +pub fn render_room_view( + room_id: &str, + player_id: usize, + st: &crate::game::GameState, +) -> String { let room = match st.world.get_room(room_id) { Some(r) => r, None => return format!("{}\r\n", ansi::error_msg("You are in the void.")), }; - let player_name = st.players.get(&player_id).map(|c| c.player.name.as_str()).unwrap_or(""); + let player_name = st + .players + .get(&player_id) + .map(|c| c.player.name.as_str()) + .unwrap_or(""); - let mut out = format!("\r\n{} {}\r\n {}\r\n", - ansi::room_name(&room.name), ansi::system_msg(&format!("[{}]", room.region)), room.description); + let mut out = format!( + "\r\n{} {}\r\n {}\r\n", + ansi::room_name(&room.name), + ansi::system_msg(&format!("[{}]", room.region)), + room.description + ); - let npc_strs: Vec = room.npcs.iter().filter_map(|id| { - let npc = st.world.get_npc(id)?; - if !st.npc_instances.get(id).map(|i| i.alive).unwrap_or(true) { return None; } - let att = st.npc_attitude_toward(id, player_name); - Some(ansi::color(attitude_color(att), &npc.name)) - }).collect(); + let npc_strs: Vec = room + .npcs + .iter() + .filter_map(|id| { + let npc = st.world.get_npc(id)?; + if !st.npc_instances.get(id).map(|i| i.alive).unwrap_or(true) { + return None; + } + let att = st.npc_attitude_toward(id, player_name); + Some(ansi::color(attitude_color(att), &npc.name)) + }) + .collect(); if !npc_strs.is_empty() { - out.push_str(&format!("\r\n{}{}\r\n", ansi::color(ansi::DIM, "Present: "), npc_strs.join(", "))); + out.push_str(&format!( + "\r\n{}{}\r\n", + ansi::color(ansi::DIM, "Present: "), + npc_strs.join(", ") + )); } - let obj_strs: Vec = room.objects.iter() + let obj_strs: Vec = room + .objects + .iter() .filter_map(|id| st.world.get_object(id)) - .map(|o| ansi::color(ansi::CYAN, &o.name)).collect(); + .map(|o| ansi::color(ansi::CYAN, &o.name)) + .collect(); if !obj_strs.is_empty() { - out.push_str(&format!("{}{}\r\n", ansi::color(ansi::DIM, "You see: "), obj_strs.join(", "))); + out.push_str(&format!( + "{}{}\r\n", + ansi::color(ansi::DIM, "You see: "), + obj_strs.join(", ") + )); } let others = st.players_in_room(room_id, player_id); if !others.is_empty() { - let names: Vec = others.iter().map(|c| ansi::player_name(&c.player.name)).collect(); - out.push_str(&format!("{}{}\r\n", ansi::color(ansi::GREEN, "Players here: "), names.join(", "))); + let names: Vec = others + .iter() + .map(|c| ansi::player_name(&c.player.name)) + .collect(); + out.push_str(&format!( + "{}{}\r\n", + ansi::color(ansi::GREEN, "Players here: "), + names.join(", ") + )); } if !room.exits.is_empty() { let mut dirs: Vec<&String> = room.exits.keys().collect(); dirs.sort(); - out.push_str(&format!("{} {}\r\n", ansi::color(ansi::DIM, "Exits:"), - dirs.iter().map(|d| ansi::direction(d)).collect::>().join(", "))); + out.push_str(&format!( + "{} {}\r\n", + ansi::color(ansi::DIM, "Exits:"), + dirs.iter() + .map(|d| ansi::direction(d)) + .collect::>() + .join(", ") + )); } out } async fn cmd_look(pid: usize, state: &SharedState) -> CommandResult { let st = state.lock().await; - let rid = match st.players.get(&pid) { Some(c) => c.player.room_id.clone(), None => return simple("Error\r\n") }; - CommandResult { output: render_room_view(&rid, pid, &st), broadcasts: Vec::new(), quit: false } + let rid = match st.players.get(&pid) { + Some(c) => c.player.room_id.clone(), + None => return simple("Error\r\n"), + }; + CommandResult { + output: render_room_view(&rid, pid, &st), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } } async fn cmd_go(pid: usize, direction: &str, state: &SharedState) -> CommandResult { @@ -145,143 +250,397 @@ async fn cmd_go(pid: usize, direction: &str, state: &SharedState) -> CommandResu let mut st = state.lock().await; let (old_rid, new_rid, pname) = { - let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n") }; - let room = match st.world.get_room(&conn.player.room_id) { Some(r) => r, None => return simple("Void\r\n") }; + let conn = match st.players.get(&pid) { + Some(c) => c, + None => return simple("Error\r\n"), + }; + let room = match st.world.get_room(&conn.player.room_id) { + Some(r) => r, + None => return simple("Void\r\n"), + }; match room.exits.get(direction) { - Some(dest) => (conn.player.room_id.clone(), dest.clone(), conn.player.name.clone()), - None => return simple(&format!("{}\r\n", ansi::error_msg(&format!("You can't go {direction}.")))), + Some(dest) => ( + conn.player.room_id.clone(), + dest.clone(), + conn.player.name.clone(), + ), + None => { + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("You can't go {direction}.")) + )) + } } }; - let leave = CryptoVec::from(format!("{}\r\n{}", ansi::system_msg(&format!("{pname} heads {direction}.")), ansi::prompt()).as_bytes()); + let leave = CryptoVec::from( + format!( + "{}\r\n{}", + ansi::system_msg(&format!("{pname} heads {direction}.")), + ansi::prompt() + ) + .as_bytes(), + ); let mut bcast = Vec::new(); - for c in st.players_in_room(&old_rid, pid) { bcast.push(BroadcastMsg { channel: c.channel, handle: c.handle.clone(), data: leave.clone() }); } + for c in st.players_in_room(&old_rid, pid) { + bcast.push(BroadcastMsg { + channel: c.channel, + handle: c.handle.clone(), + data: leave.clone(), + }); + } - if let Some(c) = st.players.get_mut(&pid) { c.player.room_id = new_rid.clone(); } + if let Some(c) = st.players.get_mut(&pid) { + c.player.room_id = new_rid.clone(); + } - let arrive = CryptoVec::from(format!("\r\n{}\r\n{}", ansi::system_msg(&format!("{pname} arrives.")), ansi::prompt()).as_bytes()); - for c in st.players_in_room(&new_rid, pid) { bcast.push(BroadcastMsg { channel: c.channel, handle: c.handle.clone(), data: arrive.clone() }); } + let arrive = CryptoVec::from( + format!( + "\r\n{}\r\n{}", + ansi::system_msg(&format!("{pname} arrives.")), + ansi::prompt() + ) + .as_bytes(), + ); + for c in st.players_in_room(&new_rid, pid) { + bcast.push(BroadcastMsg { + channel: c.channel, + handle: c.handle.clone(), + data: arrive.clone(), + }); + } st.save_player_to_db(pid); let output = render_room_view(&new_rid, pid, &st); - CommandResult { output, broadcasts: bcast, quit: false } + CommandResult { + output, + broadcasts: bcast, + kick_targets: Vec::new(), + quit: false, + } } async fn cmd_say(pid: usize, msg: &str, state: &SharedState) -> CommandResult { - if msg.is_empty() { return simple(&format!("{}\r\n", ansi::error_msg("Say what?"))); } + if msg.is_empty() { + return simple(&format!("{}\r\n", ansi::error_msg("Say what?"))); + } let st = state.lock().await; - let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n") }; + let conn = match st.players.get(&pid) { + Some(c) => c, + None => return simple("Error\r\n"), + }; let name = conn.player.name.clone(); let rid = conn.player.room_id.clone(); - let self_msg = format!("{}You say: {}{}\r\n", ansi::BOLD, ansi::RESET, ansi::color(ansi::WHITE, msg)); - let other = CryptoVec::from(format!("\r\n{} says: {}{}\r\n{}", ansi::player_name(&name), ansi::RESET, ansi::color(ansi::WHITE, msg), ansi::prompt()).as_bytes()); - let bcast: Vec<_> = st.players_in_room(&rid, pid).iter().map(|c| BroadcastMsg { channel: c.channel, handle: c.handle.clone(), data: other.clone() }).collect(); - CommandResult { output: self_msg, broadcasts: bcast, quit: false } + let self_msg = format!( + "{}You say: {}{}\r\n", + ansi::BOLD, + ansi::RESET, + ansi::color(ansi::WHITE, msg) + ); + let other = CryptoVec::from( + format!( + "\r\n{} says: {}{}\r\n{}", + ansi::player_name(&name), + ansi::RESET, + ansi::color(ansi::WHITE, msg), + ansi::prompt() + ) + .as_bytes(), + ); + let bcast: Vec<_> = st + .players_in_room(&rid, pid) + .iter() + .map(|c| BroadcastMsg { + channel: c.channel, + handle: c.handle.clone(), + data: other.clone(), + }) + .collect(); + CommandResult { + output: self_msg, + broadcasts: bcast, + kick_targets: Vec::new(), + quit: false, + } } async fn cmd_who(pid: usize, state: &SharedState) -> CommandResult { let st = state.lock().await; - let sn = st.players.get(&pid).map(|c| c.player.name.clone()).unwrap_or_default(); + let sn = st + .players + .get(&pid) + .map(|c| c.player.name.clone()) + .unwrap_or_default(); let mut out = format!("\r\n{}\r\n", ansi::bold("=== Who's Online ===")); for c in st.players.values() { - let rn = st.world.get_room(&c.player.room_id).map(|r| r.name.as_str()).unwrap_or("???"); + let rn = st + .world + .get_room(&c.player.room_id) + .map(|r| r.name.as_str()) + .unwrap_or("???"); let m = if c.player.name == sn { " (you)" } else { "" }; - out.push_str(&format!(" {} — {}{}\r\n", ansi::player_name(&c.player.name), ansi::room_name(rn), ansi::system_msg(m))); + let admin_tag = if c.player.is_admin { " [ADMIN]" } else { "" }; + out.push_str(&format!( + " {} — {}{}{}\r\n", + ansi::player_name(&c.player.name), + ansi::room_name(rn), + ansi::system_msg(m), + ansi::color(ansi::YELLOW, admin_tag), + )); + } + out.push_str(&format!( + "{}\r\n", + ansi::system_msg(&format!("{} player(s) online", st.players.len())) + )); + CommandResult { + output: out, + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, } - out.push_str(&format!("{}\r\n", ansi::system_msg(&format!("{} player(s) online", st.players.len())))); - CommandResult { output: out, broadcasts: Vec::new(), quit: false } } async fn cmd_take(pid: usize, target: &str, state: &SharedState) -> CommandResult { - if target.is_empty() { return simple("Take what?\r\n"); } + if target.is_empty() { + return simple("Take what?\r\n"); + } let mut st = state.lock().await; - let rid = match st.players.get(&pid) { Some(c) => c.player.room_id.clone(), None => return simple("Error\r\n") }; - let room = match st.world.rooms.get(&rid) { Some(r) => r, None => return simple("Void\r\n") }; - let low = target.to_lowercase(); - let oid = match room.objects.iter().find(|id| st.world.get_object(id).map(|o| o.name.to_lowercase().contains(&low)).unwrap_or(false)) { - Some(id) => id.clone(), None => return simple(&format!("{}\r\n", ansi::error_msg(&format!("You don't see '{target}' here.")))), + let rid = match st.players.get(&pid) { + Some(c) => c.player.room_id.clone(), + None => return simple("Error\r\n"), }; - let obj = match st.world.get_object(&oid) { Some(o) => o.clone(), None => return simple("Gone.\r\n") }; - if !obj.takeable { return simple(&format!("{}\r\n", ansi::error_msg(&format!("You can't take the {}.", obj.name)))); } - if let Some(room) = st.world.rooms.get_mut(&rid) { room.objects.retain(|id| id != &oid); } - if let Some(c) = st.players.get_mut(&pid) { c.player.inventory.push(obj.clone()); } + let room = match st.world.rooms.get(&rid) { + Some(r) => r, + None => return simple("Void\r\n"), + }; + let low = target.to_lowercase(); + let oid = match room.objects.iter().find(|id| { + st.world + .get_object(id) + .map(|o| o.name.to_lowercase().contains(&low)) + .unwrap_or(false) + }) { + Some(id) => id.clone(), + None => { + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("You don't see '{target}' here.")) + )) + } + }; + let obj = match st.world.get_object(&oid) { + Some(o) => o.clone(), + None => return simple("Gone.\r\n"), + }; + if !obj.takeable { + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("You can't take the {}.", obj.name)) + )); + } + if let Some(room) = st.world.rooms.get_mut(&rid) { + room.objects.retain(|id| id != &oid); + } + if let Some(c) = st.players.get_mut(&pid) { + c.player.inventory.push(obj.clone()); + } st.save_player_to_db(pid); - CommandResult { output: format!("You pick up the {}.\r\n", ansi::color(ansi::CYAN, &obj.name)), broadcasts: Vec::new(), quit: false } + CommandResult { + output: format!( + "You pick up the {}.\r\n", + ansi::color(ansi::CYAN, &obj.name) + ), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } } async fn cmd_drop(pid: usize, target: &str, state: &SharedState) -> CommandResult { - if target.is_empty() { return simple("Drop what?\r\n"); } + if target.is_empty() { + return simple("Drop what?\r\n"); + } let mut st = state.lock().await; - let conn = match st.players.get_mut(&pid) { Some(c) => c, None => return simple("Error\r\n") }; + let conn = match st.players.get_mut(&pid) { + Some(c) => c, + None => return simple("Error\r\n"), + }; let low = target.to_lowercase(); - let idx = match conn.player.inventory.iter().position(|o| o.name.to_lowercase().contains(&low)) { - Some(i) => i, None => return simple(&format!("{}\r\n", ansi::error_msg(&format!("You don't have '{target}'.")))), + let idx = match conn + .player + .inventory + .iter() + .position(|o| o.name.to_lowercase().contains(&low)) + { + Some(i) => i, + None => { + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("You don't have '{target}'.")) + )) + } }; let obj = conn.player.inventory.remove(idx); - let name = obj.name.clone(); let oid = obj.id.clone(); + let name = obj.name.clone(); + let oid = obj.id.clone(); let rid = conn.player.room_id.clone(); - if let Some(room) = st.world.rooms.get_mut(&rid) { room.objects.push(oid); } + if let Some(room) = st.world.rooms.get_mut(&rid) { + room.objects.push(oid); + } st.save_player_to_db(pid); - CommandResult { output: format!("You drop the {}.\r\n", ansi::color(ansi::CYAN, &name)), broadcasts: Vec::new(), quit: false } + CommandResult { + output: format!("You drop the {}.\r\n", ansi::color(ansi::CYAN, &name)), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } } async fn cmd_inventory(pid: usize, state: &SharedState) -> CommandResult { let st = state.lock().await; - let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n") }; + let conn = match st.players.get(&pid) { + Some(c) => c, + None => return simple("Error\r\n"), + }; let mut out = format!("\r\n{}\r\n", ansi::bold("=== Inventory ===")); if let Some(ref w) = conn.player.equipped_weapon { - out.push_str(&format!(" Weapon: {} {}\r\n", ansi::color(ansi::CYAN, &w.name), ansi::system_msg(&format!("(+{} dmg)", w.stats.damage.unwrap_or(0))))); + out.push_str(&format!( + " Weapon: {} {}\r\n", + ansi::color(ansi::CYAN, &w.name), + ansi::system_msg(&format!("(+{} dmg)", w.stats.damage.unwrap_or(0))) + )); } if let Some(ref a) = conn.player.equipped_armor { - out.push_str(&format!(" Armor: {} {}\r\n", ansi::color(ansi::CYAN, &a.name), ansi::system_msg(&format!("(+{} def)", a.stats.armor.unwrap_or(0))))); + out.push_str(&format!( + " Armor: {} {}\r\n", + ansi::color(ansi::CYAN, &a.name), + ansi::system_msg(&format!("(+{} def)", a.stats.armor.unwrap_or(0))) + )); + } + if conn.player.inventory.is_empty() { + out.push_str(&format!(" {}\r\n", ansi::system_msg("(empty)"))); + } else { + for o in &conn.player.inventory { + let k = o + .kind + .as_deref() + .map(|k| format!(" [{}]", k)) + .unwrap_or_default(); + out.push_str(&format!( + " {} {}\r\n", + ansi::color(ansi::CYAN, &o.name), + ansi::system_msg(&k) + )); + } + } + CommandResult { + output: out, + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, } - if conn.player.inventory.is_empty() { out.push_str(&format!(" {}\r\n", ansi::system_msg("(empty)"))); } - else { for o in &conn.player.inventory { - let k = o.kind.as_deref().map(|k| format!(" [{}]", k)).unwrap_or_default(); - out.push_str(&format!(" {} {}\r\n", ansi::color(ansi::CYAN, &o.name), ansi::system_msg(&k))); - }} - CommandResult { output: out, broadcasts: Vec::new(), quit: false } } async fn cmd_equip(pid: usize, target: &str, state: &SharedState) -> CommandResult { - if target.is_empty() { return simple("Equip what?\r\n"); } + if target.is_empty() { + return simple("Equip what?\r\n"); + } let mut st = state.lock().await; - let conn = match st.players.get_mut(&pid) { Some(c) => c, None => return simple("Error\r\n") }; + let conn = match st.players.get_mut(&pid) { + Some(c) => c, + None => return simple("Error\r\n"), + }; let low = target.to_lowercase(); - let idx = match conn.player.inventory.iter().position(|o| o.name.to_lowercase().contains(&low)) { - Some(i) => i, None => return simple(&format!("{}\r\n", ansi::error_msg(&format!("You don't have '{target}'.")))), + let idx = match conn + .player + .inventory + .iter() + .position(|o| o.name.to_lowercase().contains(&low)) + { + Some(i) => i, + None => { + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("You don't have '{target}'.")) + )) + } }; let obj = conn.player.inventory.remove(idx); let name = obj.name.clone(); let kind = obj.kind.as_deref().unwrap_or("").to_string(); match kind.as_str() { "weapon" => { - if let Some(old) = conn.player.equipped_weapon.take() { conn.player.inventory.push(old); } + if let Some(old) = conn.player.equipped_weapon.take() { + conn.player.inventory.push(old); + } conn.player.equipped_weapon = Some(obj); st.save_player_to_db(pid); - CommandResult { output: format!("You equip the {} as your weapon.\r\n", ansi::color(ansi::CYAN, &name)), broadcasts: Vec::new(), quit: false } + CommandResult { + output: format!( + "You equip the {} as your weapon.\r\n", + ansi::color(ansi::CYAN, &name) + ), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } } "armor" => { - if let Some(old) = conn.player.equipped_armor.take() { conn.player.inventory.push(old); } + if let Some(old) = conn.player.equipped_armor.take() { + conn.player.inventory.push(old); + } conn.player.equipped_armor = Some(obj); st.save_player_to_db(pid); - CommandResult { output: format!("You equip the {} as armor.\r\n", ansi::color(ansi::CYAN, &name)), broadcasts: Vec::new(), quit: false } + CommandResult { + output: format!( + "You equip the {} as armor.\r\n", + ansi::color(ansi::CYAN, &name) + ), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } + } + _ => { + conn.player.inventory.push(obj); + simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("You can't equip the {}.", name)) + )) } - _ => { conn.player.inventory.push(obj); simple(&format!("{}\r\n", ansi::error_msg(&format!("You can't equip the {}.", name)))) } } } async fn cmd_use(pid: usize, target: &str, state: &SharedState) -> CommandResult { - if target.is_empty() { return simple("Use what?\r\n"); } + if target.is_empty() { + return simple("Use what?\r\n"); + } let mut st = state.lock().await; - let conn = match st.players.get_mut(&pid) { Some(c) => c, None => return simple("Error\r\n") }; + let conn = match st.players.get_mut(&pid) { + Some(c) => c, + None => return simple("Error\r\n"), + }; let low = target.to_lowercase(); - let idx = match conn.player.inventory.iter().position(|o| o.name.to_lowercase().contains(&low)) { - Some(i) => i, None => return simple(&format!("{}\r\n", ansi::error_msg(&format!("You don't have '{target}'.")))), + let idx = match conn + .player + .inventory + .iter() + .position(|o| o.name.to_lowercase().contains(&low)) + { + Some(i) => i, + None => { + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("You don't have '{target}'.")) + )) + } }; let obj = &conn.player.inventory[idx]; if obj.kind.as_deref() != Some("consumable") { - return simple(&format!("{}\r\n", ansi::error_msg(&format!("You can't use the {}.", obj.name)))); + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("You can't use the {}.", obj.name)) + )); } let heal = obj.stats.heal_amount.unwrap_or(0); let name = obj.name.clone(); @@ -293,13 +652,29 @@ async fn cmd_use(pid: usize, target: &str, state: &SharedState) -> CommandResult let max_hp = conn.player.stats.max_hp; let _ = conn; st.save_player_to_db(pid); - CommandResult { output: format!("You use the {}. Restored {} HP. ({}/{})\r\n", ansi::color(ansi::CYAN, &name), ansi::color(ansi::GREEN, &healed.to_string()), new_hp, max_hp), broadcasts: Vec::new(), quit: false } + CommandResult { + output: format!( + "You use the {}. Restored {} HP. ({}/{})\r\n", + ansi::color(ansi::CYAN, &name), + ansi::color(ansi::GREEN, &healed.to_string()), + new_hp, + max_hp + ), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } } async fn cmd_examine(pid: usize, target: &str, state: &SharedState) -> CommandResult { - if target.is_empty() { return simple("Examine what?\r\n"); } + if target.is_empty() { + return simple("Examine what?\r\n"); + } let st = state.lock().await; - let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n") }; + let conn = match st.players.get(&pid) { + Some(c) => c, + None => return simple("Error\r\n"), + }; let low = target.to_lowercase(); let pname = &conn.player.name; @@ -309,39 +684,80 @@ async fn cmd_examine(pid: usize, target: &str, state: &SharedState) -> CommandRe if npc.name.to_lowercase().contains(&low) { let alive = st.npc_instances.get(nid).map(|i| i.alive).unwrap_or(true); let att = st.npc_attitude_toward(nid, pname); - let mut out = format!("\r\n{}\r\n {}\r\n", ansi::bold(&npc.name), npc.description); + let mut out = + format!("\r\n{}\r\n {}\r\n", ansi::bold(&npc.name), npc.description); if !alive { out.push_str(&format!(" {}\r\n", ansi::color(ansi::RED, "(dead)"))); } else if let Some(ref c) = npc.combat { let hp = st.npc_instances.get(nid).map(|i| i.hp).unwrap_or(c.max_hp); - out.push_str(&format!(" HP: {}/{} | ATK: {} | DEF: {}\r\n", hp, c.max_hp, c.attack, c.defense)); + out.push_str(&format!( + " HP: {}/{} | ATK: {} | DEF: {}\r\n", + hp, c.max_hp, c.attack, c.defense + )); } - out.push_str(&format!(" Attitude: {}\r\n", ansi::color(attitude_color(att), att.label()))); - return CommandResult { output: out, broadcasts: Vec::new(), quit: false }; + out.push_str(&format!( + " Attitude: {}\r\n", + ansi::color(attitude_color(att), att.label()) + )); + return CommandResult { + output: out, + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + }; } } } for oid in &room.objects { if let Some(obj) = st.world.get_object(oid) { if obj.name.to_lowercase().contains(&low) { - return CommandResult { output: format!("\r\n{}\r\n {}\r\n", ansi::bold(&obj.name), obj.description), broadcasts: Vec::new(), quit: false }; + return CommandResult { + output: format!( + "\r\n{}\r\n {}\r\n", + ansi::bold(&obj.name), + obj.description + ), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + }; } } } } for obj in &conn.player.inventory { if obj.name.to_lowercase().contains(&low) { - return CommandResult { output: format!("\r\n{}\r\n {}\r\n", ansi::bold(&obj.name), obj.description), broadcasts: Vec::new(), quit: false }; + return CommandResult { + output: format!( + "\r\n{}\r\n {}\r\n", + ansi::bold(&obj.name), + obj.description + ), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + }; } } - simple(&format!("{}\r\n", ansi::error_msg(&format!("You don't see '{target}'.")))) + simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("You don't see '{target}'.")) + )) } async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResult { - if target.is_empty() { return simple("Talk to whom?\r\n"); } + if target.is_empty() { + return simple("Talk to whom?\r\n"); + } let st = state.lock().await; - let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n") }; - let room = match st.world.get_room(&conn.player.room_id) { Some(r) => r, None => return simple("Void\r\n") }; + let conn = match st.players.get(&pid) { + Some(c) => c, + None => return simple("Error\r\n"), + }; + let room = match st.world.get_room(&conn.player.room_id) { + Some(r) => r, + None => return simple("Void\r\n"), + }; let low = target.to_lowercase(); let pname = &conn.player.name; @@ -349,120 +765,294 @@ async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResul if let Some(npc) = st.world.get_npc(nid) { if npc.name.to_lowercase().contains(&low) { if !st.npc_instances.get(nid).map(|i| i.alive).unwrap_or(true) { - return simple(&format!("{}\r\n", ansi::error_msg(&format!("{} is dead.", npc.name)))); + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("{} is dead.", npc.name)) + )); } let att = st.npc_attitude_toward(nid, pname); if !att.will_talk() { - return simple(&format!("{} snarls at you menacingly.\r\n", ansi::color(ansi::RED, &npc.name))); + return simple(&format!( + "{} snarls at you menacingly.\r\n", + ansi::color(ansi::RED, &npc.name) + )); } let greeting = npc.greeting.as_deref().unwrap_or("..."); return CommandResult { - output: format!("\r\n{} says: \"{}\"\r\n", ansi::color(ansi::YELLOW, &npc.name), ansi::color(ansi::WHITE, greeting)), - broadcasts: Vec::new(), quit: false, + output: format!( + "\r\n{} says: \"{}\"\r\n", + ansi::color(ansi::YELLOW, &npc.name), + ansi::color(ansi::WHITE, greeting) + ), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, }; } } } - simple(&format!("{}\r\n", ansi::error_msg(&format!("You don't see '{target}' here to talk to.")))) + simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("You don't see '{target}' here to talk to.")) + )) } async fn cmd_attack(pid: usize, target: &str, state: &SharedState) -> CommandResult { let mut st = state.lock().await; let npc_id = { - let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n") }; + let conn = match st.players.get(&pid) { + Some(c) => c, + None => return simple("Error\r\n"), + }; if let Some(ref combat) = conn.combat { combat.npc_id.clone() } else { - if target.is_empty() { return simple("Attack what?\r\n"); } - let room = match st.world.get_room(&conn.player.room_id) { Some(r) => r, None => return simple("Void\r\n") }; + if target.is_empty() { + return simple("Attack what?\r\n"); + } + let room = match st.world.get_room(&conn.player.room_id) { + Some(r) => r, + None => return simple("Void\r\n"), + }; let low = target.to_lowercase(); let pname = &conn.player.name; let found = room.npcs.iter().find(|nid| { if let Some(npc) = st.world.get_npc(nid) { - if !npc.name.to_lowercase().contains(&low) { return false; } + if !npc.name.to_lowercase().contains(&low) { + return false; + } let att = st.npc_attitude_toward(nid, pname); att.can_be_attacked() && npc.combat.is_some() - } else { false } + } else { + false + } }); match found { Some(id) => { - if !st.npc_instances.get(id).map(|i| i.alive).unwrap_or(false) { - return simple(&format!("{}\r\n", ansi::error_msg("That target is already dead."))); + if !st + .npc_instances + .get(id) + .map(|i| i.alive) + .unwrap_or(false) + { + return simple(&format!( + "{}\r\n", + ansi::error_msg("That target is already dead.") + )); } id.clone() } - None => return simple(&format!("{}\r\n", ansi::error_msg(&format!("No attackable target '{target}' here.")))), + None => { + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("No attackable target '{target}' here.")) + )) + } } } }; - // Set combat state if not already - if st.players.get(&pid).map(|c| c.combat.is_none()).unwrap_or(false) { + if st + .players + .get(&pid) + .map(|c| c.combat.is_none()) + .unwrap_or(false) + { if let Some(c) = st.players.get_mut(&pid) { - c.combat = Some(CombatState { npc_id: npc_id.clone() }); + c.combat = Some(CombatState { + npc_id: npc_id.clone(), + }); } } st.check_respawns(); - let player_name = st.players.get(&pid).map(|c| c.player.name.clone()).unwrap_or_default(); + let player_name = st + .players + .get(&pid) + .map(|c| c.player.name.clone()) + .unwrap_or_default(); let result = combat::do_attack(pid, &npc_id, &mut st); match result { Some(round) => { let mut out = round.output; if round.npc_died { - // Attitude shift: this NPC and faction st.shift_attitude(&npc_id, &player_name, -10); if let Some(faction) = st.world.get_npc(&npc_id).and_then(|n| n.faction.clone()) { st.shift_faction_attitude(&faction, &player_name, -5); } if let Some(msg) = st.check_level_up(pid) { - out.push_str(&format!("\r\n {} {}\r\n", ansi::color(ansi::GREEN, "***"), ansi::bold(&msg))); + out.push_str(&format!( + "\r\n {} {}\r\n", + ansi::color(ansi::GREEN, "***"), + ansi::bold(&msg) + )); } } if round.player_died { out.push_str(&combat::player_death_respawn(pid, &mut st)); - let rid = st.players.get(&pid).map(|c| c.player.room_id.clone()).unwrap_or_default(); + let rid = st + .players + .get(&pid) + .map(|c| c.player.room_id.clone()) + .unwrap_or_default(); out.push_str(&render_room_view(&rid, pid, &st)); } st.save_player_to_db(pid); - CommandResult { output: out, broadcasts: Vec::new(), quit: false } + CommandResult { + output: out, + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } } - None => simple(&format!("{}\r\n", ansi::error_msg("That target can't be attacked right now."))), + None => simple(&format!( + "{}\r\n", + ansi::error_msg("That target can't be attacked right now.") + )), } } async fn cmd_flee(pid: usize, state: &SharedState) -> CommandResult { let mut st = state.lock().await; - let conn = match st.players.get_mut(&pid) { Some(c) => c, None => return simple("Error\r\n") }; - if conn.combat.is_none() { return simple(&format!("{}\r\n", ansi::error_msg("You're not in combat."))); } + let conn = match st.players.get_mut(&pid) { + Some(c) => c, + None => return simple("Error\r\n"), + }; + if conn.combat.is_none() { + return simple(&format!( + "{}\r\n", + ansi::error_msg("You're not in combat.") + )); + } conn.combat = None; - CommandResult { output: format!("{}\r\n", ansi::system_msg("You disengage and flee from combat!")), broadcasts: Vec::new(), quit: false } + CommandResult { + output: format!( + "{}\r\n", + ansi::system_msg("You disengage and flee from combat!") + ), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } } async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult { let st = state.lock().await; - let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n") }; + let conn = match st.players.get(&pid) { + Some(c) => c, + None => return simple("Error\r\n"), + }; let p = &conn.player; let s = &p.stats; - let rn = st.world.races.iter().find(|r| r.id == p.race_id).map(|r| r.name.as_str()).unwrap_or("???"); - let cn = st.world.classes.iter().find(|c| c.id == p.class_id).map(|c| c.name.as_str()).unwrap_or("???"); - let hpc = if s.hp*3 < s.max_hp { ansi::RED } else if s.hp*3 < s.max_hp*2 { ansi::YELLOW } else { ansi::GREEN }; + let rn = st + .world + .races + .iter() + .find(|r| r.id == p.race_id) + .map(|r| r.name.as_str()) + .unwrap_or("???"); + let cn = st + .world + .classes + .iter() + .find(|c| c.id == p.class_id) + .map(|c| c.name.as_str()) + .unwrap_or("???"); + let hpc = if s.hp * 3 < s.max_hp { + ansi::RED + } else if s.hp * 3 < s.max_hp * 2 { + ansi::YELLOW + } else { + ansi::GREEN + }; let mut out = format!("\r\n{}\r\n", ansi::bold(&format!("=== {} ===", p.name))); - out.push_str(&format!(" {} {} | {} {}\r\n", ansi::color(ansi::DIM, "Race:"), ansi::color(ansi::CYAN, rn), ansi::color(ansi::DIM, "Class:"), ansi::color(ansi::CYAN, cn))); - out.push_str(&format!(" {} {}{}/{}{}\r\n", ansi::color(ansi::DIM, "HP:"), hpc, s.hp, s.max_hp, ansi::RESET)); - out.push_str(&format!(" {} {} (+{} equip) {} {} (+{} equip)\r\n", - ansi::color(ansi::DIM, "ATK:"), s.attack, p.equipped_weapon.as_ref().and_then(|w| w.stats.damage).unwrap_or(0), - ansi::color(ansi::DIM, "DEF:"), s.defense, p.equipped_armor.as_ref().and_then(|a| a.stats.armor).unwrap_or(0))); - out.push_str(&format!(" {} {}\r\n", ansi::color(ansi::DIM, "Level:"), s.level)); - out.push_str(&format!(" {} {}/{}\r\n", ansi::color(ansi::DIM, "XP:"), s.xp, s.xp_to_next)); - CommandResult { output: out, broadcasts: Vec::new(), quit: false } + out.push_str(&format!( + " {} {} | {} {}\r\n", + ansi::color(ansi::DIM, "Race:"), + ansi::color(ansi::CYAN, rn), + ansi::color(ansi::DIM, "Class:"), + ansi::color(ansi::CYAN, cn) + )); + out.push_str(&format!( + " {} {}{}/{}{}\r\n", + ansi::color(ansi::DIM, "HP:"), + hpc, + s.hp, + s.max_hp, + ansi::RESET + )); + out.push_str(&format!( + " {} {} (+{} equip) {} {} (+{} equip)\r\n", + ansi::color(ansi::DIM, "ATK:"), + s.attack, + p.equipped_weapon + .as_ref() + .and_then(|w| w.stats.damage) + .unwrap_or(0), + ansi::color(ansi::DIM, "DEF:"), + s.defense, + p.equipped_armor + .as_ref() + .and_then(|a| a.stats.armor) + .unwrap_or(0) + )); + out.push_str(&format!( + " {} {}\r\n", + ansi::color(ansi::DIM, "Level:"), + s.level + )); + out.push_str(&format!( + " {} {}/{}\r\n", + ansi::color(ansi::DIM, "XP:"), + s.xp, + s.xp_to_next + )); + if p.is_admin { + out.push_str(&format!( + " {}\r\n", + ansi::color(ansi::YELLOW, "[ADMIN]") + )); + } + CommandResult { + output: out, + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } } -fn cmd_help() -> CommandResult { +async fn cmd_admin(pid: usize, args: &str, state: &SharedState) -> CommandResult { + let st = state.lock().await; + let is_admin = st + .players + .get(&pid) + .map(|c| c.player.is_admin) + .unwrap_or(false); + drop(st); + + if !is_admin { + return simple(&format!( + "{}\r\n", + ansi::error_msg("You don't have admin privileges.") + )); + } + + admin::execute_admin(args, pid, state).await +} + +async fn cmd_help(pid: usize, state: &SharedState) -> CommandResult { + let st = state.lock().await; + let is_admin = st + .players + .get(&pid) + .map(|c| c.player.is_admin) + .unwrap_or(false); + drop(st); + let mut out = format!("\r\n{}\r\n", ansi::bold("=== Commands ===")); let cmds = [ ("look, l", "Look around the current room"), @@ -482,10 +1072,33 @@ fn cmd_help() -> CommandResult { ("help, h, ?", "Show this help"), ("quit, exit", "Leave the game"), ]; - for (c, d) in cmds { out.push_str(&format!(" {:<30} {}\r\n", ansi::color(ansi::YELLOW, c), ansi::color(ansi::DIM, d))); } - CommandResult { output: out, broadcasts: Vec::new(), quit: false } + for (c, d) in cmds { + out.push_str(&format!( + " {:<30} {}\r\n", + ansi::color(ansi::YELLOW, c), + ansi::color(ansi::DIM, d) + )); + } + if is_admin { + out.push_str(&format!( + "\r\n {} {}\r\n", + ansi::color(ansi::YELLOW, "admin "), + ansi::color(ansi::DIM, "Admin commands (use 'admin help')") + )); + } + CommandResult { + output: out, + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } } fn simple(msg: &str) -> CommandResult { - CommandResult { output: msg.to_string(), broadcasts: Vec::new(), quit: false } + CommandResult { + output: msg.to_string(), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + } } diff --git a/src/db.rs b/src/db.rs index ff2d924..74ef014 100644 --- a/src/db.rs +++ b/src/db.rs @@ -16,6 +16,7 @@ pub struct SavedPlayer { pub inventory_json: String, pub equipped_weapon_json: Option, pub equipped_armor_json: Option, + pub is_admin: bool, } pub struct NpcAttitudeRow { @@ -27,10 +28,16 @@ pub trait GameDb: Send + Sync { fn load_player(&self, name: &str) -> Option; fn save_player(&self, player: &SavedPlayer); fn delete_player(&self, name: &str); + fn set_admin(&self, name: &str, is_admin: bool) -> bool; + fn list_all_players(&self) -> Vec; fn load_attitudes(&self, player_name: &str) -> Vec; fn save_attitude(&self, player_name: &str, npc_id: &str, value: i32); fn get_attitude(&self, player_name: &str, npc_id: &str) -> Option; + + fn get_setting(&self, key: &str) -> Option; + fn set_setting(&self, key: &str, value: &str); + fn list_settings(&self) -> Vec<(String, String)>; } // --- SQLite implementation --- @@ -61,7 +68,8 @@ impl SqliteDb { defense INTEGER NOT NULL, inventory_json TEXT NOT NULL DEFAULT '[]', equipped_weapon_json TEXT, - equipped_armor_json TEXT + equipped_armor_json TEXT, + is_admin INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS npc_attitudes ( @@ -69,10 +77,25 @@ impl SqliteDb { npc_id TEXT NOT NULL, value INTEGER NOT NULL, PRIMARY KEY (player_name, npc_id) + ); + + CREATE TABLE IF NOT EXISTS server_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL );", ) .map_err(|e| format!("Failed to create tables: {e}"))?; + // Migration: add is_admin column if missing + let has_admin: bool = conn + .prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='is_admin'") + .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0))) + .map(|c| c > 0) + .unwrap_or(false); + if !has_admin { + let _ = conn.execute("ALTER TABLE players ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0", []); + } + log::info!("Database opened: {}", path.display()); Ok(SqliteDb { conn: std::sync::Mutex::new(conn), @@ -85,7 +108,8 @@ impl GameDb for SqliteDb { let conn = self.conn.lock().unwrap(); conn.query_row( "SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp, - attack, defense, inventory_json, equipped_weapon_json, equipped_armor_json + attack, defense, inventory_json, equipped_weapon_json, + equipped_armor_json, is_admin FROM players WHERE name = ?1", [name], |row| { @@ -103,6 +127,7 @@ impl GameDb for SqliteDb { inventory_json: row.get(10)?, equipped_weapon_json: row.get(11)?, equipped_armor_json: row.get(12)?, + is_admin: row.get::<_, i32>(13)? != 0, }) }, ) @@ -113,18 +138,21 @@ impl GameDb for SqliteDb { let conn = self.conn.lock().unwrap(); let _ = conn.execute( "INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp, - attack, defense, inventory_json, equipped_weapon_json, equipped_armor_json) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13) + attack, defense, inventory_json, equipped_weapon_json, + equipped_armor_json, is_admin) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14) ON CONFLICT(name) DO UPDATE SET room_id=excluded.room_id, level=excluded.level, xp=excluded.xp, hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack, defense=excluded.defense, inventory_json=excluded.inventory_json, equipped_weapon_json=excluded.equipped_weapon_json, - equipped_armor_json=excluded.equipped_armor_json", + equipped_armor_json=excluded.equipped_armor_json, + is_admin=excluded.is_admin", rusqlite::params![ p.name, p.race_id, p.class_id, p.room_id, p.level, p.xp, p.hp, p.max_hp, p.attack, p.defense, p.inventory_json, p.equipped_weapon_json, p.equipped_armor_json, + p.is_admin as i32, ], ); } @@ -135,6 +163,50 @@ impl GameDb for SqliteDb { let _ = conn.execute("DELETE FROM npc_attitudes WHERE player_name = ?1", [name]); } + fn set_admin(&self, name: &str, is_admin: bool) -> bool { + let conn = self.conn.lock().unwrap(); + let rows = conn + .execute( + "UPDATE players SET is_admin = ?1 WHERE name = ?2", + rusqlite::params![is_admin as i32, name], + ) + .unwrap_or(0); + rows > 0 + } + + fn list_all_players(&self) -> Vec { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp, + attack, defense, inventory_json, equipped_weapon_json, + equipped_armor_json, is_admin + FROM players ORDER BY name", + ) + .unwrap(); + stmt.query_map([], |row| { + Ok(SavedPlayer { + name: row.get(0)?, + race_id: row.get(1)?, + class_id: row.get(2)?, + room_id: row.get(3)?, + level: row.get(4)?, + xp: row.get(5)?, + hp: row.get(6)?, + max_hp: row.get(7)?, + attack: row.get(8)?, + defense: row.get(9)?, + inventory_json: row.get(10)?, + equipped_weapon_json: row.get(11)?, + equipped_armor_json: row.get(12)?, + is_admin: row.get::<_, i32>(13)? != 0, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + } + fn load_attitudes(&self, player_name: &str) -> Vec { let conn = self.conn.lock().unwrap(); let mut stmt = conn @@ -170,4 +242,34 @@ impl GameDb for SqliteDb { ) .ok() } + + fn get_setting(&self, key: &str) -> Option { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT value FROM server_settings WHERE key = ?1", + [key], + |row| row.get(0), + ) + .ok() + } + + fn set_setting(&self, key: &str, value: &str) { + let conn = self.conn.lock().unwrap(); + let _ = conn.execute( + "INSERT INTO server_settings (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + rusqlite::params![key, value], + ); + } + + fn list_settings(&self) -> Vec<(String, String)> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT key, value FROM server_settings ORDER BY key") + .unwrap(); + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + } } diff --git a/src/game.rs b/src/game.rs index 3ff0cfb..abf3a8b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -29,16 +29,25 @@ pub struct Player { pub inventory: Vec, pub equipped_weapon: Option, pub equipped_armor: Option, + pub is_admin: bool, } impl Player { pub fn effective_attack(&self) -> i32 { - let bonus = self.equipped_weapon.as_ref().and_then(|w| w.stats.damage).unwrap_or(0); + let bonus = self + .equipped_weapon + .as_ref() + .and_then(|w| w.stats.damage) + .unwrap_or(0); self.stats.attack + bonus } pub fn effective_defense(&self) -> i32 { - let bonus = self.equipped_armor.as_ref().and_then(|a| a.stats.armor).unwrap_or(0); + let bonus = self + .equipped_armor + .as_ref() + .and_then(|a| a.stats.armor) + .unwrap_or(0); self.stats.defense + bonus } } @@ -74,24 +83,41 @@ impl GameState { let mut npc_instances = HashMap::new(); for npc in world.npcs.values() { if let Some(ref combat) = npc.combat { - npc_instances.insert(npc.id.clone(), NpcInstance { - hp: combat.max_hp, alive: true, death_time: None, - }); + npc_instances.insert( + npc.id.clone(), + NpcInstance { + hp: combat.max_hp, + alive: true, + death_time: None, + }, + ); } } - GameState { world, db, players: HashMap::new(), npc_instances } + GameState { + world, + db, + players: HashMap::new(), + npc_instances, + } } pub fn spawn_room(&self) -> &str { &self.world.spawn_room } - // Get effective attitude of an NPC towards a specific player + pub fn is_registration_open(&self) -> bool { + self.db + .get_setting("registration_open") + .map(|v| v != "false") + .unwrap_or(true) + } + pub fn npc_attitude_toward(&self, npc_id: &str, player_name: &str) -> Attitude { if let Some(val) = self.db.get_attitude(player_name, npc_id) { return Attitude::from_value(val); } - self.world.get_npc(npc_id) + self.world + .get_npc(npc_id) .map(|n| n.base_attitude) .unwrap_or(Attitude::Neutral) } @@ -100,7 +126,8 @@ impl GameState { if let Some(val) = self.db.get_attitude(player_name, npc_id) { return val; } - self.world.get_npc(npc_id) + self.world + .get_npc(npc_id) .map(|n| n.base_attitude.default_value()) .unwrap_or(0) } @@ -111,7 +138,6 @@ impl GameState { self.db.save_attitude(player_name, npc_id, new_val); } - // Shift attitude for all NPCs in the same faction pub fn shift_faction_attitude(&self, faction: &str, player_name: &str, delta: i32) { for npc in self.world.npcs.values() { if npc.faction.as_deref() == Some(faction) { @@ -121,8 +147,13 @@ impl GameState { } pub fn create_new_player( - &mut self, id: usize, name: String, race_id: String, class_id: String, - channel: ChannelId, handle: Handle, + &mut self, + id: usize, + name: String, + race_id: String, + class_id: String, + channel: ChannelId, + handle: Handle, ) { let room_id = self.world.spawn_room.clone(); let race = self.world.races.iter().find(|r| r.id == race_id); @@ -142,23 +173,54 @@ impl GameState { let defense = base_def + con_mod / 2; let stats = PlayerStats { - max_hp, hp: max_hp, attack, defense, level: 1, xp: 0, xp_to_next: 100, + max_hp, + hp: max_hp, + attack, + defense, + level: 1, + xp: 0, + xp_to_next: 100, }; - self.players.insert(id, PlayerConnection { - player: Player { name, race_id, class_id, room_id, stats, inventory: Vec::new(), equipped_weapon: None, equipped_armor: None }, - channel, handle, combat: None, - }); + self.players.insert( + id, + PlayerConnection { + player: Player { + name, + race_id, + class_id, + room_id, + stats, + inventory: Vec::new(), + equipped_weapon: None, + equipped_armor: None, + is_admin: false, + }, + channel, + handle, + combat: None, + }, + ); } pub fn load_existing_player( - &mut self, id: usize, saved: SavedPlayer, channel: ChannelId, handle: Handle, + &mut self, + id: usize, + saved: SavedPlayer, + channel: ChannelId, + handle: Handle, ) { - let inventory: Vec = serde_json::from_str(&saved.inventory_json).unwrap_or_default(); - let equipped_weapon: Option = saved.equipped_weapon_json.as_deref().and_then(|j| serde_json::from_str(j).ok()); - let equipped_armor: Option = saved.equipped_armor_json.as_deref().and_then(|j| serde_json::from_str(j).ok()); + let inventory: Vec = + serde_json::from_str(&saved.inventory_json).unwrap_or_default(); + let equipped_weapon: Option = saved + .equipped_weapon_json + .as_deref() + .and_then(|j| serde_json::from_str(j).ok()); + let equipped_armor: Option = saved + .equipped_armor_json + .as_deref() + .and_then(|j| serde_json::from_str(j).ok()); - // Validate room still exists, else spawn let room_id = if self.world.rooms.contains_key(&saved.room_id) { saved.room_id } else { @@ -166,32 +228,65 @@ impl GameState { }; let stats = PlayerStats { - max_hp: saved.max_hp, hp: saved.hp, attack: saved.attack, defense: saved.defense, - level: saved.level, xp: saved.xp, xp_to_next: saved.level * 100, + max_hp: saved.max_hp, + hp: saved.hp, + attack: saved.attack, + defense: saved.defense, + level: saved.level, + xp: saved.xp, + xp_to_next: saved.level * 100, }; - self.players.insert(id, PlayerConnection { - player: Player { - name: saved.name, race_id: saved.race_id, class_id: saved.class_id, - room_id, stats, inventory, equipped_weapon, equipped_armor, + self.players.insert( + id, + PlayerConnection { + player: Player { + name: saved.name, + race_id: saved.race_id, + class_id: saved.class_id, + room_id, + stats, + inventory, + equipped_weapon, + equipped_armor, + is_admin: saved.is_admin, + }, + channel, + handle, + combat: None, }, - channel, handle, combat: None, - }); + ); } pub fn save_player_to_db(&self, player_id: usize) { if let Some(conn) = self.players.get(&player_id) { let p = &conn.player; - let inv_json = serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into()); - let weapon_json = p.equipped_weapon.as_ref().map(|w| serde_json::to_string(w).unwrap_or_else(|_| "null".into())); - let armor_json = p.equipped_armor.as_ref().map(|a| serde_json::to_string(a).unwrap_or_else(|_| "null".into())); + let inv_json = + serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into()); + let weapon_json = p + .equipped_weapon + .as_ref() + .map(|w| serde_json::to_string(w).unwrap_or_else(|_| "null".into())); + let armor_json = p + .equipped_armor + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "null".into())); self.db.save_player(&SavedPlayer { - name: p.name.clone(), race_id: p.race_id.clone(), class_id: p.class_id.clone(), - room_id: p.room_id.clone(), level: p.stats.level, xp: p.stats.xp, - hp: p.stats.hp, max_hp: p.stats.max_hp, attack: p.stats.attack, - defense: p.stats.defense, inventory_json: inv_json, - equipped_weapon_json: weapon_json, equipped_armor_json: armor_json, + name: p.name.clone(), + race_id: p.race_id.clone(), + class_id: p.class_id.clone(), + room_id: p.room_id.clone(), + level: p.stats.level, + xp: p.stats.xp, + hp: p.stats.hp, + max_hp: p.stats.max_hp, + attack: p.stats.attack, + defense: p.stats.defense, + inventory_json: inv_json, + equipped_weapon_json: weapon_json, + equipped_armor_json: armor_json, + is_admin: p.is_admin, }); } } @@ -202,17 +297,27 @@ impl GameState { } pub fn players_in_room(&self, room_id: &str, exclude_id: usize) -> Vec<&PlayerConnection> { - self.players.iter() + self.players + .iter() .filter(|(&id, conn)| conn.player.room_id == room_id && id != exclude_id) - .map(|(_, conn)| conn).collect() + .map(|(_, conn)| conn) + .collect() } pub fn check_respawns(&mut self) { let now = Instant::now(); for (npc_id, instance) in self.npc_instances.iter_mut() { - if instance.alive { continue; } - let npc = match self.world.npcs.get(npc_id) { Some(n) => n, None => continue }; - let respawn_secs = match npc.respawn_secs { Some(s) => s, None => continue }; + if instance.alive { + continue; + } + let npc = match self.world.npcs.get(npc_id) { + Some(n) => n, + None => continue, + }; + let respawn_secs = match npc.respawn_secs { + Some(s) => s, + None => continue, + }; if let Some(death_time) = instance.death_time { if now.duration_since(death_time).as_secs() >= respawn_secs { if let Some(ref combat) = npc.combat { @@ -228,7 +333,9 @@ impl GameState { pub fn check_level_up(&mut self, player_id: usize) -> Option { let conn = self.players.get_mut(&player_id)?; let player = &mut conn.player; - if player.stats.xp < player.stats.xp_to_next { return None; } + if player.stats.xp < player.stats.xp_to_next { + return None; + } player.stats.xp -= player.stats.xp_to_next; player.stats.level += 1; @@ -236,7 +343,11 @@ impl GameState { let class = self.world.classes.iter().find(|c| c.id == player.class_id); let (hp_g, atk_g, def_g) = match class { - Some(c) => (c.growth.hp_per_level, c.growth.attack_per_level, c.growth.defense_per_level), + Some(c) => ( + c.growth.hp_per_level, + c.growth.attack_per_level, + c.growth.defense_per_level, + ), None => (10, 2, 1), }; player.stats.max_hp += hp_g; @@ -244,6 +355,9 @@ impl GameState { player.stats.attack += atk_g; player.stats.defense += def_g; - Some(format!("You are now level {}! HP:{} ATK:{} DEF:{}", player.stats.level, player.stats.max_hp, player.stats.attack, player.stats.defense)) + Some(format!( + "You are now level {}! HP:{} ATK:{} DEF:{}", + player.stats.level, player.stats.max_hp, player.stats.attack, player.stats.defense + )) } } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4bd77c2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +pub mod admin; +pub mod ansi; +pub mod chargen; +pub mod combat; +pub mod commands; +pub mod db; +pub mod game; +pub mod ssh; +pub mod world; diff --git a/src/main.rs b/src/main.rs index 65ad751..b201c17 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,3 @@ -mod ansi; -mod chargen; -mod combat; -mod commands; -mod db; -mod game; -mod ssh; -mod world; - use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; @@ -15,6 +6,11 @@ use russh::keys::ssh_key::rand_core::OsRng; use russh::server::Server as _; use tokio::net::TcpListener; +use mudserver::db; +use mudserver::game; +use mudserver::ssh; +use mudserver::world; + const DEFAULT_PORT: u16 = 2222; const DEFAULT_WORLD_DIR: &str = "./world"; const DEFAULT_DB_PATH: &str = "./mudserver.db"; @@ -33,7 +29,10 @@ async fn main() { match args[i].as_str() { "--port" | "-p" => { i += 1; - port = args.get(i).and_then(|s| s.parse().ok()).expect("--port requires a number"); + port = args + .get(i) + .and_then(|s| s.parse().ok()) + .expect("--port requires a number"); } "--world" | "-w" => { i += 1; @@ -71,7 +70,8 @@ async fn main() { }); let db: Arc = Arc::new(database); - let key = russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap(); + let key = + russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap(); let config = russh::server::Config { inactivity_timeout: Some(std::time::Duration::from_secs(3600)), auth_rejection_time: std::time::Duration::from_secs(1), diff --git a/src/ssh.rs b/src/ssh.rs index 1f9ee5f..6edcca2 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -15,7 +15,10 @@ pub struct MudServer { impl MudServer { pub fn new(state: SharedState) -> Self { - MudServer { state, next_id: AtomicUsize::new(1) } + MudServer { + state, + next_id: AtomicUsize::new(1), + } } } @@ -26,8 +29,14 @@ impl Server for MudServer { let id = self.next_id.fetch_add(1, Ordering::SeqCst); log::info!("New connection (id={id}) from {addr:?}"); MudHandler { - id, username: String::new(), channel: None, handle: None, - line_buffer: String::new(), chargen: None, state: self.state.clone(), + id, + username: String::new(), + channel: None, + handle: None, + line_buffer: String::new(), + chargen: None, + rejected: false, + state: self.state.clone(), } } @@ -42,8 +51,8 @@ pub struct MudHandler { channel: Option, handle: Option, line_buffer: String, - // None = not yet determined, Some(None) = returning player, Some(Some(cg)) = in chargen chargen: Option>, + rejected: bool, state: SharedState, } @@ -55,32 +64,41 @@ impl MudHandler { async fn start_session(&mut self, session: &mut Session, channel: ChannelId) { let state = self.state.lock().await; let world_name = state.world.name.clone(); - - // Check if returning player let saved = state.db.load_player(&self.username); + let registration_open = state.is_registration_open(); drop(state); let welcome = format!( "{}\r\n{}Welcome to {}, {}!\r\n", - ansi::CLEAR_SCREEN, ansi::welcome_banner(), - ansi::bold(&world_name), ansi::player_name(&self.username), + ansi::CLEAR_SCREEN, + ansi::welcome_banner(), + ansi::bold(&world_name), + ansi::player_name(&self.username), ); self.send_text(session, channel, &welcome); if let Some(saved) = saved { - // Returning player — load from DB let handle = session.handle(); let mut state = self.state.lock().await; state.load_existing_player(self.id, saved, channel, handle); - - let msg = format!("{}\r\n", ansi::system_msg("Welcome back! Your character has been restored.")); drop(state); - self.send_text(session, channel, &msg); - self.chargen = Some(None); // signal: no chargen needed + let msg = format!( + "{}\r\n", + ansi::system_msg("Welcome back! Your character has been restored.") + ); + self.send_text(session, channel, &msg); + self.chargen = Some(None); self.enter_world(session, channel).await; + } else if !registration_open { + let msg = format!( + "{}\r\n{}\r\n", + ansi::error_msg("Registration is currently closed. New characters cannot be created."), + ansi::system_msg("Contact an administrator for access. Disconnecting..."), + ); + self.send_text(session, channel, &msg); + self.rejected = true; } else { - // New player — start chargen let cg = ChargenState::new(); let state = self.state.lock().await; let prompt = cg.prompt_text(&state.world); @@ -98,13 +116,20 @@ impl MudHandler { None => return, }; - // Broadcast arrival let arrival = CryptoVec::from( - format!("\r\n{}\r\n{}", ansi::system_msg(&format!("{player_name} has entered the world.")), ansi::prompt()).as_bytes(), + format!( + "\r\n{}\r\n{}", + ansi::system_msg(&format!("{player_name} has entered the world.")), + ansi::prompt() + ) + .as_bytes(), ); - let others: Vec<_> = state.players_in_room(&room_id, self.id).iter().map(|c| (c.channel, c.handle.clone())).collect(); + let others: Vec<_> = state + .players_in_room(&room_id, self.id) + .iter() + .map(|c| (c.channel, c.handle.clone())) + .collect(); - // Render room let room_view = render_entry_room(&state, &room_id, &player_name, self.id); drop(state); @@ -115,19 +140,49 @@ impl MudHandler { } } - async fn finish_chargen(&mut self, race_id: String, class_id: String, session: &mut Session, channel: ChannelId) { + async fn finish_chargen( + &mut self, + race_id: String, + class_id: String, + session: &mut Session, + channel: ChannelId, + ) { let handle = session.handle(); let mut state = self.state.lock().await; - let race_name = state.world.races.iter().find(|r| r.id == race_id).map(|r| r.name.clone()).unwrap_or_default(); - let class_name = state.world.classes.iter().find(|c| c.id == class_id).map(|c| c.name.clone()).unwrap_or_default(); + let race_name = state + .world + .races + .iter() + .find(|r| r.id == race_id) + .map(|r| r.name.clone()) + .unwrap_or_default(); + let class_name = state + .world + .classes + .iter() + .find(|c| c.id == class_id) + .map(|c| c.name.clone()) + .unwrap_or_default(); - state.create_new_player(self.id, self.username.clone(), race_id, class_id, channel, handle); + state.create_new_player( + self.id, + self.username.clone(), + race_id, + class_id, + channel, + handle, + ); state.save_player_to_db(self.id); - drop(state); - let msg = format!("\r\n{}\r\n\r\n", ansi::system_msg(&format!("Character created: {} the {} {}", self.username, race_name, class_name))); + let msg = format!( + "\r\n{}\r\n\r\n", + ansi::system_msg(&format!( + "Character created: {} the {} {}", + self.username, race_name, class_name + )) + ); self.send_text(session, channel, &msg); self.chargen = Some(None); @@ -138,49 +193,93 @@ impl MudHandler { let mut state = self.state.lock().await; if let Some(conn) = state.remove_player(self.id) { let departure = CryptoVec::from( - format!("\r\n{}\r\n{}", ansi::system_msg(&format!("{} has left the world.", conn.player.name)), ansi::prompt()).as_bytes(), + format!( + "\r\n{}\r\n{}", + ansi::system_msg(&format!("{} has left the world.", conn.player.name)), + ansi::prompt() + ) + .as_bytes(), ); - let others: Vec<_> = state.players_in_room(&conn.player.room_id, self.id).iter().map(|c| (c.channel, c.handle.clone())).collect(); + let others: Vec<_> = state + .players_in_room(&conn.player.room_id, self.id) + .iter() + .map(|c| (c.channel, c.handle.clone())) + .collect(); drop(state); - for (ch, h) in others { let _ = h.data(ch, departure.clone()).await; } + for (ch, h) in others { + let _ = h.data(ch, departure.clone()).await; + } log::info!("{} disconnected (id={})", conn.player.name, self.id); } } } -fn render_entry_room(state: &crate::game::GameState, room_id: &str, player_name: &str, player_id: usize) -> String { - let room = match state.world.get_room(room_id) { Some(r) => r, None => return String::new() }; +fn render_entry_room( + state: &crate::game::GameState, + room_id: &str, + player_name: &str, + player_id: usize, +) -> String { + let room = match state.world.get_room(room_id) { + Some(r) => r, + None => return String::new(), + }; let mut out = String::new(); - out.push_str(&format!("{} {}\r\n", ansi::room_name(&room.name), ansi::system_msg(&format!("[{}]", room.region)))); + out.push_str(&format!( + "{} {}\r\n", + ansi::room_name(&room.name), + ansi::system_msg(&format!("[{}]", room.region)) + )); out.push_str(&format!(" {}\r\n", room.description)); - let npc_strs: Vec = room.npcs.iter().filter_map(|id| { - let npc = state.world.get_npc(id)?; - let alive = state.npc_instances.get(id).map(|i| i.alive).unwrap_or(true); - if !alive { return None; } - let att = state.npc_attitude_toward(id, player_name); - let color = match att { - crate::world::Attitude::Friendly => ansi::GREEN, - crate::world::Attitude::Neutral | crate::world::Attitude::Wary => ansi::YELLOW, - _ => ansi::RED, - }; - Some(ansi::color(color, &npc.name)) - }).collect(); + let npc_strs: Vec = room + .npcs + .iter() + .filter_map(|id| { + let npc = state.world.get_npc(id)?; + let alive = state.npc_instances.get(id).map(|i| i.alive).unwrap_or(true); + if !alive { + return None; + } + let att = state.npc_attitude_toward(id, player_name); + let color = match att { + crate::world::Attitude::Friendly => ansi::GREEN, + crate::world::Attitude::Neutral | crate::world::Attitude::Wary => ansi::YELLOW, + _ => ansi::RED, + }; + Some(ansi::color(color, &npc.name)) + }) + .collect(); if !npc_strs.is_empty() { - out.push_str(&format!("\r\n{}{}\r\n", ansi::color(ansi::DIM, "Present: "), npc_strs.join(", "))); + out.push_str(&format!( + "\r\n{}{}\r\n", + ansi::color(ansi::DIM, "Present: "), + npc_strs.join(", ") + )); } let others = state.players_in_room(room_id, player_id); if !others.is_empty() { - let names: Vec = others.iter().map(|c| ansi::player_name(&c.player.name)).collect(); - out.push_str(&format!("{}{}\r\n", ansi::color(ansi::GREEN, "Players here: "), names.join(", "))); + let names: Vec = others + .iter() + .map(|c| ansi::player_name(&c.player.name)) + .collect(); + out.push_str(&format!( + "{}{}\r\n", + ansi::color(ansi::GREEN, "Players here: "), + names.join(", ") + )); } if !room.exits.is_empty() { let mut dirs: Vec<&String> = room.exits.keys().collect(); dirs.sort(); let dir_strs: Vec = dirs.iter().map(|d| ansi::direction(d)).collect(); - out.push_str(&format!("{} {}\r\n", ansi::color(ansi::DIM, "Exits:"), dir_strs.join(", "))); + out.push_str(&format!( + "{} {}\r\n", + ansi::color(ansi::DIM, "Exits:"), + dir_strs.join(", ") + )); } out.push_str(&ansi::prompt()); out @@ -195,7 +294,11 @@ impl russh::server::Handler for MudHandler { Ok(Auth::Accept) } - async fn auth_publickey(&mut self, user: &str, _key: &russh::keys::ssh_key::PublicKey) -> Result { + async fn auth_publickey( + &mut self, + user: &str, + _key: &russh::keys::ssh_key::PublicKey, + ) -> Result { self.username = user.to_string(); Ok(Auth::Accept) } @@ -205,24 +308,52 @@ impl russh::server::Handler for MudHandler { Ok(Auth::Accept) } - async fn channel_open_session(&mut self, channel: Channel, session: &mut Session) -> Result { + async fn channel_open_session( + &mut self, + channel: Channel, + session: &mut Session, + ) -> Result { self.channel = Some(channel.id()); self.handle = Some(session.handle()); Ok(true) } - async fn pty_request(&mut self, channel: ChannelId, _term: &str, _col_width: u32, _row_height: u32, _pix_width: u32, _pix_height: u32, _modes: &[(Pty, u32)], session: &mut Session) -> Result<(), Self::Error> { + async fn pty_request( + &mut self, + channel: ChannelId, + _term: &str, + _col_width: u32, + _row_height: u32, + _pix_width: u32, + _pix_height: u32, + _modes: &[(Pty, u32)], + session: &mut Session, + ) -> Result<(), Self::Error> { session.channel_success(channel)?; Ok(()) } - async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<(), Self::Error> { + async fn shell_request( + &mut self, + channel: ChannelId, + session: &mut Session, + ) -> Result<(), Self::Error> { session.channel_success(channel)?; self.start_session(session, channel).await; Ok(()) } - async fn data(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> { + async fn data( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + if self.rejected { + session.close(channel)?; + return Ok(()); + } + for &byte in data { match byte { 3 | 4 => { @@ -237,7 +368,9 @@ impl russh::server::Handler for MudHandler { } } b'\r' | b'\n' => { - if byte == b'\n' && self.line_buffer.is_empty() { continue; } + if byte == b'\n' && self.line_buffer.is_empty() { + continue; + } session.data(channel, CryptoVec::from(&b"\r\n"[..]))?; let line = std::mem::take(&mut self.line_buffer); @@ -251,8 +384,11 @@ impl russh::server::Handler for MudHandler { let state = self.state.lock().await; cg.handle_input(&line, &state.world) }; - let msg_text = match result { Ok(msg) | Err(msg) => msg }; - let _ = session.data(channel, CryptoVec::from(msg_text.as_bytes())); + let msg_text = match result { + Ok(msg) | Err(msg) => msg, + }; + let _ = + session.data(channel, CryptoVec::from(msg_text.as_bytes())); if cg.is_done() { chargen_done = cg.result(); } @@ -260,16 +396,17 @@ impl russh::server::Handler for MudHandler { } if let Some((race_id, class_id)) = chargen_done { self.chargen = None; - self.finish_chargen(race_id, class_id, session, channel).await; + self.finish_chargen(race_id, class_id, session, channel) + .await; continue; } if chargen_active { - // Still in chargen, show next prompt if let Some(Some(ref cg)) = self.chargen { let state = self.state.lock().await; let prompt = cg.prompt_text(&state.world); drop(state); - let _ = session.data(channel, CryptoVec::from(prompt.as_bytes())); + let _ = + session.data(channel, CryptoVec::from(prompt.as_bytes())); } continue; } @@ -277,7 +414,9 @@ impl russh::server::Handler for MudHandler { continue; } - let keep_going = commands::execute(&line, self.id, &self.state, session, channel).await?; + let keep_going = + commands::execute(&line, self.id, &self.state, session, channel) + .await?; if !keep_going { self.handle_disconnect().await; session.close(channel)?; @@ -295,12 +434,20 @@ impl russh::server::Handler for MudHandler { Ok(()) } - async fn channel_eof(&mut self, _channel: ChannelId, _session: &mut Session) -> Result<(), Self::Error> { + async fn channel_eof( + &mut self, + _channel: ChannelId, + _session: &mut Session, + ) -> Result<(), Self::Error> { self.handle_disconnect().await; Ok(()) } - async fn channel_close(&mut self, _channel: ChannelId, _session: &mut Session) -> Result<(), Self::Error> { + async fn channel_close( + &mut self, + _channel: ChannelId, + _session: &mut Session, + ) -> Result<(), Self::Error> { self.handle_disconnect().await; Ok(()) }