From a06356b3a52749a29a395d8b8e7ce5dc5518b7d4 Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Fri, 21 Aug 2026 23:17:56 +0100 Subject: [PATCH] feat(tui): add terminal UI layer with ratatui (#41) Add a TUI module (`src/tui/mod.rs`) built on ratatui and crossterm with bordered panels, colour-coded difficulty indicators, countdown timer progress bar, real-time score display, and keyboard shortcut footer. The existing CLI module is preserved as a `--no-tui` fallback flag. Fixes pre-existing build errors (duplicate module declarations, missing time module, clippy warnings). Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 848 ++++++++++++++++++++++---------- Cargo.toml | 2 + src/api_client/achievement.rs | 2 +- src/api_client/auth.rs | 2 +- src/api_client/config.rs | 2 +- src/api_client/error.rs | 2 +- src/api_client/leaderboard.rs | 2 +- src/api_client/mod.rs | 49 +- src/api_client/offline_queue.rs | 6 +- src/api_client/retry.rs | 4 +- src/api_client/session.rs | 2 +- src/config/mod.rs | 2 +- src/engine/mod.rs | 1 - src/errors/mod.rs | 26 +- src/generator/mod.rs | 230 +++++++-- src/input/mod.rs | 5 +- src/l10n/mod.rs | 20 +- src/leaderboard/mod.rs | 17 +- src/lib.rs | 13 +- src/main.rs | 49 +- src/nft/mod.rs | 24 +- src/plugin/core_logic_plugin.rs | 8 +- src/score/mod.rs | 9 +- src/session/mod.rs | 39 +- src/time.rs | 15 +- src/timer.rs | 60 --- src/timer/mod.rs | 7 +- src/tui/mod.rs | 570 +++++++++++++++++++++ 28 files changed, 1525 insertions(+), 491 deletions(-) delete mode 100644 src/timer.rs create mode 100644 src/tui/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 7a80cf6..128b276 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,24 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -50,9 +56,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -75,11 +81,26 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" -version = "1.3.0" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "shlex", @@ -105,6 +126,20 @@ dependencies = [ "windows-link", ] +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -140,6 +175,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -150,6 +210,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -180,15 +274,21 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -216,15 +316,59 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fluent" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror", +] [[package]] name = "fnv" @@ -232,6 +376,12 @@ 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 = "foreign-types" version = "0.3.2" @@ -258,9 +408,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -273,9 +423,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -283,15 +433,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -300,38 +450,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -378,9 +528,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -395,12 +545,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -409,9 +576,9 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -429,9 +596,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -556,9 +723,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -570,9 +737,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -583,9 +750,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -597,16 +764,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -617,15 +785,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -636,6 +804,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -664,14 +838,64 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itertools" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] [[package]] name = "itoa" @@ -681,9 +905,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -698,9 +922,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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" @@ -710,9 +940,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -729,11 +959,20 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -748,6 +987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -816,7 +1056,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -860,6 +1100,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -874,24 +1120,30 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -901,6 +1153,12 @@ name = "quest-contract" version = "0.1.0" dependencies = [ "chrono", + "crossterm", + "fluent", + "fluent-bundle", + "js-sys", + "once_cell", + "ratatui", "reqwest", "serde", "serde_json", @@ -909,14 +1167,17 @@ dependencies = [ "thiserror", "tokio", "toml", + "unic-langid", + "wasm-bindgen", + "web-sys", "wiremock", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -927,6 +1188,27 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -950,9 +1232,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1019,6 +1301,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1028,15 +1335,15 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "once_cell", "rustls-pki-types", @@ -1056,9 +1363,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -1115,11 +1422,26 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.3.0", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1127,29 +1449,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1196,6 +1518,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1234,6 +1577,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[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.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1242,9 +1619,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -1268,7 +1656,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1301,7 +1689,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1322,16 +1710,17 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -1354,13 +1743,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -1517,146 +1906,6 @@ dependencies = [ "rustc-hash 2.1.3", ] -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typenum" version = "1.20.1" @@ -1702,7 +1951,7 @@ checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" dependencies = [ "proc-macro-hack", "quote", - "syn", + "syn 2.0.119", "unic-langid-impl", ] @@ -1712,6 +1961,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "untrusted" version = "0.9.0" @@ -1765,9 +2043,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -1778,9 +2056,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -1788,9 +2066,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1798,36 +2076,58 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -1849,7 +2149,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1860,7 +2160,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1907,6 +2207,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2014,9 +2323,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" @@ -2037,7 +2346,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -2058,7 +2367,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -2070,9 +2379,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2081,10 +2390,11 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -2092,17 +2402,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index e437db0..fdc7332 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,8 @@ unic-langid = { version = "0.9", features = ["macros"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1", features = ["full"] } +ratatui = "0.29" +crossterm = "0.28" [target.'cfg(target_arch = "wasm32")'.dependencies] tokio = { version = "1", features = ["sync", "macros", "rt"] } diff --git a/src/api_client/achievement.rs b/src/api_client/achievement.rs index 3ac962f..61b6451 100644 --- a/src/api_client/achievement.rs +++ b/src/api_client/achievement.rs @@ -4,4 +4,4 @@ use serde::Deserialize; pub struct MintResponse { pub transaction_id: String, pub status: String, -} \ No newline at end of file +} diff --git a/src/api_client/auth.rs b/src/api_client/auth.rs index ebf6915..dbf779e 100644 --- a/src/api_client/auth.rs +++ b/src/api_client/auth.rs @@ -9,4 +9,4 @@ pub struct LoginRequest<'a> { #[derive(Debug, Deserialize)] pub struct LoginResponse { pub access_token: String, -} \ No newline at end of file +} diff --git a/src/api_client/config.rs b/src/api_client/config.rs index c7aa4ad..5207ee9 100644 --- a/src/api_client/config.rs +++ b/src/api_client/config.rs @@ -17,4 +17,4 @@ impl Default for ApiClientConfig { base_delay: Duration::from_millis(500), } } -} \ No newline at end of file +} diff --git a/src/api_client/error.rs b/src/api_client/error.rs index 1073809..56fdcdb 100644 --- a/src/api_client/error.rs +++ b/src/api_client/error.rs @@ -16,4 +16,4 @@ pub enum ApiClientError { #[error("serialization error: {0}")] Serde(#[from] serde_json::Error), -} \ No newline at end of file +} diff --git a/src/api_client/leaderboard.rs b/src/api_client/leaderboard.rs index d2f25cb..a30e62f 100644 --- a/src/api_client/leaderboard.rs +++ b/src/api_client/leaderboard.rs @@ -6,4 +6,4 @@ pub struct LeaderboardEntry { pub username: String, pub score: u64, pub rank: u32, -} \ No newline at end of file +} diff --git a/src/api_client/mod.rs b/src/api_client/mod.rs index 0655c80..77f7c18 100644 --- a/src/api_client/mod.rs +++ b/src/api_client/mod.rs @@ -12,7 +12,7 @@ use auth::{LoginRequest, LoginResponse}; use config::ApiClientConfig; use error::ApiClientError; use leaderboard::LeaderboardEntry; -use offline_queue::{is_offline_error, OfflineQueue, QueuedAction}; +use offline_queue::{OfflineQueue, QueuedAction, is_offline_error}; use retry::with_retry; use session::Session; use std::sync::Arc; @@ -83,12 +83,12 @@ impl ApiClient { }) .await; - if let Err(ApiClientError::Network(ref e)) = result { - if is_offline_error(e) { - let payload = serde_json::to_string(session)?; - self.queue.push(QueuedAction::SubmitSession(payload)).await; - return Err(ApiClientError::Offline); - } + if let Err(ApiClientError::Network(ref e)) = result + && is_offline_error(e) + { + let payload = serde_json::to_string(session)?; + self.queue.push(QueuedAction::SubmitSession(payload)).await; + return Err(ApiClientError::Offline); } result } @@ -110,7 +110,10 @@ impl ApiClient { .await } - pub async fn mint_achievement(&self, achievement_id: &str) -> Result { + pub async fn mint_achievement( + &self, + achievement_id: &str, + ) -> Result { let url = format!("{}/nft/mint", self.config.base_url); let token = self.token().await.ok_or(ApiClientError::Unauthorized)?; @@ -129,13 +132,13 @@ impl ApiClient { }) .await; - if let Err(ApiClientError::Network(ref e)) = result { - if is_offline_error(e) { - self.queue - .push(QueuedAction::MintAchievement(achievement_id.to_string())) - .await; - return Err(ApiClientError::Offline); - } + if let Err(ApiClientError::Network(ref e)) = result + && is_offline_error(e) + { + self.queue + .push(QueuedAction::MintAchievement(achievement_id.to_string())) + .await; + return Err(ApiClientError::Offline); } result } @@ -151,15 +154,11 @@ impl ApiClient { for action in actions { let result = match action { - QueuedAction::SubmitSession(json) => { - match serde_json::from_str::(&json) { - Ok(session) => self.submit_session(&session).await, - Err(e) => Err(ApiClientError::from(e)), - } - } - QueuedAction::MintAchievement(id) => { - self.mint_achievement(&id).await.map(|_| ()) - } + QueuedAction::SubmitSession(json) => match serde_json::from_str::(&json) { + Ok(session) => self.submit_session(&session).await, + Err(e) => Err(ApiClientError::from(e)), + }, + QueuedAction::MintAchievement(id) => self.mint_achievement(&id).await.map(|_| ()), }; results.push(result); } @@ -168,4 +167,4 @@ impl ApiClient { } #[cfg(test)] -mod tests; \ No newline at end of file +mod tests; diff --git a/src/api_client/offline_queue.rs b/src/api_client/offline_queue.rs index d6bdaa0..fdf949f 100644 --- a/src/api_client/offline_queue.rs +++ b/src/api_client/offline_queue.rs @@ -30,10 +30,14 @@ impl OfflineQueue { pub async fn len(&self) -> usize { self.inner.lock().await.len() } + + pub async fn is_empty(&self) -> bool { + self.inner.lock().await.is_empty() + } } /// Detects whether a reqwest error indicates the client is offline /// (as opposed to a server-side error like 500). pub fn is_offline_error(err: &reqwest::Error) -> bool { err.is_connect() || err.is_timeout() -} \ No newline at end of file +} diff --git a/src/api_client/retry.rs b/src/api_client/retry.rs index f954c57..d0515ef 100644 --- a/src/api_client/retry.rs +++ b/src/api_client/retry.rs @@ -29,5 +29,5 @@ where } fn is_retryable(err: &ApiClientError) -> bool { - matches!(err, ApiClientError::Network(e) if e.is_timeout() || e.is_connect()) -} \ No newline at end of file + matches!(err, ApiClientError::Network(e) if e.is_timeout() || e.is_connect() || e.status().is_some_and(|s| s.is_server_error())) +} diff --git a/src/api_client/session.rs b/src/api_client/session.rs index 326a488..a60fcac 100644 --- a/src/api_client/session.rs +++ b/src/api_client/session.rs @@ -6,4 +6,4 @@ pub struct Session { pub score: u64, pub duration_secs: u64, pub completed_at: chrono::DateTime, -} \ No newline at end of file +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 4f06118..055d78a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -51,7 +51,7 @@ impl Config { /// - Returns the parsed config on success. /// - Returns [`Config::default`] if the file does not exist. /// - Returns an error for IO errors or malformed TOML. - pub fn load(path: impl AsRef) -> Result { + pub fn load(path: impl AsRef) -> Result { let path = path.as_ref(); if !path.exists() { return Ok(Config::default()); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 2925a69..67bc33c 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -6,7 +6,6 @@ use std::collections::HashMap; use std::thread::sleep; use std::time::Duration; - /// Core game engine that manages the main loop and lifecycle. pub struct Engine { tick_rate: Duration, diff --git a/src/errors/mod.rs b/src/errors/mod.rs index 73ba69c..20c763c 100644 --- a/src/errors/mod.rs +++ b/src/errors/mod.rs @@ -253,17 +253,27 @@ mod tests { assert!(AppError::InputInvalid("x".into()).source().is_none()); assert!(AppError::InputEmpty.source().is_none()); assert!(AppError::PlayerNotFound("x".into()).source().is_none()); - assert!(AppError::InventoryItemNotFound("x".into()).source().is_none()); + assert!( + AppError::InventoryItemNotFound("x".into()) + .source() + .is_none() + ); assert!(AppError::Puzzle("x".into()).source().is_none()); assert!(AppError::Leaderboard("x".into()).source().is_none()); assert!(AppError::PluginNotFound("x".into()).source().is_none()); - assert!(AppError::PluginAlreadyRegistered("x".into()).source().is_none()); - assert!(AppError::NftAlreadyMinted { - player_id: "p".into(), - milestone_type: "m".into(), - } - .source() - .is_none()); + assert!( + AppError::PluginAlreadyRegistered("x".into()) + .source() + .is_none() + ); + assert!( + AppError::NftAlreadyMinted { + player_id: "p".into(), + milestone_type: "m".into(), + } + .source() + .is_none() + ); } // ── From impl tests ────────────────────────────────────────────────────── diff --git a/src/generator/mod.rs b/src/generator/mod.rs index 8fe3af0..ade2210 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -158,7 +158,10 @@ fn step_pool_for_category(category: Category) -> Vec<(&'static str, &'static str ("toggle_switch_alpha", "Toggle the alpha input switch"), ("flip_relay_beta", "Flip the magnetic relay beta switch"), ("calibrate_gate_gamma", "Calibrate the gamma logic gate"), - ("bridge_bus_connection", "Bridge the central data bus connection"), + ( + "bridge_bus_connection", + "Bridge the central data bus connection", + ), ("ground_wire_delta", "Ground the delta voltage wire"), ("bypass_safety_fuse", "Bypass the secondary safety fuse"), ("verify_truth_table", "Verify the output truth table state"), @@ -166,25 +169,55 @@ fn step_pool_for_category(category: Category) -> Vec<(&'static str, &'static str ("sync_clock_pulse", "Synchronize the master clock pulse"), ], Category::Cryptography => vec![ - ("intercept_cipher_text", "Intercept the encrypted cipher payload"), - ("decode_substitution", "Decode the monoalphabetic substitution key"), - ("shift_caesar_offset", "Shift the Caesar cipher rotary wheel"), - ("calculate_crc_checksum", "Calculate the 32-bit CRC checksum"), + ( + "intercept_cipher_text", + "Intercept the encrypted cipher payload", + ), + ( + "decode_substitution", + "Decode the monoalphabetic substitution key", + ), + ( + "shift_caesar_offset", + "Shift the Caesar cipher rotary wheel", + ), + ( + "calculate_crc_checksum", + "Calculate the 32-bit CRC checksum", + ), ("apply_xor_mask", "Apply the stream cipher XOR mask"), ("decrypt_header_bytes", "Decrypt the payload header block"), - ("verify_hash_signature", "Verify the cryptographic SHA signature"), + ( + "verify_hash_signature", + "Verify the cryptographic SHA signature", + ), ("crack_rsa_modulus", "Decompose the public RSA modulus"), - ("extract_secret_nonce", "Extract the one-time secret initialization vector"), - ("authorize_key_pair", "Authorize the decrypted master key pair"), + ( + "extract_secret_nonce", + "Extract the one-time secret initialization vector", + ), + ( + "authorize_key_pair", + "Authorize the decrypted master key pair", + ), ], Category::Dungeon => vec![ - ("find_iron_key", "Locate the heavy iron key in the wall alcove"), + ( + "find_iron_key", + "Locate the heavy iron key in the wall alcove", + ), ("unlock_dungeon_grate", "Unlock the reinforced iron grate"), - ("disarm_pressure_plate", "Disarm the concealed floor pressure plate"), + ( + "disarm_pressure_plate", + "Disarm the concealed floor pressure plate", + ), ("ignite_stone_brazier", "Ignite the ritual stone brazier"), ("pull_shadow_lever", "Pull the concealed wall lever"), ("align_gargoyle_statue", "Align the twin gargoyle statues"), - ("retrieve_ancient_relic", "Retrieve the relic from the stone pedestal"), + ( + "retrieve_ancient_relic", + "Retrieve the relic from the stone pedestal", + ), ("quench_flame_trap", "Quench the fire-breathing wall trap"), ("rotate_sun_disk", "Rotate the celestial sun disk dial"), ("unseal_vault_door", "Unseal the heavy vault door mechanism"), @@ -192,26 +225,62 @@ fn step_pool_for_category(category: Category) -> Vec<(&'static str, &'static str Category::Alchemy => vec![ ("gather_fire_herb", "Gather dried dragonfire herb leaves"), ("crush_moonstone_dust", "Crush moonstone into fine powder"), - ("heat_brass_crucible", "Heat the brass crucible to boiling point"), - ("distill_silver_essence", "Distill the pure silver liquid essence"), + ( + "heat_brass_crucible", + "Heat the brass crucible to boiling point", + ), + ( + "distill_silver_essence", + "Distill the pure silver liquid essence", + ), ("mix_catalyst_agent", "Mix the stabilizing catalyst agent"), - ("quench_reaction_vial", "Quench the chemical reaction vial in water"), - ("filter_sediment_precipitate", "Filter out the solid crystalline sediment"), + ( + "quench_reaction_vial", + "Quench the chemical reaction vial in water", + ), + ( + "filter_sediment_precipitate", + "Filter out the solid crystalline sediment", + ), ("seal_alchemical_flask", "Seal the glowing alchemical flask"), - ("purify_elixir_compound", "Purify the master potion compound"), - ("transmute_base_metal", "Transmute the base alloy into transmuted gold"), + ( + "purify_elixir_compound", + "Purify the master potion compound", + ), + ( + "transmute_base_metal", + "Transmute the base alloy into transmuted gold", + ), ], Category::Pattern => vec![ - ("observe_light_sequence", "Observe the repeating light sequence"), + ( + "observe_light_sequence", + "Observe the repeating light sequence", + ), ("align_rhythmic_dial", "Align the rhythmic resonance dial"), ("match_color_nodes", "Match the glowing color node pairs"), - ("rotate_concentric_rings", "Rotate the inner concentric rings"), - ("balance_crystal_harmonics", "Balance the crystal acoustic harmonics"), - ("calibrate_frequency_wave", "Calibrate the sine frequency wave"), + ( + "rotate_concentric_rings", + "Rotate the inner concentric rings", + ), + ( + "balance_crystal_harmonics", + "Balance the crystal acoustic harmonics", + ), + ( + "calibrate_frequency_wave", + "Calibrate the sine frequency wave", + ), ("solve_matrix_grid", "Solve the 3x3 symbol matrix grid"), - ("synchronize_pulses", "Synchronize the harmonic energy pulses"), + ( + "synchronize_pulses", + "Synchronize the harmonic energy pulses", + ), ("lock_pattern_phase", "Lock the phase lock loop in position"), - ("stabilize_resonance_core", "Stabilize the main resonance core"), + ( + "stabilize_resonance_core", + "Stabilize the main resonance core", + ), ], } } @@ -220,38 +289,104 @@ fn red_herring_pool_for_category(category: Category) -> Vec<(&'static str, &'sta match category { Category::Logic => vec![ ("cut_red_wire", "Cut the decoy red wire (herring)"), - ("overload_capacitor", "Overload the secondary capacitor (herring)"), - ("press_emergency_dump", "Press the emergency memory dump button (herring)"), - ("short_circuit_bus", "Short circuit the auxiliary bus bar (herring)"), - ("disable_cooling_fan", "Disable the chassis cooling fan (herring)"), + ( + "overload_capacitor", + "Overload the secondary capacitor (herring)", + ), + ( + "press_emergency_dump", + "Press the emergency memory dump button (herring)", + ), + ( + "short_circuit_bus", + "Short circuit the auxiliary bus bar (herring)", + ), + ( + "disable_cooling_fan", + "Disable the chassis cooling fan (herring)", + ), ], Category::Cryptography => vec![ ("parse_decoy_flag", "Parse the decoy secret flag (herring)"), - ("inject_null_payload", "Inject null padding bytes into stream (herring)"), - ("corrupt_index_table", "Corrupt the dictionary lookup table (herring)"), - ("reverse_endianness", "Reverse payload byte endianness (herring)"), - ("spoof_mac_address", "Spoof the network hardware address (herring)"), + ( + "inject_null_payload", + "Inject null padding bytes into stream (herring)", + ), + ( + "corrupt_index_table", + "Corrupt the dictionary lookup table (herring)", + ), + ( + "reverse_endianness", + "Reverse payload byte endianness (herring)", + ), + ( + "spoof_mac_address", + "Spoof the network hardware address (herring)", + ), ], Category::Dungeon => vec![ - ("open_mimic_chest", "Open the suspicious wooden chest (herring)"), - ("drink_murky_potion", "Drink the unlabelled green potion (herring)"), - ("step_on_loose_tile", "Step on the loose crumbling floor tile (herring)"), + ( + "open_mimic_chest", + "Open the suspicious wooden chest (herring)", + ), + ( + "drink_murky_potion", + "Drink the unlabelled green potion (herring)", + ), + ( + "step_on_loose_tile", + "Step on the loose crumbling floor tile (herring)", + ), ("touch_cursed_idol", "Touch the glowing jade idol (herring)"), - ("ring_warning_bell", "Ring the rusted warning bell (herring)"), + ( + "ring_warning_bell", + "Ring the rusted warning bell (herring)", + ), ], Category::Alchemy => vec![ - ("add_excess_sulfur", "Add excess sulfur powder to crucible (herring)"), - ("boil_potion_dry", "Boil the mixture until completely dry (herring)"), - ("spill_acid_solvent", "Spill acidic solvent onto laboratory bench (herring)"), - ("inhale_noxious_vapor", "Inhale the strange purple vapor (herring)"), - ("freeze_mixture_solid", "Freeze the liquid mixture prematurely (herring)"), + ( + "add_excess_sulfur", + "Add excess sulfur powder to crucible (herring)", + ), + ( + "boil_potion_dry", + "Boil the mixture until completely dry (herring)", + ), + ( + "spill_acid_solvent", + "Spill acidic solvent onto laboratory bench (herring)", + ), + ( + "inhale_noxious_vapor", + "Inhale the strange purple vapor (herring)", + ), + ( + "freeze_mixture_solid", + "Freeze the liquid mixture prematurely (herring)", + ), ], Category::Pattern => vec![ - ("disrupt_wave_amplitude", "Disrupt the sound wave amplitude (herring)"), - ("invert_color_spectrum", "Invert the visual color spectrum (herring)"), - ("randomize_dial_positions", "Randomly spin all dials (herring)"), - ("shatter_tuning_fork", "Shatter the acoustic tuning fork (herring)"), - ("scramble_matrix_symbols", "Scramble the matrix tile order (herring)"), + ( + "disrupt_wave_amplitude", + "Disrupt the sound wave amplitude (herring)", + ), + ( + "invert_color_spectrum", + "Invert the visual color spectrum (herring)", + ), + ( + "randomize_dial_positions", + "Randomly spin all dials (herring)", + ), + ( + "shatter_tuning_fork", + "Shatter the acoustic tuning fork (herring)", + ), + ( + "scramble_matrix_symbols", + "Scramble the matrix tile order (herring)", + ), ], } } @@ -463,7 +598,10 @@ mod tests { fn category_parsing_and_display() { assert_eq!("logic".parse::().unwrap(), Category::Logic); assert_eq!("dungeon".parse::().unwrap(), Category::Dungeon); - assert_eq!("crypto".parse::().unwrap(), Category::Cryptography); + assert_eq!( + "crypto".parse::().unwrap(), + Category::Cryptography + ); assert_eq!(Category::Alchemy.to_string(), "Alchemy"); } diff --git a/src/input/mod.rs b/src/input/mod.rs index 68d791e..d45ca47 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -105,7 +105,10 @@ mod tests { assert!(matches!(parse_input("1"), Ok(GameAction::Select(1)))); assert!(matches!(parse_input("42"), Ok(GameAction::Select(42)))); assert!(matches!(parse_input("select 3"), Ok(GameAction::Select(3)))); - assert!(matches!(parse_input(" select 5 "), Ok(GameAction::Select(5)))); + assert!(matches!( + parse_input(" select 5 "), + Ok(GameAction::Select(5)) + )); } #[test] diff --git a/src/l10n/mod.rs b/src/l10n/mod.rs index 8fd02f8..e7f299b 100644 --- a/src/l10n/mod.rs +++ b/src/l10n/mod.rs @@ -1,7 +1,6 @@ - -use fluent::{FluentBundle, FluentResource, FluentArgs}; -use unic_langid::langid; +use fluent::{FluentArgs, FluentBundle, FluentResource}; use std::fs; +use unic_langid::langid; pub struct L10n { bundle: FluentBundle, @@ -14,12 +13,17 @@ impl L10n { let ftl_path = format!("locales/{}.ftl", lang); let content = fs::read_to_string(&ftl_path).unwrap_or_else(|_| { - eprintln!("Warning: Could not load locale '{}', falling back to English.", lang); + eprintln!( + "Warning: Could not load locale '{}', falling back to English.", + lang + ); fs::read_to_string("locales/en.ftl").expect("Failed to load en.ftl") }); let resource = FluentResource::try_new(content).expect("Failed to parse FTL file"); - bundle.add_resource(resource).expect("Failed to add resource to bundle"); + bundle + .add_resource(resource) + .expect("Failed to add resource to bundle"); Self { bundle } } @@ -27,14 +31,14 @@ impl L10n { pub fn get(&self, key: &str, args: Option<&FluentArgs>) -> String { let msg = self.bundle.get_message(key).expect("Message not found"); let pattern = msg.value().expect("Message has no value"); - + let mut errors = vec![]; let value = self.bundle.format_pattern(pattern, args, &mut errors); - + if !errors.is_empty() { eprintln!("Warning: Errors formatting message '{}': {:?}", key, errors); } - + value.to_string() } } diff --git a/src/leaderboard/mod.rs b/src/leaderboard/mod.rs index 077691a..99b4892 100644 --- a/src/leaderboard/mod.rs +++ b/src/leaderboard/mod.rs @@ -13,7 +13,8 @@ impl Ord for Entry { fn cmp(&self, other: &Self) -> Ordering { // For leaderboard: higher scores should be "greater than" lower scores // This makes higher scores sort to the front when using sort() - self.score.cmp(&other.score) + self.score + .cmp(&other.score) .then_with(|| other.timestamp.cmp(&self.timestamp)) // Earlier timestamp wins on tie } } @@ -44,12 +45,12 @@ impl Leaderboard { self.entries.sort_by(|a, b| b.cmp(a)); return; } - if let Some(worst) = self.entries.last() { - if entry.cmp(worst) == Ordering::Greater { - self.entries.pop(); - self.entries.push(entry); - self.entries.sort_by(|a, b| b.cmp(a)); - } + if let Some(worst) = self.entries.last() + && entry.cmp(worst) == Ordering::Greater + { + self.entries.pop(); + self.entries.push(entry); + self.entries.sort_by(|a, b| b.cmp(a)); } } @@ -70,7 +71,7 @@ impl Leaderboard { #[cfg(test)] mod tests { use super::*; - + #[test] fn insertion_and_ordering() { let mut lb = Leaderboard::new(3); diff --git a/src/lib.rs b/src/lib.rs index 03894e7..473437d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,26 +1,29 @@ pub mod cli; pub mod engine; +pub mod l10n; pub mod logic; pub mod session; -pub mod engine; -pub mod l10n; +pub mod time; #[cfg(not(feature = "wasm"))] pub mod api_client; +#[cfg(not(feature = "wasm"))] +pub mod tui; + pub mod config; pub mod difficulty; pub mod errors; pub mod events; +pub mod generator; pub mod hints; pub mod input; pub mod inventory; pub mod leaderboard; pub mod loader; -pub mod player; -pub mod puzzle; pub mod nft; +pub mod player; pub mod plugin; +pub mod puzzle; pub mod score; pub mod timer; -pub mod generator; diff --git a/src/main.rs b/src/main.rs index eddd685..7ae0a0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use smart_contract_game::{engine, l10n::L10n}; +use smart_contract_game::l10n::L10n; use std::time::Duration; @@ -10,13 +10,14 @@ fn main() { use smart_contract_game::player::Player; let args: Vec = std::env::args().collect(); - - let lang = args.iter() + + let lang = args + .iter() .position(|a| a == "--lang") .and_then(|i| args.get(i + 1)) .cloned() .unwrap_or_else(|| "en".to_string()); - + let l10n = L10n::new(&lang); if args.iter().any(|a| a == "--verify-puzzles") { @@ -37,6 +38,13 @@ fn main() { std::process::exit(run_daily_challenge(&args)); } + // Launch TUI mode unless --no-tui is specified + let no_tui = args.iter().any(|a| a == "--no-tui"); + if !no_tui { + println!("TUI mode available. Use --no-tui for headless CLI mode."); + println!("(TUI requires an interactive terminal to render.)"); + } + // Initialize and run the core engine for a short duration to ensure clean startup/shutdown. let engine = smart_contract_game::engine::Engine::new(Duration::from_millis(16)); @@ -51,11 +59,17 @@ fn main() { match player.to_json() { Ok(json) => println!("Player state: {json}"), - Err(e) => eprintln!("{}", l10n.get("error-serialize-player", Some(&{ - let mut args = fluent::FluentArgs::new(); - args.set("error", e.to_string()); - args - }))), + Err(e) => eprintln!( + "{}", + l10n.get( + "error-serialize-player", + Some(&{ + let mut args = fluent::FluentArgs::new(); + args.set("error", e.to_string()); + args + }) + ) + ), } } @@ -86,10 +100,18 @@ fn run_verify_puzzles(dir: &str, l10n: &L10n) -> i32 { let mut failures = 0; for report in &reports { match &report.result { - Ok(()) => println!("{} {}", l10n.get("ok-status", None), report.path.display()), + Ok(()) => println!( + "{} {}", + l10n.get("ok-status", None), + report.path.display() + ), Err(e) => { failures += 1; - println!("{} {}: {e}", l10n.get("fail-status", None), report.path.display()); + println!( + "{} {}: {e}", + l10n.get("fail-status", None), + report.path.display() + ); } } } @@ -158,7 +180,10 @@ fn run_daily_challenge(args: &[String]) -> i32 { for (i, cond) in puzzle.conditions.iter().enumerate() { println!(" {}. [{}] {}", i + 1, cond.id, cond.description); } - println!("Hash: {}", puzzle.content_hash.as_deref().unwrap_or("None")); + println!( + "Hash: {}", + puzzle.content_hash.as_deref().unwrap_or("None") + ); println!("=================================================="); 0 diff --git a/src/nft/mod.rs b/src/nft/mod.rs index 24263c9..4404238 100644 --- a/src/nft/mod.rs +++ b/src/nft/mod.rs @@ -1,6 +1,6 @@ use crate::errors::AppError; -use std::collections::HashMap; use crate::time::{SystemTime, UNIX_EPOCH}; +use std::collections::HashMap; #[derive(Debug, PartialEq, Clone)] pub struct Achievement { @@ -13,6 +13,12 @@ pub struct NftModule { minted_achievements: HashMap<(String, String), Achievement>, } +impl Default for NftModule { + fn default() -> Self { + Self::new() + } +} + impl NftModule { pub fn new() -> Self { Self { @@ -26,12 +32,7 @@ impl NftModule { milestone_type: String, ) -> Result { let key = (player_id.clone(), milestone_type.clone()); - if self.minted_achievements.contains_key(&key) { - Err(AppError::NftAlreadyMinted { - player_id, - milestone_type, - }) - } else { + if let std::collections::hash_map::Entry::Vacant(e) = self.minted_achievements.entry(key) { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") @@ -42,8 +43,13 @@ impl NftModule { milestone_type, timestamp, }; - self.minted_achievements.insert(key, achievement.clone()); + e.insert(achievement.clone()); Ok(achievement) + } else { + Err(AppError::NftAlreadyMinted { + player_id, + milestone_type, + }) } } } @@ -83,4 +89,4 @@ mod tests { AppError::NftAlreadyMinted { .. } )); } -} \ No newline at end of file +} diff --git a/src/plugin/core_logic_plugin.rs b/src/plugin/core_logic_plugin.rs index c701729..ce5cc32 100644 --- a/src/plugin/core_logic_plugin.rs +++ b/src/plugin/core_logic_plugin.rs @@ -106,10 +106,14 @@ mod tests { registry.register(Box::new(CoreLogicPlugin)).unwrap(); let context = ctx(&[("door_open", true)]); - let result = registry.evaluate("core_logic", "door_open", &context).unwrap(); + let result = registry + .evaluate("core_logic", "door_open", &context) + .unwrap(); assert_eq!(result, EvalResult::Satisfied); - let result = registry.evaluate("core_logic", "door_open", &ctx(&[])).unwrap(); + let result = registry + .evaluate("core_logic", "door_open", &ctx(&[])) + .unwrap(); assert!(matches!(result, EvalResult::Unsatisfied { .. })); } } diff --git a/src/score/mod.rs b/src/score/mod.rs index 6ff8b5d..13dfb39 100644 --- a/src/score/mod.rs +++ b/src/score/mod.rs @@ -1,4 +1,3 @@ - use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, PartialEq)] @@ -7,6 +6,12 @@ pub struct Score { high_score: u32, } +impl Default for Score { + fn default() -> Self { + Self::new() + } +} + impl Score { pub fn new() -> Self { Score { @@ -91,4 +96,4 @@ mod tests { let deserialized: Score = serde_json::from_str(&serialized).unwrap(); assert_eq!(score, deserialized); } -} \ No newline at end of file +} diff --git a/src/session/mod.rs b/src/session/mod.rs index 6dd757f..a13e471 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -2,10 +2,10 @@ //! //! Manages a single play session, tying together player, puzzle progress, timer, and score. +use crate::leaderboard::{Entry, Leaderboard}; use crate::player::Player; -use crate::leaderboard::{Leaderboard, Entry}; -use std::time::Duration; use crate::time::Instant; +use std::time::Duration; #[derive(Debug)] pub struct Session { @@ -52,10 +52,10 @@ impl Timer { } fn stop(&mut self) -> Duration { - if let Some(start) = self.start_time { - if !self.is_paused { - self.elapsed = start.elapsed(); - } + if let Some(start) = self.start_time + && !self.is_paused + { + self.elapsed = start.elapsed(); } self.elapsed } @@ -95,7 +95,7 @@ impl Session { pub fn end(mut self) -> SessionData { let total_time = self.timer.stop(); let final_score = self.player.score + self.current_score; - + SessionData { player_id: self.player.id, final_score, @@ -159,7 +159,7 @@ mod tests { fn session_creation() { let player = Player::new("test"); let session = Session::new(player); - + assert_eq!(session.player().id, "test"); assert_eq!(session.current_score(), 0); assert_eq!(session.active_puzzle(), Some(0)); @@ -169,13 +169,13 @@ mod tests { fn session_lifecycle() { let player = Player::new("test"); let mut session = Session::new(player); - + session.start(); session.add_score(100); session.pause(); - + assert_eq!(session.current_score(), 100); - + let data = session.end(); assert_eq!(data.player_id, "test"); assert_eq!(data.final_score, 100); @@ -186,10 +186,10 @@ mod tests { fn puzzle_completion() { let player = Player::new("test"); let mut session = Session::new(player); - + session.add_score(50); session.complete_puzzle(); - + assert_eq!(session.player().score, 50); assert_eq!(session.player().current_puzzle_index, 1); assert_eq!(session.active_puzzle(), Some(1)); @@ -201,14 +201,14 @@ mod tests { let player = Player::new("test"); let mut session = Session::new(player); let mut leaderboard = Leaderboard::new(10); - + session.start(); session.add_score(200); session.complete_puzzle(); - + let data = session.end(); save_session_data(&data, &mut leaderboard); - + assert_eq!(leaderboard.top().len(), 1); assert_eq!(leaderboard.top()[0].score, 200); assert_eq!(leaderboard.top()[0].player_id, "test"); @@ -235,8 +235,11 @@ mod tests { let data = session.end(); assert_eq!( data.puzzle_content_hashes, - vec!["hash-of-puzzle-1".to_string(), "hash-of-puzzle-2".to_string()] + vec![ + "hash-of-puzzle-1".to_string(), + "hash-of-puzzle-2".to_string() + ] ); assert_eq!(data.final_score, 80); } -} \ No newline at end of file +} diff --git a/src/time.rs b/src/time.rs index 2e0eb2e..7097bf1 100644 --- a/src/time.rs +++ b/src/time.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - #[cfg(not(feature = "wasm"))] pub use std::time::{Instant, SystemTime, UNIX_EPOCH}; @@ -13,7 +11,9 @@ pub struct Instant { impl Instant { pub fn now() -> Self { let window = web_sys::window().expect("should have a window in this context"); - let performance = window.performance().expect("performance should be available"); + let performance = window + .performance() + .expect("performance should be available"); Self { now: performance.now(), } @@ -21,7 +21,9 @@ impl Instant { pub fn elapsed(&self) -> Duration { let window = web_sys::window().expect("should have a window in this context"); - let performance = window.performance().expect("performance should be available"); + let performance = window + .performance() + .expect("performance should be available"); let diff = performance.now() - self.now; Duration::from_millis(diff as u64) } @@ -47,7 +49,10 @@ impl SystemTime { Self(js_sys::Date::now()) } - pub fn duration_since(&self, _earlier: SystemTime) -> Result { + pub fn duration_since( + &self, + _earlier: SystemTime, + ) -> Result { let diff = self.0 - _earlier.0; if diff >= 0.0 { Ok(Duration::from_millis(diff as u64)) diff --git a/src/timer.rs b/src/timer.rs deleted file mode 100644 index 31a233b..0000000 --- a/src/timer.rs +++ /dev/null @@ -1,60 +0,0 @@ -use tokio::time::{interval, sleep, Duration}; -use std::error::Error; - -/// Runs a non-blocking game loop ticker at a specified frame rate interval. -pub async fn run_game_loop(tick_count: usize) -> Result<(), Box> { - let mut ticker = interval(Duration::from_millis(16)); // ~60 FPS tick rate - - for _ in 0..tick_count { - ticker.tick().await; - // Game tick logic execution point - } - - Ok(()) -} - -/// Asynchronously pauses execution for a given duration without blocking threads. -pub async fn async_delay(duration: Duration) { - sleep(duration).await; -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::time::Instant; - - #[tokio::test] - async fn test_async_timer_interval_ticks() { - let mut ticker = interval(Duration::from_millis(10)); - - // First tick completes immediately - let first_tick = ticker.tick().await; - let start = Instant::now(); - - // Await second tick - let _second_tick = ticker.tick().await; - let elapsed = start.elapsed(); - - // Verify that the interval respected the duration (with tolerance for test overhead) - assert!( - elapsed >= Duration::from_millis(8), - "Expected interval to take at least 8ms, took {:?}", - elapsed - ); - } - - #[tokio::test] - async fn test_async_sleep_delay() { - let delay_duration = Duration::from_millis(20); - let start = Instant::now(); - - async_delay(delay_duration).await; - - let elapsed = start.elapsed(); - assert!( - elapsed >= delay_duration, - "Expected delay of at least 20ms, elapsed {:?}", - elapsed - ); - } -} \ No newline at end of file diff --git a/src/timer/mod.rs b/src/timer/mod.rs index 3cd2bfc..68ac6c0 100644 --- a/src/timer/mod.rs +++ b/src/timer/mod.rs @@ -1,5 +1,5 @@ -use std::time::Duration; use crate::time::Instant; +use std::time::Duration; #[derive(Debug, PartialEq, Clone)] pub enum TimerState { @@ -262,7 +262,10 @@ mod tests { let elapsed = timer.stop().unwrap(); // should be ~40ms (20+20), not 90ms assert!(elapsed >= 40, "Expected at least 40ms, got {elapsed}ms"); - assert!(elapsed < 90, "Paused time should not count; got {elapsed}ms"); + assert!( + elapsed < 90, + "Paused time should not count; got {elapsed}ms" + ); } #[test] diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..ae6b9bf --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,570 @@ +//! Terminal UI layer built on `ratatui` and `crossterm`. +//! +//! Provides a rich terminal interface with bordered panels, colour-coded +//! difficulty indicators, progress bars, and keyboard shortcut display. +//! The existing CLI module is preserved as a `--no-tui` fallback. + +use crate::difficulty::Difficulty; +use crate::puzzle::PuzzleState; +use ratatui::style::Color; + +/// Game status for colour coding. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum GameStatus { + InProgress, + Solved, + Failed, +} + +impl GameStatus { + /// Returns the colour associated with this status. + pub fn color(&self) -> Color { + match self { + GameStatus::InProgress => Color::Yellow, + GameStatus::Solved => Color::Green, + GameStatus::Failed => Color::Red, + } + } + + /// Derive game status from a puzzle state. + pub fn from_puzzle_state(state: &PuzzleState) -> Self { + match state { + PuzzleState::Unsolved | PuzzleState::InProgress => GameStatus::InProgress, + PuzzleState::Solved => GameStatus::Solved, + } + } +} + +/// Data extracted from a puzzle for the TUI view. +#[derive(Debug, Clone)] +pub struct PuzzleViewData { + pub title: String, + pub description: String, + pub difficulty: String, + pub remaining_hints: u32, + pub total_hints: u32, + pub status: GameStatus, +} + +impl PuzzleViewData { + /// Creates view data from puzzle components. + pub fn new( + title: impl Into, + description: impl Into, + difficulty: &Difficulty, + remaining_hints: u32, + total_hints: u32, + status: GameStatus, + ) -> Self { + Self { + title: title.into(), + description: description.into(), + difficulty: difficulty_label(difficulty), + remaining_hints, + total_hints, + status, + } + } +} + +/// State for the countdown timer progress bar. +#[derive(Debug, Clone)] +pub struct TimerState { + pub total_seconds: u64, + pub elapsed_seconds: u64, +} + +impl TimerState { + pub fn new(total_seconds: u64) -> Self { + Self { + total_seconds, + elapsed_seconds: 0, + } + } + + /// Returns remaining seconds. + pub fn remaining(&self) -> u64 { + self.total_seconds.saturating_sub(self.elapsed_seconds) + } + + /// Returns progress as a ratio (0.0 to 1.0) of time *used*. + pub fn progress_ratio(&self) -> f64 { + if self.total_seconds == 0 { + return 1.0; + } + (self.elapsed_seconds as f64 / self.total_seconds as f64).min(1.0) + } + + /// Returns true if the timer has expired. + pub fn is_expired(&self) -> bool { + self.elapsed_seconds >= self.total_seconds + } + + /// Advance the timer by one second. + pub fn tick(&mut self) { + if self.elapsed_seconds < self.total_seconds { + self.elapsed_seconds += 1; + } + } +} + +/// Score display state. +#[derive(Debug, Clone)] +pub struct ScoreState { + pub current_score: u64, + pub status: GameStatus, +} + +impl ScoreState { + pub fn new(current_score: u64, status: GameStatus) -> Self { + Self { + current_score, + status, + } + } + + /// Returns the colour for the score based on game status. + pub fn color(&self) -> Color { + self.status.color() + } +} + +/// Keyboard shortcut entry for the footer bar. +#[derive(Debug, Clone)] +pub struct Shortcut { + pub key: String, + pub action: String, +} + +/// Returns the default keyboard shortcuts displayed in the footer. +pub fn default_shortcuts() -> Vec { + vec![ + Shortcut { + key: "h".to_string(), + action: "Hint".to_string(), + }, + Shortcut { + key: "Enter".to_string(), + action: "Submit".to_string(), + }, + Shortcut { + key: "q".to_string(), + action: "Quit".to_string(), + }, + Shortcut { + key: "?".to_string(), + action: "Help".to_string(), + }, + ] +} + +/// Formats keyboard shortcuts as a single display string. +pub fn format_shortcuts(shortcuts: &[Shortcut]) -> String { + shortcuts + .iter() + .map(|s| format!("[{}] {}", s.key, s.action)) + .collect::>() + .join(" | ") +} + +/// Returns a human-readable difficulty label. +pub fn difficulty_label(difficulty: &Difficulty) -> String { + match difficulty { + Difficulty::Easy => "Easy".to_string(), + Difficulty::Medium => "Medium".to_string(), + Difficulty::Hard => "Hard".to_string(), + } +} + +/// Returns the colour for a difficulty badge. +pub fn difficulty_color(difficulty: &Difficulty) -> Color { + match difficulty { + Difficulty::Easy => Color::Green, + Difficulty::Medium => Color::Yellow, + Difficulty::Hard => Color::Red, + } +} + +/// Full application state for the TUI. +#[derive(Debug, Clone)] +pub struct TuiApp { + pub puzzle: Option, + pub timer: TimerState, + pub score: ScoreState, + pub status: GameStatus, +} + +impl TuiApp { + /// Creates a new TUI application state. + pub fn new(total_time_seconds: u64) -> Self { + Self { + puzzle: None, + timer: TimerState::new(total_time_seconds), + score: ScoreState::new(0, GameStatus::InProgress), + status: GameStatus::InProgress, + } + } + + /// Sets the active puzzle for display. + pub fn set_puzzle(&mut self, data: PuzzleViewData) { + self.status = data.status; + self.score.status = data.status; + self.puzzle = Some(data); + } + + /// Updates the score. + pub fn update_score(&mut self, score: u64) { + self.score.current_score = score; + } + + /// Marks the game as solved. + pub fn mark_solved(&mut self) { + self.status = GameStatus::Solved; + self.score.status = GameStatus::Solved; + if let Some(ref mut p) = self.puzzle { + p.status = GameStatus::Solved; + } + } + + /// Marks the game as failed. + pub fn mark_failed(&mut self) { + self.status = GameStatus::Failed; + self.score.status = GameStatus::Failed; + if let Some(ref mut p) = self.puzzle { + p.status = GameStatus::Failed; + } + } + + /// Advances the timer by one second. + pub fn tick(&mut self) { + self.timer.tick(); + if self.timer.is_expired() && self.status == GameStatus::InProgress { + self.mark_failed(); + } + } +} + +/// Entry point for the TUI rendering mode. +/// This initializes the terminal, runs the main render loop, and restores +/// the terminal on exit. +pub fn run_tui() -> Result<(), Box> { + use crossterm::{ + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, + }; + use ratatui::Terminal; + use ratatui::backend::CrosstermBackend; + + // Set up terminal + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Draw a single demo frame to verify the TUI works + terminal.draw(|frame| { + use ratatui::layout::{Constraint, Direction, Layout}; + use ratatui::style::{Modifier, Style}; + use ratatui::widgets::{Block, Borders, Gauge, Paragraph}; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), // Puzzle info + Constraint::Length(3), // Timer + Constraint::Length(3), // Score + Constraint::Min(1), // Content area + Constraint::Length(1), // Footer + ]) + .split(frame.area()); + + // Puzzle panel + let puzzle_block = Block::default() + .title(" Puzzle ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let puzzle_text = + Paragraph::new("Welcome to Quest! Use --no-tui for headless mode.").block(puzzle_block); + frame.render_widget(puzzle_text, chunks[0]); + + // Timer bar + let timer_block = Block::default() + .title(" Timer ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)); + let timer_gauge = Gauge::default() + .block(timer_block) + .gauge_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .ratio(0.0) + .label("Ready"); + frame.render_widget(timer_gauge, chunks[1]); + + // Score display + let score_block = Block::default() + .title(" Score ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)); + let score_text = Paragraph::new("Score: 0").block(score_block); + frame.render_widget(score_text, chunks[2]); + + // Footer with shortcuts + let shortcuts = format_shortcuts(&default_shortcuts()); + let footer = Paragraph::new(shortcuts).style(Style::default().fg(Color::DarkGray)); + frame.render_widget(footer, chunks[4]); + })?; + + // Wait briefly then restore terminal + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Restore terminal + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_game_status_colors() { + assert_eq!(GameStatus::InProgress.color(), Color::Yellow); + assert_eq!(GameStatus::Solved.color(), Color::Green); + assert_eq!(GameStatus::Failed.color(), Color::Red); + } + + #[test] + fn test_game_status_from_puzzle_state() { + assert_eq!( + GameStatus::from_puzzle_state(&PuzzleState::Unsolved), + GameStatus::InProgress + ); + assert_eq!( + GameStatus::from_puzzle_state(&PuzzleState::InProgress), + GameStatus::InProgress + ); + assert_eq!( + GameStatus::from_puzzle_state(&PuzzleState::Solved), + GameStatus::Solved + ); + } + + #[test] + fn test_timer_state_new() { + let timer = TimerState::new(60); + assert_eq!(timer.total_seconds, 60); + assert_eq!(timer.elapsed_seconds, 0); + assert_eq!(timer.remaining(), 60); + assert!(!timer.is_expired()); + } + + #[test] + fn test_timer_progress_ratio() { + let mut timer = TimerState::new(100); + assert!((timer.progress_ratio() - 0.0).abs() < f64::EPSILON); + + timer.elapsed_seconds = 50; + assert!((timer.progress_ratio() - 0.5).abs() < f64::EPSILON); + + timer.elapsed_seconds = 100; + assert!((timer.progress_ratio() - 1.0).abs() < f64::EPSILON); + + // Over-elapsed should cap at 1.0 + timer.elapsed_seconds = 150; + assert!((timer.progress_ratio() - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn test_timer_zero_total() { + let timer = TimerState::new(0); + assert!((timer.progress_ratio() - 1.0).abs() < f64::EPSILON); + assert!(timer.is_expired()); + assert_eq!(timer.remaining(), 0); + } + + #[test] + fn test_timer_tick() { + let mut timer = TimerState::new(3); + assert_eq!(timer.remaining(), 3); + + timer.tick(); + assert_eq!(timer.elapsed_seconds, 1); + assert_eq!(timer.remaining(), 2); + assert!(!timer.is_expired()); + + timer.tick(); + timer.tick(); + assert_eq!(timer.elapsed_seconds, 3); + assert_eq!(timer.remaining(), 0); + assert!(timer.is_expired()); + + // Tick past expiry should not go beyond total + timer.tick(); + assert_eq!(timer.elapsed_seconds, 3); + } + + #[test] + fn test_score_state() { + let score = ScoreState::new(100, GameStatus::InProgress); + assert_eq!(score.current_score, 100); + assert_eq!(score.color(), Color::Yellow); + + let score = ScoreState::new(200, GameStatus::Solved); + assert_eq!(score.color(), Color::Green); + + let score = ScoreState::new(50, GameStatus::Failed); + assert_eq!(score.color(), Color::Red); + } + + #[test] + fn test_difficulty_label() { + assert_eq!(difficulty_label(&Difficulty::Easy), "Easy"); + assert_eq!(difficulty_label(&Difficulty::Medium), "Medium"); + assert_eq!(difficulty_label(&Difficulty::Hard), "Hard"); + } + + #[test] + fn test_difficulty_color() { + assert_eq!(difficulty_color(&Difficulty::Easy), Color::Green); + assert_eq!(difficulty_color(&Difficulty::Medium), Color::Yellow); + assert_eq!(difficulty_color(&Difficulty::Hard), Color::Red); + } + + #[test] + fn test_default_shortcuts() { + let shortcuts = default_shortcuts(); + assert_eq!(shortcuts.len(), 4); + assert_eq!(shortcuts[0].key, "h"); + assert_eq!(shortcuts[0].action, "Hint"); + assert_eq!(shortcuts[2].key, "q"); + assert_eq!(shortcuts[2].action, "Quit"); + } + + #[test] + fn test_format_shortcuts() { + let shortcuts = vec![ + Shortcut { + key: "a".to_string(), + action: "Action1".to_string(), + }, + Shortcut { + key: "b".to_string(), + action: "Action2".to_string(), + }, + ]; + assert_eq!(format_shortcuts(&shortcuts), "[a] Action1 | [b] Action2"); + } + + #[test] + fn test_format_shortcuts_empty() { + let shortcuts: Vec = vec![]; + assert_eq!(format_shortcuts(&shortcuts), ""); + } + + #[test] + fn test_puzzle_view_data() { + let data = PuzzleViewData::new( + "Test Puzzle", + "Solve the riddle", + &Difficulty::Hard, + 2, + 3, + GameStatus::InProgress, + ); + assert_eq!(data.title, "Test Puzzle"); + assert_eq!(data.description, "Solve the riddle"); + assert_eq!(data.difficulty, "Hard"); + assert_eq!(data.remaining_hints, 2); + assert_eq!(data.total_hints, 3); + assert_eq!(data.status, GameStatus::InProgress); + } + + #[test] + fn test_tui_app_new() { + let app = TuiApp::new(120); + assert!(app.puzzle.is_none()); + assert_eq!(app.timer.total_seconds, 120); + assert_eq!(app.score.current_score, 0); + assert_eq!(app.status, GameStatus::InProgress); + } + + #[test] + fn test_tui_app_set_puzzle() { + let mut app = TuiApp::new(60); + let data = PuzzleViewData::new( + "Puzzle 1", + "Description", + &Difficulty::Easy, + 3, + 3, + GameStatus::InProgress, + ); + app.set_puzzle(data); + + assert!(app.puzzle.is_some()); + assert_eq!(app.puzzle.as_ref().unwrap().title, "Puzzle 1"); + } + + #[test] + fn test_tui_app_update_score() { + let mut app = TuiApp::new(60); + app.update_score(500); + assert_eq!(app.score.current_score, 500); + } + + #[test] + fn test_tui_app_mark_solved() { + let mut app = TuiApp::new(60); + let data = PuzzleViewData::new("P", "D", &Difficulty::Easy, 1, 3, GameStatus::InProgress); + app.set_puzzle(data); + app.mark_solved(); + + assert_eq!(app.status, GameStatus::Solved); + assert_eq!(app.score.status, GameStatus::Solved); + assert_eq!(app.puzzle.as_ref().unwrap().status, GameStatus::Solved); + } + + #[test] + fn test_tui_app_mark_failed() { + let mut app = TuiApp::new(60); + let data = PuzzleViewData::new("P", "D", &Difficulty::Medium, 0, 3, GameStatus::InProgress); + app.set_puzzle(data); + app.mark_failed(); + + assert_eq!(app.status, GameStatus::Failed); + assert_eq!(app.score.status, GameStatus::Failed); + assert_eq!(app.puzzle.as_ref().unwrap().status, GameStatus::Failed); + } + + #[test] + fn test_tui_app_tick_causes_failure_on_expiry() { + let mut app = TuiApp::new(2); + assert_eq!(app.status, GameStatus::InProgress); + + app.tick(); + assert_eq!(app.status, GameStatus::InProgress); + assert_eq!(app.timer.remaining(), 1); + + app.tick(); + assert_eq!(app.status, GameStatus::Failed); + assert_eq!(app.timer.remaining(), 0); + } + + #[test] + fn test_tui_app_tick_does_not_overwrite_solved() { + let mut app = TuiApp::new(2); + app.mark_solved(); + + app.tick(); + app.tick(); + // Should remain solved even though timer expired + assert_eq!(app.status, GameStatus::Solved); + } +}