diff --git a/Cargo.lock b/Cargo.lock index 7a80cf6..f673557 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ 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 = "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", ] @@ -48,11 +48,20 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[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" @@ -77,9 +86,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[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", @@ -180,13 +189,13 @@ 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]] @@ -216,15 +225,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 = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +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 = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror", +] [[package]] name = "fnv" @@ -258,9 +311,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 +326,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 +336,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 +353,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 +431,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", @@ -409,9 +462,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 +482,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 +609,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 +623,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 +636,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 +650,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 +671,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", @@ -667,11 +721,30 @@ dependencies = [ "hashbrown", ] +[[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 = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "itoa" @@ -681,9 +754,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 +771,9 @@ 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" @@ -710,9 +783,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" @@ -731,9 +804,9 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[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" @@ -816,7 +889,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -874,24 +947,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", ] @@ -900,7 +979,12 @@ dependencies = [ name = "quest-contract" version = "0.1.0" dependencies = [ + "bincode", "chrono", + "fluent", + "fluent-bundle", + "js-sys", + "once_cell", "reqwest", "serde", "serde_json", @@ -909,14 +993,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", ] @@ -950,9 +1037,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 +1106,18 @@ 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 = "1.1.4" @@ -1034,9 +1133,9 @@ dependencies = [ [[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 +1155,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 +1214,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 +1241,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", @@ -1242,9 +1356,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 = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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 = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -1268,7 +1393,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1322,16 +1447,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 +1480,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 +1643,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 +1688,7 @@ checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" dependencies = [ "proc-macro-hack", "quote", - "syn", + "syn 2.0.119", "unic-langid-impl", ] @@ -1765,9 +1751,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 +1764,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 +1774,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,31 +1784,31 @@ 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", @@ -1849,7 +1835,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1860,7 +1846,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2014,9 +2000,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 +2023,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -2058,7 +2044,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -2070,9 +2056,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 +2067,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 +2079,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..e90eab9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ wasm-bindgen = { version = "0.2", optional = true } web-sys = { version = "0.3", features = ["Storage", "Window", "Performance"], optional = true } js-sys = { version = "0.3", optional = true } once_cell = { version = "1.19", optional = true } +bincode = "1" fluent = "0.16" fluent-bundle = "0.15" unic-langid = { version = "0.9", features = ["macros"] } 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..4e98b0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,10 @@ pub mod cli; pub mod engine; +pub mod l10n; pub mod logic; +pub mod replay; pub mod session; -pub mod engine; -pub mod l10n; +pub mod time; #[cfg(not(feature = "wasm"))] pub mod api_client; @@ -12,15 +13,15 @@ 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..70b28ac 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,10 @@ fn main() { std::process::exit(run_daily_challenge(&args)); } + if args.iter().any(|a| a == "--playback") { + std::process::exit(run_playback(&args)); + } + // 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 +56,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 +97,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,8 +177,75 @@ 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 } + +/// Runs the `--playback ` command: loads a `.qreplay` file and replays +/// all recorded events, printing a summary at the end. +fn run_playback(args: &[String]) -> i32 { + use smart_contract_game::replay::ReplayPlayer; + + let file_path = match args + .iter() + .position(|a| a == "--playback") + .and_then(|i| args.get(i + 1)) + { + Some(p) => p.clone(), + None => { + eprintln!("Usage: --playback [--speed <1|2|4>]"); + return 1; + } + }; + + let speed: u32 = args + .iter() + .position(|a| a == "--speed") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.parse().ok()) + .unwrap_or(1); + + if !matches!(speed, 1 | 2 | 4) { + eprintln!("Invalid speed: {speed}. Allowed values: 1, 2, 4"); + return 1; + } + + let player = match ReplayPlayer::load(&file_path) { + Ok(p) => p, + Err(e) => { + eprintln!("Failed to load replay file '{file_path}': {e}"); + return 1; + } + }; + + println!("=== Replay Playback ({speed}x speed) ==="); + if let Some(hash) = player.puzzle_content_hash() { + println!("Puzzle hash: {hash}"); + } + + let events = player.playback(speed); + for (timestamp_us, event) in &events { + let ms = timestamp_us / 1000; + println!("[{ms:>8}ms] {event}"); + } + + let summary = player.summary(); + print_replay_summary(&summary); + + 0 +} + +fn print_replay_summary(summary: &smart_contract_game::replay::ReplaySummary) { + println!(); + println!("=== Replay Summary ==="); + println!("Total time: {}ms", summary.total_time_ms); + println!("Hints used: {}", summary.hints_used); + println!("Attempts: {}", summary.attempt_count); + println!("Final score: {}", summary.final_score); + println!("======================"); +} 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/replay/mod.rs b/src/replay/mod.rs new file mode 100644 index 0000000..75b4228 --- /dev/null +++ b/src/replay/mod.rs @@ -0,0 +1,639 @@ +//! Replay recording and playback engine. +//! +//! Records every player action during a session as a timestamped event log +//! and supports deterministic playback. Replay files are serialized as +//! compact `.qreplay` binary files using `bincode`. + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::fs; +use std::io; +use std::path::Path; +use std::time::Duration; + +/// All recordable actions that can occur during a session. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ReplayEvent { + /// Session started with the given puzzle ID. + SessionStart { puzzle_id: String }, + /// Player revealed a hint (hint index, 0-based). + HintReveal { hint_index: u32 }, + /// Player submitted an answer attempt. + AnswerAttempt { answer: String, correct: bool }, + /// Session ended with final score. + SessionEnd { final_score: u64 }, +} + +impl fmt::Display for ReplayEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReplayEvent::SessionStart { puzzle_id } => { + write!(f, "SESSION_START puzzle={puzzle_id}") + } + ReplayEvent::HintReveal { hint_index } => { + write!(f, "HINT_REVEAL index={hint_index}") + } + ReplayEvent::AnswerAttempt { answer, correct } => { + let status = if *correct { "CORRECT" } else { "WRONG" }; + write!(f, "ANSWER_ATTEMPT answer=\"{answer}\" result={status}") + } + ReplayEvent::SessionEnd { final_score } => { + write!(f, "SESSION_END score={final_score}") + } + } + } +} + +/// A single timestamped event in the replay log. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TimestampedEvent { + /// Microseconds elapsed since session start. + pub timestamp_us: u64, + /// The event that occurred. + pub event: ReplayEvent, +} + +/// Header data stored alongside the event log in a `.qreplay` file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ReplayHeader { + /// Format version for forward compatibility. + pub version: u32, + /// Content hash of the puzzle for integrity cross-checking. + pub puzzle_content_hash: Option, +} + +/// Complete replay data serialized to a `.qreplay` file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ReplayData { + pub header: ReplayHeader, + pub events: Vec, +} + +/// Summary statistics computed from a completed replay. +#[derive(Debug, Clone, PartialEq)] +pub struct ReplaySummary { + pub total_time_ms: u64, + pub hints_used: u32, + pub attempt_count: u32, + pub final_score: u64, +} + +/// Records events during a live session. +pub struct ReplayRecorder { + start: std::time::Instant, + events: Vec, + puzzle_content_hash: Option, +} + +impl ReplayRecorder { + /// Creates a new recorder. Call this when the session begins. + pub fn new(puzzle_content_hash: Option) -> Self { + Self { + start: std::time::Instant::now(), + events: Vec::new(), + puzzle_content_hash, + } + } + + /// Records an event with the current timestamp relative to session start. + pub fn record(&mut self, event: ReplayEvent) { + let elapsed = self.start.elapsed(); + let timestamp_us = elapsed.as_micros() as u64; + self.events.push(TimestampedEvent { + timestamp_us, + event, + }); + } + + /// Records an event with an explicit microsecond timestamp (for testing). + pub fn record_at(&mut self, timestamp_us: u64, event: ReplayEvent) { + self.events.push(TimestampedEvent { + timestamp_us, + event, + }); + } + + /// Returns the number of recorded events. + pub fn event_count(&self) -> usize { + self.events.len() + } + + /// Builds the final replay data. + pub fn finish(self) -> ReplayData { + ReplayData { + header: ReplayHeader { + version: 1, + puzzle_content_hash: self.puzzle_content_hash, + }, + events: self.events, + } + } + + /// Saves the replay to a `.qreplay` binary file. + pub fn save_to_file(self, path: impl AsRef) -> io::Result<()> { + let data = self.finish(); + save_replay(&data, path) + } +} + +/// Handles playback of a recorded replay. +pub struct ReplayPlayer { + data: ReplayData, +} + +impl ReplayPlayer { + /// Loads a replay from a `.qreplay` binary file. + pub fn load(path: impl AsRef) -> io::Result { + let data = load_replay(path)?; + Ok(Self { data }) + } + + /// Creates a player from existing replay data (for testing). + pub fn from_data(data: ReplayData) -> Self { + Self { data } + } + + /// Returns the puzzle content hash from the replay header. + pub fn puzzle_content_hash(&self) -> Option<&str> { + self.data.header.puzzle_content_hash.as_deref() + } + + /// Checks whether the replay's puzzle hash matches an expected hash. + /// Returns `true` if they match or if no hash is stored. Returns `false` + /// on mismatch. + pub fn verify_integrity(&self, expected_hash: &str) -> bool { + match &self.data.header.puzzle_content_hash { + Some(stored) => stored == expected_hash, + None => true, + } + } + + /// Replays all events in order, returning them with adjusted timestamps + /// based on the playback speed multiplier. + pub fn playback(&self, speed: u32) -> Vec<(u64, String)> { + let speed = if speed == 0 { 1 } else { speed }; + self.data + .events + .iter() + .map(|e| { + let adjusted_us = e.timestamp_us / speed as u64; + (adjusted_us, e.event.to_string()) + }) + .collect() + } + + /// Returns the raw events for inspection. + pub fn events(&self) -> &[TimestampedEvent] { + &self.data.events + } + + /// Computes a summary of the replay. + pub fn summary(&self) -> ReplaySummary { + let total_time_us = self.data.events.last().map(|e| e.timestamp_us).unwrap_or(0); + + let mut hints_used: u32 = 0; + let mut attempt_count: u32 = 0; + let mut final_score: u64 = 0; + + for te in &self.data.events { + match &te.event { + ReplayEvent::HintReveal { .. } => hints_used += 1, + ReplayEvent::AnswerAttempt { .. } => attempt_count += 1, + ReplayEvent::SessionEnd { final_score: score } => final_score = *score, + _ => {} + } + } + + ReplaySummary { + total_time_ms: total_time_us / 1000, + hints_used, + attempt_count, + final_score, + } + } +} + +/// Serializes replay data to a binary file. +pub fn save_replay(data: &ReplayData, path: impl AsRef) -> io::Result<()> { + let bytes = + bincode::serialize(data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + if let Some(parent) = path.as_ref().parent() { + fs::create_dir_all(parent)?; + } + + fs::write(path, bytes) +} + +/// Deserializes replay data from a binary file. +pub fn load_replay(path: impl AsRef) -> io::Result { + let bytes = fs::read(path)?; + bincode::deserialize(&bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} + +/// Simulates playback by sleeping between events (real-time or accelerated). +/// Intended for interactive CLI playback. +pub fn playback_with_delays(data: &ReplayData, speed: u32) { + let speed = if speed == 0 { 1 } else { speed }; + let mut last_us: u64 = 0; + + for te in &data.events { + let delta_us = te.timestamp_us.saturating_sub(last_us); + let sleep_us = delta_us / speed as u64; + + if sleep_us > 0 { + std::thread::sleep(Duration::from_micros(sleep_us)); + } + + let ms = te.timestamp_us / 1000; + println!("[{ms:>8}ms] {}", te.event); + last_us = te.timestamp_us; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_event_recording() { + let mut recorder = ReplayRecorder::new(Some("abc123".to_string())); + recorder.record_at( + 0, + ReplayEvent::SessionStart { + puzzle_id: "puzzle-1".to_string(), + }, + ); + recorder.record_at(1_000_000, ReplayEvent::HintReveal { hint_index: 0 }); + recorder.record_at( + 2_000_000, + ReplayEvent::AnswerAttempt { + answer: "lever".to_string(), + correct: false, + }, + ); + recorder.record_at( + 3_500_000, + ReplayEvent::AnswerAttempt { + answer: "pull_lever".to_string(), + correct: true, + }, + ); + recorder.record_at(4_000_000, ReplayEvent::SessionEnd { final_score: 85 }); + + assert_eq!(recorder.event_count(), 5); + + let data = recorder.finish(); + assert_eq!(data.header.version, 1); + assert_eq!(data.header.puzzle_content_hash, Some("abc123".to_string())); + assert_eq!(data.events.len(), 5); + assert_eq!(data.events[0].timestamp_us, 0); + assert_eq!(data.events[4].timestamp_us, 4_000_000); + } + + #[test] + fn test_serialization_roundtrip() { + let mut recorder = ReplayRecorder::new(Some("hash-xyz".to_string())); + recorder.record_at( + 0, + ReplayEvent::SessionStart { + puzzle_id: "test-puzzle".to_string(), + }, + ); + recorder.record_at(500_000, ReplayEvent::HintReveal { hint_index: 0 }); + recorder.record_at( + 1_000_000, + ReplayEvent::AnswerAttempt { + answer: "test".to_string(), + correct: true, + }, + ); + recorder.record_at(1_500_000, ReplayEvent::SessionEnd { final_score: 100 }); + + let original = recorder.finish(); + + // Serialize + let bytes = bincode::serialize(&original).expect("serialize"); + // Deserialize + let restored: ReplayData = bincode::deserialize(&bytes).expect("deserialize"); + + assert_eq!(original, restored); + } + + #[test] + fn test_file_save_and_load() { + let dir = tempfile::tempdir().expect("create tempdir"); + let path = dir.path().join("test.qreplay"); + + let mut recorder = ReplayRecorder::new(Some("file-test-hash".to_string())); + recorder.record_at( + 0, + ReplayEvent::SessionStart { + puzzle_id: "file-test".to_string(), + }, + ); + recorder.record_at( + 1_000_000, + ReplayEvent::AnswerAttempt { + answer: "answer".to_string(), + correct: true, + }, + ); + recorder.record_at(2_000_000, ReplayEvent::SessionEnd { final_score: 50 }); + + let original = recorder.finish(); + save_replay(&original, &path).expect("save"); + + let loaded = load_replay(&path).expect("load"); + assert_eq!(original, loaded); + } + + #[test] + fn test_deterministic_playback() { + let mut recorder = ReplayRecorder::new(None); + recorder.record_at( + 0, + ReplayEvent::SessionStart { + puzzle_id: "det-test".to_string(), + }, + ); + recorder.record_at(1_000_000, ReplayEvent::HintReveal { hint_index: 0 }); + recorder.record_at(2_000_000, ReplayEvent::HintReveal { hint_index: 1 }); + recorder.record_at( + 3_000_000, + ReplayEvent::AnswerAttempt { + answer: "wrong".to_string(), + correct: false, + }, + ); + recorder.record_at( + 4_000_000, + ReplayEvent::AnswerAttempt { + answer: "right".to_string(), + correct: true, + }, + ); + recorder.record_at(5_000_000, ReplayEvent::SessionEnd { final_score: 80 }); + + let data = recorder.finish(); + let player = ReplayPlayer::from_data(data.clone()); + + // Playback at 1x + let playback_1x = player.playback(1); + assert_eq!(playback_1x.len(), 6); + assert_eq!(playback_1x[0].0, 0); // 0us + assert_eq!(playback_1x[1].0, 1_000_000); // 1s in us + + // Playback at 2x — timestamps halved + let playback_2x = player.playback(2); + assert_eq!(playback_2x[1].0, 500_000); // 0.5s in us + + // Playback at 4x — timestamps quartered + let playback_4x = player.playback(4); + assert_eq!(playback_4x[1].0, 250_000); // 0.25s in us + + // Event content is identical regardless of speed + assert_eq!(playback_1x[0].1, playback_2x[0].1); + assert_eq!(playback_1x[0].1, playback_4x[0].1); + + // Run it again — deterministic, same output + let player2 = ReplayPlayer::from_data(data); + let playback_1x_again = player2.playback(1); + assert_eq!(playback_1x, playback_1x_again); + } + + #[test] + fn test_replay_summary() { + let mut recorder = ReplayRecorder::new(None); + recorder.record_at( + 0, + ReplayEvent::SessionStart { + puzzle_id: "summary-test".to_string(), + }, + ); + recorder.record_at(1_000_000, ReplayEvent::HintReveal { hint_index: 0 }); + recorder.record_at(2_000_000, ReplayEvent::HintReveal { hint_index: 1 }); + recorder.record_at( + 3_000_000, + ReplayEvent::AnswerAttempt { + answer: "wrong".to_string(), + correct: false, + }, + ); + recorder.record_at( + 4_000_000, + ReplayEvent::AnswerAttempt { + answer: "right".to_string(), + correct: true, + }, + ); + recorder.record_at(5_000_000, ReplayEvent::SessionEnd { final_score: 75 }); + + let data = recorder.finish(); + let player = ReplayPlayer::from_data(data); + let summary = player.summary(); + + assert_eq!(summary.total_time_ms, 5000); // 5 seconds + assert_eq!(summary.hints_used, 2); + assert_eq!(summary.attempt_count, 2); + assert_eq!(summary.final_score, 75); + } + + #[test] + fn test_integrity_check_match() { + let data = ReplayData { + header: ReplayHeader { + version: 1, + puzzle_content_hash: Some("expected-hash-123".to_string()), + }, + events: vec![], + }; + let player = ReplayPlayer::from_data(data); + + assert!(player.verify_integrity("expected-hash-123")); + assert!(!player.verify_integrity("wrong-hash")); + } + + #[test] + fn test_integrity_check_no_hash() { + let data = ReplayData { + header: ReplayHeader { + version: 1, + puzzle_content_hash: None, + }, + events: vec![], + }; + let player = ReplayPlayer::from_data(data); + + // No hash stored — passes any check + assert!(player.verify_integrity("anything")); + } + + #[test] + fn test_integrity_mismatch_detection() { + let data = ReplayData { + header: ReplayHeader { + version: 1, + puzzle_content_hash: Some("correct-hash".to_string()), + }, + events: vec![TimestampedEvent { + timestamp_us: 0, + event: ReplayEvent::SessionStart { + puzzle_id: "integrity-test".to_string(), + }, + }], + }; + let player = ReplayPlayer::from_data(data); + + assert!(player.verify_integrity("correct-hash")); + assert!(!player.verify_integrity("tampered-hash")); + assert!(!player.verify_integrity("")); + } + + #[test] + fn test_empty_replay_summary() { + let data = ReplayData { + header: ReplayHeader { + version: 1, + puzzle_content_hash: None, + }, + events: vec![], + }; + let player = ReplayPlayer::from_data(data); + let summary = player.summary(); + + assert_eq!(summary.total_time_ms, 0); + assert_eq!(summary.hints_used, 0); + assert_eq!(summary.attempt_count, 0); + assert_eq!(summary.final_score, 0); + } + + #[test] + fn test_event_display() { + let start = ReplayEvent::SessionStart { + puzzle_id: "p1".to_string(), + }; + assert_eq!(start.to_string(), "SESSION_START puzzle=p1"); + + let hint = ReplayEvent::HintReveal { hint_index: 2 }; + assert_eq!(hint.to_string(), "HINT_REVEAL index=2"); + + let attempt = ReplayEvent::AnswerAttempt { + answer: "foo".to_string(), + correct: true, + }; + assert_eq!( + attempt.to_string(), + "ANSWER_ATTEMPT answer=\"foo\" result=CORRECT" + ); + + let end = ReplayEvent::SessionEnd { final_score: 42 }; + assert_eq!(end.to_string(), "SESSION_END score=42"); + } + + #[test] + fn test_save_creates_parent_dirs() { + let dir = tempfile::tempdir().expect("create tempdir"); + let path = dir.path().join("nested").join("deep").join("test.qreplay"); + + let data = ReplayData { + header: ReplayHeader { + version: 1, + puzzle_content_hash: None, + }, + events: vec![], + }; + + save_replay(&data, &path).expect("save with nested dirs"); + assert!(path.exists()); + + let loaded = load_replay(&path).expect("load"); + assert_eq!(data, loaded); + } + + #[test] + fn test_load_invalid_file_fails() { + let dir = tempfile::tempdir().expect("create tempdir"); + let path = dir.path().join("bad.qreplay"); + + // Write invalid data + let mut f = fs::File::create(&path).expect("create"); + f.write_all(b"this is not valid bincode").expect("write"); + + let result = load_replay(&path); + assert!(result.is_err()); + } + + #[test] + fn test_load_nonexistent_file_fails() { + let result = load_replay("/nonexistent/path/replay.qreplay"); + assert!(result.is_err()); + } + + #[test] + fn test_recorder_with_live_timestamps() { + let mut recorder = ReplayRecorder::new(None); + recorder.record(ReplayEvent::SessionStart { + puzzle_id: "live".to_string(), + }); + std::thread::sleep(Duration::from_millis(5)); + recorder.record(ReplayEvent::SessionEnd { final_score: 10 }); + + let data = recorder.finish(); + assert_eq!(data.events.len(), 2); + // Second event should have a larger timestamp than the first + assert!(data.events[1].timestamp_us > data.events[0].timestamp_us); + } + + #[test] + fn test_file_roundtrip_with_all_event_types() { + let dir = tempfile::tempdir().expect("create tempdir"); + let path = dir.path().join("full.qreplay"); + + let mut recorder = ReplayRecorder::new(Some("full-hash".to_string())); + recorder.record_at( + 0, + ReplayEvent::SessionStart { + puzzle_id: "full-test".to_string(), + }, + ); + recorder.record_at(100_000, ReplayEvent::HintReveal { hint_index: 0 }); + recorder.record_at(200_000, ReplayEvent::HintReveal { hint_index: 1 }); + recorder.record_at(300_000, ReplayEvent::HintReveal { hint_index: 2 }); + recorder.record_at( + 400_000, + ReplayEvent::AnswerAttempt { + answer: "wrong1".to_string(), + correct: false, + }, + ); + recorder.record_at( + 500_000, + ReplayEvent::AnswerAttempt { + answer: "wrong2".to_string(), + correct: false, + }, + ); + recorder.record_at( + 600_000, + ReplayEvent::AnswerAttempt { + answer: "correct".to_string(), + correct: true, + }, + ); + recorder.record_at(700_000, ReplayEvent::SessionEnd { final_score: 42 }); + + let original = recorder.finish(); + save_replay(&original, &path).expect("save"); + let loaded = load_replay(&path).expect("load"); + assert_eq!(original, loaded); + + let player = ReplayPlayer::from_data(loaded); + let summary = player.summary(); + assert_eq!(summary.hints_used, 3); + assert_eq!(summary.attempt_count, 3); + assert_eq!(summary.final_score, 42); + assert_eq!(summary.total_time_ms, 700); + } +} 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]