commit 6ef4c40e29db3fa0126333f161193de29d274643 Author: funman300 Date: Thu Jun 25 14:42:16 2026 -0700 Initial commit: OpenFUT Core + Bridge scaffold Two-repo monorepo for an offline FUT backend inspired by SPT. openfut-core: game-independent REST API backend (Axum, SQLite, SQLx) - 19 API endpoints: auth, profiles, clubs, cards, packs, squads, objectives, SBCs, match rewards, NPC market, statistics - JSON-driven card/pack/objective/SBC data (fully moddable) - SQLx migrations, weighted pack generator, SBC validation engine - 5 integration tests passing openfut-bridge: FIFA 23 traffic proxy + reverse-engineering scaffold - Catch-all HTTP proxy with request capture to captures/ - Known-route mapper (FUT paths → Core API calls) - Placeholder responses for unknown endpoints - Admin endpoints: captures, unknown endpoint list - 4 unit tests passing cargo fmt ✓ cargo clippy -D warnings ✓ cargo test ✓ Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55b7a41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Rust build artifacts +target/ +**/target/ + +# SQLite databases +*.db +*.db-shm +*.db-wal + +# Environment files +.env +.env.local + +# Captures (runtime data, not source) +openfut-bridge/captures/ + +# Editor +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..ebc3c7e --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# OpenFUT + +**Offline Ultimate Team — like SPT, but for FIFA 23.** + +OpenFUT replaces EA's retired FUT servers with a fully offline, single-player backend. You own FIFA 23 legitimately. You just want to keep playing after EA shut down the servers. + +--- + +## Repositories + +| Repo | Purpose | +|---|---| +| [`openfut-core`](./openfut-core) | Game-independent offline FUT backend | +| [`openfut-bridge`](./openfut-bridge) | FIFA 23 integration layer + reverse-engineering proxy | + +--- + +## Architecture + +``` +FIFA 23 client + │ + ▼ +┌─────────────────┐ +│ openfut-bridge │ ← intercepts FUT traffic, maps routes, logs unknown +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ openfut-core │ ← offline FUT backend: profiles, packs, squads, SBCs +└─────────────────┘ + │ + ▼ + SQLite database +``` + +**Core** is game-independent. It speaks a clean REST API and knows nothing about FIFA 23 internals. + +**Bridge** is FIFA-specific. It speaks FIFA 23's wire protocol and translates it into Core API calls. Bridge is where all reverse engineering work happens. + +--- + +## Current Status + +| Feature | Status | +|---|---| +| Local profiles + clubs | ✅ Working | +| Coin system | ✅ Working | +| Pack generator | ✅ Working | +| Card collection | ✅ Working | +| Squad builder | ✅ Working | +| Objectives engine | ✅ Working | +| SBC engine | ✅ Working | +| Match rewards | ✅ Working | +| NPC transfer market | ✅ Working | +| Statistics | ✅ Working | +| FIFA 23 integration | 🔴 Reverse engineering in progress | +| Chemistry calculation | 🟡 In progress | +| Full Draft mode | 🟡 In progress | +| Squad Battles AI generator | 🟡 In progress | +| Objectives claim flow | 🟡 In progress | + +--- + +## Running + +```bash +# Start the offline backend +cd openfut-core +cargo run + +# Start the proxy (for traffic capture / FIFA integration) +cd openfut-bridge +cargo run +``` + +Core listens on `http://127.0.0.1:8080` by default. +Bridge listens on `http://127.0.0.1:8443` by default. + +--- + +## Design Principles + +- **Offline-first.** No EA account required. No internet connection needed. +- **Single-player only.** This is not a multiplayer server emulator. +- **Data-driven.** All cards, packs, SBCs, and objectives are JSON files. Everything is moddable. +- **Clean architecture.** Core has zero knowledge of FIFA 23. Bridge has zero game logic. +- **No copyrighted assets.** No real player images, no EA trademarks in data files. + +--- + +## Disclaimer + +This project is not affiliated with EA Sports. You must own FIFA 23 legitimately to use this software. This project does not bypass any ownership verification and is intended only to restore offline playability of a game whose online services have been retired. diff --git a/openfut-bridge/Cargo.lock b/openfut-bridge/Cargo.lock new file mode 100644 index 0000000..67fe8f2 --- /dev/null +++ b/openfut-bridge/Cargo.lock @@ -0,0 +1,1889 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.2", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http 1.4.2", + "http-body 1.0.1", + "hyper 1.10.1", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openfut-bridge" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "bytes", + "chrono", + "dotenvy", + "http 1.4.2", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[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 1.0.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[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 = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/openfut-bridge/Cargo.toml b/openfut-bridge/Cargo.toml new file mode 100644 index 0000000..b156733 --- /dev/null +++ b/openfut-bridge/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "openfut-bridge" +version = "0.1.0" +edition = "2021" +authors = ["OpenFUT Contributors"] +description = "FIFA 23 integration layer and reverse-engineering proxy" +license = "MIT" +repository = "https://github.com/openfut/openfut-bridge" + +[lib] +name = "openfut_bridge" +path = "src/lib.rs" + +[[bin]] +name = "openfut-bridge" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.7", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tower-http = { version = "0.5", features = ["cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +thiserror = "1" +anyhow = "1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +reqwest = { version = "0.11", features = ["json"] } +dotenvy = "0.15" +http = "1" +bytes = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } diff --git a/openfut-bridge/README.md b/openfut-bridge/README.md new file mode 100644 index 0000000..c4ab269 --- /dev/null +++ b/openfut-bridge/README.md @@ -0,0 +1,101 @@ +# OpenFUT Bridge + +**FIFA 23 integration layer and reverse-engineering proxy.** + +OpenFUT Bridge sits between the FIFA 23 client and the internet. It intercepts all FUT API traffic, logs every request, and routes known endpoints to OpenFUT Core. Unknown endpoints receive safe placeholder responses so the client keeps running. + +--- + +## What it does + +- Transparent HTTP proxy — point FIFA 23 here instead of EA's servers +- Logs every request (method, path, headers, body, timestamp) to `captures/` +- Maps known FUT endpoints to OpenFUT Core API calls +- Returns placeholder JSON for unknown endpoints (prevents client crashes) +- Admin API for reviewing captures and identifying unmapped routes +- Foundation for building the full FIFA 23 integration + +--- + +## Architecture + +``` +FIFA 23 client + │ + ▼ +OpenFUT Bridge (port 8443) + │ + ├── Known route → OpenFUT Core (port 8080) + └── Unknown route → Placeholder JSON + saved to captures/ +``` + +--- + +## Quick Start + +```bash +# Run Core first +cd ../openfut-core && cargo run + +# Run Bridge +cargo run + +# Or with config +CORE_URL=http://127.0.0.1:8080 BRIDGE_LISTEN_ADDR=127.0.0.1:8443 cargo run +``` + +### Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `BRIDGE_LISTEN_ADDR` | `127.0.0.1:8443` | Bridge listen address | +| `CORE_URL` | `http://127.0.0.1:8080` | OpenFUT Core URL | +| `CAPTURES_DIR` | `captures` | Where to save captured requests | +| `PLACEHOLDER_MODE` | `true` | Return 200 for unknown endpoints | + +--- + +## Admin API + +| Method | Path | Description | +|---|---|---| +| `GET` | `/_bridge/health` | Bridge status | +| `GET` | `/_bridge/captures` | All captured requests | +| `GET` | `/_bridge/unknown` | Unknown endpoints only | + +--- + +## Reverse Engineering Workflow + +1. Point FIFA 23 at the Bridge (see `docs/setup-notes.md` for proxy setup) +2. Start a game session +3. Check `captures/` or `/_bridge/unknown` for new endpoints +4. Document the endpoint in `docs/endpoint-map.md` +5. Add a mapping in `src/mapper.rs` +6. Implement the Core handler + +--- + +## Proxy Setup (Research Notes) + +See `docs/setup-notes.md` for notes on: +- Windows `hosts` file redirection +- RPCS3 network config (PS3) +- Certificate bypass approaches +- mitmproxy integration + +--- + +## Development + +```bash +cargo fmt +cargo clippy -- -D warnings +cargo test +``` + +--- + +## License + +MIT diff --git a/openfut-bridge/TODO.md b/openfut-bridge/TODO.md new file mode 100644 index 0000000..a8c1785 --- /dev/null +++ b/openfut-bridge/TODO.md @@ -0,0 +1,36 @@ +# OpenFUT Bridge — TODO + +## Reverse Engineering +- [ ] #1 Set up mitmproxy and capture a real FIFA 23 FUT session +- [ ] #2 Document all observed endpoints in `docs/endpoint-map.md` +- [ ] #3 Document FIFA 23 auth flow (token format, headers, session lifecycle) +- [ ] #4 Document request body formats for auth, squad, packs +- [ ] #5 Identify which endpoints are mandatory vs optional for FUT to load +- [ ] #6 Test hosts-file redirect approach on PC +- [ ] #7 Research certificate pinning in FIFA 23 PC build +- [ ] #8 Test RPCS3 network proxy configuration +- [ ] #9 Document response formats EA uses (some differ from request format) +- [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify) + +## Proxy +- [ ] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS +- [ ] #12 Add replay CLI: `openfut-bridge replay captures/some_file.json` +- [ ] #13 Add `DELETE /_bridge/captures` to wipe capture folder +- [ ] #14 Add capture deduplication (same method+path within 1 second) +- [ ] #15 Add request diff tool: show what changed between two captures + +## Mapper +- [ ] #16 Implement actual auth endpoint mapping (static token response) +- [ ] #17 Map squad read endpoint when confirmed +- [ ] #18 Map pack details endpoint when confirmed +- [ ] #19 Add X-UT-SID session header pass-through to Core +- [ ] #20 Add phishing token passthrough + +## Admin UI +- [ ] #21 Build a simple web dashboard for viewing captures +- [ ] #22 Add endpoint status page (known vs unknown vs confirmed) +- [ ] #23 Add live capture stream via SSE + +## Testing +- [ ] #24 Add test for placeholder response format +- [ ] #25 Add integration test that fires real HTTP at the Bridge diff --git a/openfut-bridge/docs/endpoint-map.md b/openfut-bridge/docs/endpoint-map.md new file mode 100644 index 0000000..78b611c --- /dev/null +++ b/openfut-bridge/docs/endpoint-map.md @@ -0,0 +1,64 @@ +# FUT → OpenFUT Core Endpoint Map + +This document maps confirmed or suspected FIFA 23 FUT API endpoints to their OpenFUT Core equivalents. + +## Legend + +- ✅ Confirmed + implemented +- 🟡 Suspected — implemented with placeholder +- ❌ Unknown — not yet mapped + +--- + +## Auth + +| FUT Endpoint | Core Endpoint | Status | Notes | +|---|---|---|---| +| `POST /ut/auth` | `POST /auth/local` | 🟡 | EA OAuth flow → local profile creation | + +## Profile / Club + +| FUT Endpoint | Core Endpoint | Status | Notes | +|---|---|---|---| +| `GET /ut/game/fut/user/settings` | `GET /profile` | 🟡 | | +| `GET /ut/game/fut/usermassinfo` | `GET /club` | 🟡 | EA bulk endpoint | + +## Squad + +| FUT Endpoint | Core Endpoint | Status | Notes | +|---|---|---|---| +| `GET /ut/game/fut/squad/active` | `GET /squad` | 🟡 | | +| `PUT /ut/game/fut/squad/active` | `POST /squad` | ❌ | | + +## Packs + +| FUT Endpoint | Core Endpoint | Status | Notes | +|---|---|---|---| +| `GET /ut/game/fut/store/packdetails` | `GET /packs` | 🟡 | | +| `POST /ut/game/fut/pack/buy` | `POST /packs/open/:id` | ❌ | | + +## Transfer Market + +| FUT Endpoint | Core Endpoint | Status | Notes | +|---|---|---|---| +| `GET /ut/game/fut/transfermarket` | `GET /market` | 🟡 | | +| `DELETE /ut/game/fut/trade/:id` | `POST /market/sell` | ❌ | | + +## Matches / Seasons + +| FUT Endpoint | Core Endpoint | Status | Notes | +|---|---|---|---| +| Unknown | `POST /matches/result` | ❌ | Needs capture | + +## Objectives + +| FUT Endpoint | Core Endpoint | Status | Notes | +|---|---|---|---| +| Unknown | `GET /objectives` | ❌ | Needs capture | + +## SBCs + +| FUT Endpoint | Core Endpoint | Status | Notes | +|---|---|---|---| +| Unknown | `GET /sbc` | ❌ | Needs capture | +| Unknown | `POST /sbc/submit` | ❌ | Needs capture | diff --git a/openfut-bridge/docs/reverse-engineering.md b/openfut-bridge/docs/reverse-engineering.md new file mode 100644 index 0000000..c1346da --- /dev/null +++ b/openfut-bridge/docs/reverse-engineering.md @@ -0,0 +1,85 @@ +# Reverse Engineering Notes — FIFA 23 FUT API + +This document tracks what is known and unknown about EA's FUT API as used by FIFA 23. + +--- + +## Status + +🔴 Very early — almost nothing confirmed. All mappings in `src/mapper.rs` are speculative. + +--- + +## Known / Suspected Endpoints + +These are guesses based on: +- Common FUT API patterns from public research +- Observations from older FIFA titles +- Community reverse-engineering work + +| Method | Path | Purpose | Status | +|---|---|---|---| +| `POST` | `/ut/auth` | Authentication / session | Suspected | +| `GET` | `/ut/game/fut/user/settings` | User settings | Suspected | +| `GET` | `/ut/game/fut/usermassinfo` | Club + profile bulk | Suspected | +| `GET` | `/ut/game/fut/squad/active` | Active squad | Suspected | +| `GET` | `/ut/game/fut/store/packdetails` | Pack store | Suspected | +| `GET` | `/ut/game/fut/transfermarket` | Transfer market | Suspected | + +--- + +## Unknown Endpoints + +Run `GET /_bridge/unknown` after a game session to see what new routes appeared. +Each entry represents a real FIFA 23 request that hasn't been mapped yet. + +--- + +## Request Format Notes + +### Auth + +EA FUT auth appears to use a multi-step token flow: +1. EA account auth (OAuth2-style) +2. FUT-specific auth with a "nucleus ID" +3. Session token issued + +For offline purposes, OpenFUT Bridge returns a static token that satisfies the client. + +### Headers + +Common headers seen in FUT traffic: +- `X-UT-SID` — session token +- `X-UT-PHISHING-TOKEN` — anti-CSRF token +- `Content-Type: application/json` +- `X-HTTP-Method-Override` — EA sometimes uses POST + this header instead of DELETE/PUT + +--- + +## Tools + +- [mitmproxy](https://mitmproxy.org/) — HTTPS interception +- [Fiddler](https://www.telerik.com/fiddler) — Windows-friendly proxy +- Wireshark — low-level packet capture +- OpenFUT Bridge `captures/` folder — automatic request logging + +--- + +## Resources + +- Previous FIFA FUT API research: search GitHub for "fifa-ut-api", "easfc", "futapi" +- ea.com documentation: none public +- Community wikis: FUT Trading community resources + +--- + +## TODO + +- [ ] Capture a real FIFA 23 session via mitmproxy +- [ ] Document the auth flow completely +- [ ] Map the squad endpoints +- [ ] Map the pack opening endpoints +- [ ] Map the objectives endpoints +- [ ] Map the SBC endpoints +- [ ] Map the transfer market endpoints +- [ ] Identify which endpoints are critical vs optional diff --git a/openfut-bridge/docs/setup-notes.md b/openfut-bridge/docs/setup-notes.md new file mode 100644 index 0000000..0e4c3c3 --- /dev/null +++ b/openfut-bridge/docs/setup-notes.md @@ -0,0 +1,92 @@ +# Proxy Setup Notes + +Research notes for routing FIFA 23 traffic through OpenFUT Bridge. + +--- + +## Goal + +Intercept all FUT API calls from FIFA 23 so the Bridge can: +1. Log every request for reverse engineering +2. Route known endpoints to OpenFUT Core +3. Return placeholder responses for the rest + +--- + +## PC (FIFA 23 via EA App / Steam) + +### Option A: Windows hosts file + +Redirect `utas.fut.ea.com` and related domains to localhost. + +``` +# C:\Windows\System32\drivers\etc\hosts +127.0.0.1 utas.fut.ea.com +127.0.0.1 utas2.fut.ea.com +127.0.0.1 ea.com +``` + +The Bridge must listen on port 443 (HTTPS) or 80 (HTTP). +FIFA 23 expects HTTPS — you'll need a self-signed cert and need to trust it. + +### Option B: mitmproxy + +1. Install mitmproxy +2. Set Windows system proxy to 127.0.0.1:8080 +3. Configure mitmproxy to forward FUT traffic to Bridge + +```bash +mitmproxy --mode transparent \ + --listen-host 127.0.0.1 \ + --listen-port 8080 +``` + +⚠️ FIFA 23 may use certificate pinning — this is unconfirmed. If pinning is active, the hosts file approach or a traffic-level redirect may be needed instead. + +--- + +## RPCS3 (PS3 emulator) + +RPCS3 has built-in network settings: + +1. Open RPCS3 → Configuration → Network +2. Set DNS to point to your OpenFUT Bridge IP +3. Configure `PSN Status` to `RPCN` or custom +4. The Bridge intercepts DNS and HTTP/HTTPS traffic + +Research needed: RPCS3 FIFA 23 title ID and specific network behavior. + +--- + +## Certificate Handling + +EA's FUT API uses HTTPS. Options: + +1. **Self-signed cert** — generate with `openssl` and add to system trust store +2. **HTTP downgrade** — if the client accepts HTTP (unlikely for production) +3. **Traffic-level redirect** — use iptables/nftables to redirect port 443 traffic +4. **No cert** — if FIFA 23 PC doesn't verify certs (to be tested) + +### Generating a self-signed cert + +```bash +openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes \ + -subj "/CN=utas.fut.ea.com" +``` + +--- + +## Status + +🔴 Not tested yet. All of the above is research / theoretical. +The first step is to capture real FIFA 23 traffic with mitmproxy and document actual endpoints. + +--- + +## Next Steps + +1. Set up mitmproxy on a FIFA 23 PC +2. Start FIFA 23 and navigate to FUT mode +3. Export the mitmproxy capture +4. Add real endpoints to `docs/endpoint-map.md` +5. Add mappings to `src/mapper.rs` diff --git a/openfut-bridge/src/capture.rs b/openfut-bridge/src/capture.rs new file mode 100644 index 0000000..9d43e8d --- /dev/null +++ b/openfut-bridge/src/capture.rs @@ -0,0 +1,91 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use uuid::Uuid; + +/// A captured HTTP request from the FIFA 23 client. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapturedRequest { + pub id: String, + pub timestamp: String, + pub method: String, + pub path: String, + pub query: Option, + pub headers: Vec<(String, String)>, + pub body: Option, + pub response_status: Option, + pub response_body: Option, + pub mapped_to_core: Option, +} + +impl CapturedRequest { + pub fn new( + method: &str, + path: &str, + query: Option<&str>, + headers: Vec<(String, String)>, + body: Option, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + timestamp: Utc::now().to_rfc3339(), + method: method.to_string(), + path: path.to_string(), + query: query.map(String::from), + headers, + body, + response_status: None, + response_body: None, + mapped_to_core: None, + } + } + + pub fn with_response(mut self, status: u16, body: Option) -> Self { + self.response_status = Some(status); + self.response_body = body; + self + } +} + +/// Persist a capture to disk as JSON. +pub fn save_capture(captures_dir: &str, capture: &CapturedRequest) -> anyhow::Result<()> { + let dir = Path::new(captures_dir); + std::fs::create_dir_all(dir)?; + + let filename = format!( + "{}_{}_{}.json", + capture.timestamp.replace(':', "-"), + capture.method, + capture.id + ); + let path = dir.join(filename); + + let json = serde_json::to_string_pretty(capture)?; + std::fs::write(&path, json)?; + + tracing::debug!("Capture saved: {:?}", path); + Ok(()) +} + +/// Load all captures from the captures directory. +pub fn load_all_captures(captures_dir: &str) -> anyhow::Result> { + let dir = Path::new(captures_dir); + if !dir.exists() { + return Ok(vec![]); + } + + let mut captures = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + let content = std::fs::read_to_string(&path)?; + if let Ok(capture) = serde_json::from_str::(&content) { + captures.push(capture); + } + } + } + + captures.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + Ok(captures) +} diff --git a/openfut-bridge/src/config.rs b/openfut-bridge/src/config.rs new file mode 100644 index 0000000..ef841aa --- /dev/null +++ b/openfut-bridge/src/config.rs @@ -0,0 +1,27 @@ +use anyhow::Result; + +#[derive(Debug, Clone)] +pub struct Config { + /// Address the Bridge proxy listens on (FIFA 23 should point here) + pub listen_addr: String, + /// OpenFUT Core base URL + pub core_url: String, + /// Where to persist captures + pub captures_dir: String, + /// If true, return placeholder 200 responses for unknown routes + pub placeholder_mode: bool, +} + +impl Config { + pub fn from_env() -> Result { + Ok(Self { + listen_addr: std::env::var("BRIDGE_LISTEN_ADDR") + .unwrap_or_else(|_| "127.0.0.1:8443".into()), + core_url: std::env::var("CORE_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into()), + captures_dir: std::env::var("CAPTURES_DIR").unwrap_or_else(|_| "captures".into()), + placeholder_mode: std::env::var("PLACEHOLDER_MODE") + .map(|v| v == "true" || v == "1") + .unwrap_or(true), + }) + } +} diff --git a/openfut-bridge/src/error.rs b/openfut-bridge/src/error.rs new file mode 100644 index 0000000..4b258d0 --- /dev/null +++ b/openfut-bridge/src/error.rs @@ -0,0 +1,40 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum BridgeError { + #[allow(dead_code)] + #[error("upstream error: {0}")] + Upstream(String), + + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("internal error: {0}")] + Internal(#[from] anyhow::Error), +} + +impl IntoResponse for BridgeError { + fn into_response(self) -> Response { + let (status, msg) = match &self { + BridgeError::Upstream(e) => (StatusCode::BAD_GATEWAY, e.clone()), + BridgeError::Serialization(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + BridgeError::Io(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + BridgeError::Internal(e) => { + tracing::error!("Bridge internal error: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()) + } + }; + (status, Json(json!({ "error": msg }))).into_response() + } +} + +pub type BridgeResult = Result; diff --git a/openfut-bridge/src/lib.rs b/openfut-bridge/src/lib.rs new file mode 100644 index 0000000..042f4c6 --- /dev/null +++ b/openfut-bridge/src/lib.rs @@ -0,0 +1,6 @@ +pub mod capture; +pub mod config; +pub mod error; +pub mod mapper; +pub mod proxy; +pub mod routes; diff --git a/openfut-bridge/src/main.rs b/openfut-bridge/src/main.rs new file mode 100644 index 0000000..1255e14 --- /dev/null +++ b/openfut-bridge/src/main.rs @@ -0,0 +1,52 @@ +use anyhow::Result; +use axum::{ + routing::{any, get}, + Router, +}; +use openfut_bridge::{config::Config, proxy::ProxyState, routes}; +use tower_http::{cors::CorsLayer, trace::TraceLayer}; +use tracing::info; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +#[tokio::main] +async fn main() -> Result<()> { + dotenvy::dotenv().ok(); + + tracing_subscriber::registry() + .with( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "openfut_bridge=debug,tower_http=info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let cfg = Config::from_env()?; + let listen_addr = cfg.listen_addr.clone(); + + info!("OpenFUT Bridge starting on {listen_addr}"); + info!("Core URL: {}", cfg.core_url); + info!("Placeholder mode: {}", cfg.placeholder_mode); + info!("Captures dir: {}", cfg.captures_dir); + + std::fs::create_dir_all(&cfg.captures_dir)?; + + let state = ProxyState::new(cfg); + + let app = Router::new() + .route("/_bridge/health", get(routes::health::get_health)) + .route("/_bridge/captures", get(routes::admin::get_captures)) + .route( + "/_bridge/unknown", + get(routes::admin::get_unknown_endpoints), + ) + .fallback(any(openfut_bridge::proxy::catch_all_handler)) + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::permissive()) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(&listen_addr).await?; + info!("Bridge listening on http://{listen_addr}"); + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/openfut-bridge/src/mapper.rs b/openfut-bridge/src/mapper.rs new file mode 100644 index 0000000..55fb93b --- /dev/null +++ b/openfut-bridge/src/mapper.rs @@ -0,0 +1,105 @@ +/// Mapping layer: translates known FIFA 23 API paths to OpenFUT Core endpoints. +/// Unknown paths return None and will be recorded for reverse engineering. + +#[derive(Debug, Clone)] +pub struct CoreMapping { + pub method: &'static str, + pub core_path: &'static str, + #[allow(dead_code)] + pub notes: &'static str, +} + +/// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call. +/// Returns Some(mapping) if the route is known, None otherwise. +pub fn map_to_core(method: &str, path: &str) -> Option { + let m = method.to_uppercase(); + let p = path.trim_end_matches('/'); + + // These are speculative mappings based on common FUT API patterns. + // They will be refined as reverse engineering progresses. + let known: &[(&str, &str, CoreMapping)] = &[ + // Auth + ( + "POST", + "/ut/auth", + CoreMapping { + method: "POST", + core_path: "/auth/local", + notes: "FUT auth → Core local auth", + }, + ), + ( + "GET", + "/ut/game/fut/user/settings", + CoreMapping { + method: "GET", + core_path: "/profile", + notes: "FUT settings → Core profile", + }, + ), + // Club + ( + "GET", + "/ut/game/fut/usermassinfo", + CoreMapping { + method: "GET", + core_path: "/club", + notes: "FUT mass info → Core club", + }, + ), + // Squad + ( + "GET", + "/ut/game/fut/squad/active", + CoreMapping { + method: "GET", + core_path: "/squad", + notes: "FUT active squad → Core squad", + }, + ), + // Packs + ( + "GET", + "/ut/game/fut/store/packdetails", + CoreMapping { + method: "GET", + core_path: "/packs", + notes: "FUT pack store → Core pack list", + }, + ), + // Transfer market (guessed) + ( + "GET", + "/ut/game/fut/transfermarket", + CoreMapping { + method: "GET", + core_path: "/market", + notes: "FUT transfer market → Core NPC market", + }, + ), + ]; + + for (km, kp, mapping) in known { + if *km == m && *kp == p { + return Some(mapping.clone()); + } + } + + None +} + +/// Return a safe placeholder response for unknown endpoints. +/// This prevents the FIFA 23 client from crashing while we log traffic. +pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value { + tracing::warn!( + "UNKNOWN ENDPOINT: {} {} — returning placeholder", + method, + path + ); + serde_json::json!({ + "status": "ok", + "openfut_note": "This endpoint has not been mapped yet. Check captures/ for details.", + "method": method, + "path": path, + }) +} diff --git a/openfut-bridge/src/proxy.rs b/openfut-bridge/src/proxy.rs new file mode 100644 index 0000000..589f9c8 --- /dev/null +++ b/openfut-bridge/src/proxy.rs @@ -0,0 +1,154 @@ +use axum::{ + body::Body, + extract::{Request, State}, + http::StatusCode, + response::Response, +}; +use bytes::Bytes; +use serde_json::Value; +use std::sync::Arc; + +use crate::{ + capture::{save_capture, CapturedRequest}, + config::Config, + error::BridgeResult, + mapper::{map_to_core, placeholder_response}, +}; + +#[derive(Clone)] +pub struct ProxyState { + pub config: Arc, + pub http_client: reqwest::Client, +} + +impl ProxyState { + pub fn new(config: Config) -> Self { + Self { + config: Arc::new(config), + http_client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("failed to build HTTP client"), + } + } +} + +pub async fn catch_all_handler( + State(state): State, + req: Request, +) -> BridgeResult> { + let method = req.method().to_string(); + let uri = req.uri().clone(); + let path = uri.path().to_string(); + let query = uri.query().map(String::from); + + let headers: Vec<(String, String)> = req + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + + // Extract body via axum's built-in mechanism + let (_parts, body) = req.into_parts(); + let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024) + .await + .unwrap_or_default(); + + let body_str = if body_bytes.is_empty() { + None + } else { + Some(String::from_utf8_lossy(&body_bytes).to_string()) + }; + + tracing::info!( + "→ {} {}{}", + method, + path, + query + .as_deref() + .map(|q| format!("?{q}")) + .unwrap_or_default() + ); + + let mut capture = + CapturedRequest::new(&method, &path, query.as_deref(), headers, body_str.clone()); + + let (response_body, status_code): (Value, u16) = + if let Some(mapping) = map_to_core(&method, &path) { + tracing::info!( + " ↳ Mapped to Core: {} {}", + mapping.method, + mapping.core_path + ); + capture.mapped_to_core = Some(mapping.core_path.to_string()); + + match forward_to_core( + &state, + mapping.method, + mapping.core_path, + body_str.as_deref(), + ) + .await + { + Ok((body, status)) => (body, status), + Err(e) => { + tracing::error!("Core request failed: {e}"); + (serde_json::json!({ "error": e.to_string() }), 502) + } + } + } else if state.config.placeholder_mode { + (placeholder_response(&method, &path), 200) + } else { + (serde_json::json!({ "error": "endpoint not mapped" }), 404) + }; + + capture = capture.with_response(status_code, Some(response_body.to_string())); + + let captures_dir = state.config.captures_dir.clone(); + let capture_clone = capture.clone(); + tokio::spawn(async move { + if let Err(e) = save_capture(&captures_dir, &capture_clone) { + tracing::warn!("Failed to save capture: {e}"); + } + }); + + let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK); + let json_bytes = + serde_json::to_vec(&response_body).map_err(crate::error::BridgeError::Serialization)?; + + let response = Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(json_bytes)) + .map_err(|e| anyhow::anyhow!("response build error: {e}"))?; + + Ok(response) +} + +async fn forward_to_core( + state: &ProxyState, + method: &str, + path: &str, + body: Option<&str>, +) -> anyhow::Result<(Value, u16)> { + let url = format!("{}{}", state.config.core_url, path); + let builder = match method { + "POST" => state.http_client.post(&url), + "PUT" => state.http_client.put(&url), + "DELETE" => state.http_client.delete(&url), + _ => state.http_client.get(&url), + }; + + let builder = if let Some(b) = body { + builder + .header("content-type", "application/json") + .body(b.to_string()) + } else { + builder + }; + + let resp = builder.send().await?; + let status = resp.status().as_u16(); + let body: Value = resp.json().await.unwrap_or(Value::Null); + Ok((body, status)) +} diff --git a/openfut-bridge/src/routes/admin.rs b/openfut-bridge/src/routes/admin.rs new file mode 100644 index 0000000..c7bffce --- /dev/null +++ b/openfut-bridge/src/routes/admin.rs @@ -0,0 +1,51 @@ +use axum::{extract::State, Json}; +use serde_json::{json, Value}; + +use crate::{capture::load_all_captures, error::BridgeResult, proxy::ProxyState}; + +/// List all captured requests. +pub async fn get_captures(State(state): State) -> BridgeResult> { + let captures = load_all_captures(&state.config.captures_dir) + .map_err(crate::error::BridgeError::Internal)?; + + let unknown: Vec<_> = captures + .iter() + .filter(|c| c.mapped_to_core.is_none()) + .collect(); + + Ok(Json(json!({ + "total": captures.len(), + "unknown_endpoints": unknown.len(), + "captures": captures, + }))) +} + +/// List only unknown (unmapped) endpoints. +pub async fn get_unknown_endpoints(State(state): State) -> BridgeResult> { + let captures = load_all_captures(&state.config.captures_dir) + .map_err(crate::error::BridgeError::Internal)?; + + let mut seen = std::collections::HashSet::new(); + let unknown: Vec = captures + .iter() + .filter(|c| c.mapped_to_core.is_none()) + .filter_map(|c| { + let key = format!("{} {}", c.method, c.path); + if seen.insert(key) { + Some(json!({ + "method": c.method, + "path": c.path, + "first_seen": c.timestamp, + "capture_id": c.id, + })) + } else { + None + } + }) + .collect(); + + Ok(Json(json!({ + "unknown_endpoint_count": unknown.len(), + "endpoints": unknown, + }))) +} diff --git a/openfut-bridge/src/routes/health.rs b/openfut-bridge/src/routes/health.rs new file mode 100644 index 0000000..6e83515 --- /dev/null +++ b/openfut-bridge/src/routes/health.rs @@ -0,0 +1,14 @@ +use axum::{http::StatusCode, Json}; +use serde_json::{json, Value}; + +pub async fn get_health() -> (StatusCode, Json) { + ( + StatusCode::OK, + Json(json!({ + "status": "ok", + "service": "openfut-bridge", + "version": env!("CARGO_PKG_VERSION"), + "note": "This proxy captures FIFA 23 traffic and routes known endpoints to OpenFUT Core." + })), + ) +} diff --git a/openfut-bridge/src/routes/mod.rs b/openfut-bridge/src/routes/mod.rs new file mode 100644 index 0000000..0e59b8a --- /dev/null +++ b/openfut-bridge/src/routes/mod.rs @@ -0,0 +1,2 @@ +pub mod admin; +pub mod health; diff --git a/openfut-bridge/tests/proxy_test.rs b/openfut-bridge/tests/proxy_test.rs new file mode 100644 index 0000000..6b7ec35 --- /dev/null +++ b/openfut-bridge/tests/proxy_test.rs @@ -0,0 +1,48 @@ +use openfut_bridge::{capture::CapturedRequest, mapper::map_to_core}; + +#[test] +fn test_known_endpoint_maps_to_core() { + let mapping = map_to_core("POST", "/ut/auth"); + assert!(mapping.is_some()); + let m = mapping.unwrap(); + assert_eq!(m.core_path, "/auth/local"); +} + +#[test] +fn test_unknown_endpoint_returns_none() { + let mapping = map_to_core("GET", "/ut/game/fut/some/unknown/path"); + assert!(mapping.is_none()); +} + +#[test] +fn test_capture_serializes_cleanly() { + let capture = CapturedRequest::new( + "GET", + "/ut/game/fut/user/settings", + None, + vec![("user-agent".into(), "FIFA23/1.0".into())], + None, + ); + + let json = serde_json::to_string(&capture).expect("serialize"); + let back: CapturedRequest = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.path, "/ut/game/fut/user/settings"); + assert_eq!(back.method, "GET"); + assert!(back.mapped_to_core.is_none()); + assert!(back.response_status.is_none()); +} + +#[test] +fn test_capture_with_response() { + let capture = CapturedRequest::new( + "POST", + "/ut/auth", + None, + vec![], + Some(r#"{"token":"abc"}"#.into()), + ) + .with_response(200, Some(r#"{"status":"ok"}"#.into())); + + assert_eq!(capture.response_status, Some(200)); + assert!(capture.response_body.is_some()); +} diff --git a/openfut-core/Cargo.lock b/openfut-core/Cargo.lock new file mode 100644 index 0000000..861a96c --- /dev/null +++ b/openfut-core/Cargo.lock @@ -0,0 +1,2753 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http 1.4.2", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.2", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "axum-test" +version = "14.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167294800740b4b6bc7bfbccbf3a1d50a6c6e097342580ec4c11d1672e456292" +dependencies = [ + "anyhow", + "async-trait", + "auto-future", + "axum", + "bytes", + "cookie", + "http 1.4.2", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower 0.4.13", + "url", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.2", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.4.2", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openfut-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "axum-macros", + "axum-test", + "chrono", + "dotenvy", + "rand", + "serde", + "serde_json", + "sqlx", + "thiserror 1.0.69", + "tokio", + "tower 0.5.3", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reserve-port" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ea98a177596a4579881992bd2bd4af27772fc95d0e5f5668a8f9535eca6380" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "mime", + "mime_guess", + "rand", + "thiserror 1.0.69", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +dependencies = [ + "ahash", + "atoi", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[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", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "http 1.4.2", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[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 = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/openfut-core/Cargo.toml b/openfut-core/Cargo.toml new file mode 100644 index 0000000..cfb589f --- /dev/null +++ b/openfut-core/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "openfut-core" +version = "0.1.0" +edition = "2021" +authors = ["OpenFUT Contributors"] +description = "Offline Ultimate Team backend — game-independent core" +license = "MIT" +repository = "https://github.com/openfut/openfut-core" + +[lib] +name = "openfut_core" +path = "src/lib.rs" + +[[bin]] +name = "openfut-core" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.7", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "migrate", "chrono", "uuid"] } +tower-http = { version = "0.5", features = ["cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +thiserror = "1" +anyhow = "1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +rand = "0.8" +dotenvy = "0.15" +axum-macros = "0.4" + +[dev-dependencies] +axum-test = "14" +tokio = { version = "1", features = ["full"] } +tower = { version = "0.5", features = ["util"] } diff --git a/openfut-core/README.md b/openfut-core/README.md new file mode 100644 index 0000000..7904754 --- /dev/null +++ b/openfut-core/README.md @@ -0,0 +1,135 @@ +# OpenFUT Core + +**Offline Ultimate Team backend — game-independent.** + +OpenFUT Core is the heart of the OpenFUT project: a fully offline, single-player FUT-style backend written in Rust. It is deliberately decoupled from any specific game, though it is designed to power a FIFA 23 offline experience. + +--- + +## What it does + +- Creates and manages local player profiles and clubs +- Manages coins, XP, and progression +- Generates packs from weighted JSON definitions +- Tracks your full card collection +- Squad builder with formations and chemistry (chemistry calculations: WIP) +- Objectives engine (daily, weekly, lifetime, milestone) +- SBC (Squad Building Challenge) engine with JSON-defined challenges +- Match result processing with coin and XP rewards +- NPC transfer market with daily refreshes +- Statistics tracking +- Fully moddable via JSON data files + +--- + +## Tech Stack + +- **Rust** + **Axum** (HTTP framework) +- **Tokio** (async runtime) +- **SQLite** + **SQLx** (database + migrations) +- **Serde** (JSON data layer) +- **tower-http** (middleware: CORS, tracing) + +--- + +## Quick Start + +```bash +# Build +cargo build --release + +# Run (creates openfut.db in current directory) +./target/release/openfut-core + +# Or with custom config +DATABASE_URL=sqlite://./myclub.db LISTEN_ADDR=127.0.0.1:8080 ./target/release/openfut-core +``` + +### Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `LISTEN_ADDR` | `127.0.0.1:8080` | Address to listen on | +| `DATABASE_URL` | `sqlite://openfut.db` | SQLite database path | +| `DATA_DIR` | `data` | Path to JSON data files | + +--- + +## API Routes + +| Method | Path | Description | +|---|---|---| +| `GET` | `/health` | Health check | +| `POST` | `/auth/local` | Create first-run profile + club | +| `GET` | `/profile` | Get active profile | +| `GET` | `/club` | Get active club with coins | +| `GET` | `/cards` | Browse all card definitions | +| `GET` | `/collection` | Get owned cards | +| `GET` | `/packs` | List unopened packs | +| `POST` | `/packs/open/:pack_id` | Open a pack | +| `GET` | `/squad` | Get active squad | +| `POST` | `/squad` | Save squad | +| `GET` | `/objectives` | List objectives with progress | +| `POST` | `/matches/result` | Submit match result + receive rewards | +| `GET` | `/sbc` | List SBC definitions | +| `POST` | `/sbc/submit` | Submit SBC solution | +| `GET` | `/market` | Browse NPC transfer market | +| `POST` | `/market/buy` | Buy listing | +| `POST` | `/market/sell` | Quick-sell card | +| `POST` | `/market/refresh` | Refresh NPC listings | +| `GET` | `/statistics` | Get match/pack/SBC stats | + +--- + +## First Run + +```bash +# Create your profile +curl -X POST http://localhost:8080/auth/local \ + -H 'Content-Type: application/json' \ + -d '{"username": "Player 1"}' + +# Check your club (5000 coins + a gold pack waiting) +curl http://localhost:8080/club + +# Open your starter pack +curl -X POST http://localhost:8080/packs/open/ + +# Submit a match win +curl -X POST http://localhost:8080/matches/result \ + -H 'Content-Type: application/json' \ + -d '{"squad_id":"any","opponent_name":"Beginner AI","goals_for":3,"goals_against":0,"mode":"squad_battles"}' +``` + +--- + +## Modding + +All game content lives in `data/`. Drop JSON files into the appropriate folder and restart. + +``` +data/ + cards/ ← CardDefinition[] + packs/ ← PackDefinition[] + objectives/ ← ObjectiveDefinition[] + sbcs/ ← SbcDefinition[] + events/ ← (future) +``` + +See `docs/modding.md` for schema reference. + +--- + +## Development + +```bash +cargo fmt +cargo clippy -- -D warnings +cargo test +``` + +--- + +## License + +MIT — see LICENSE diff --git a/openfut-core/TODO.md b/openfut-core/TODO.md new file mode 100644 index 0000000..cdae56f --- /dev/null +++ b/openfut-core/TODO.md @@ -0,0 +1,82 @@ +# OpenFUT Core — TODO + +Small, independently completable tasks. + +## Foundation +- [ ] #1 Add `.env.example` with all supported env vars documented +- [ ] #2 Add `CONTRIBUTING.md` with dev setup instructions +- [ ] #3 Write modding guide (`docs/modding.md`) with full JSON schemas +- [ ] #4 Add database schema diagram to `docs/` +- [ ] #5 Add GitHub Actions CI workflow (fmt + clippy + test) + +## Cards +- [ ] #6 Add more bronze card JSON entries (target: 30+ cards) +- [ ] #7 Add silver card JSON entries (target: 20+ cards) +- [ ] #8 Add rare gold card JSON entries (target: 10+ cards) +- [ ] #9 Add TOTW placeholder cards +- [ ] #10 Add Hero placeholder cards +- [ ] #11 Implement card image_path support (placeholder PNG serving) +- [ ] #12 Add `GET /cards/:card_id` endpoint for single card lookup + +## Packs +- [ ] #13 Add TOTW pack definition +- [ ] #14 Add Icon pack definition +- [ ] #15 Implement `POST /packs/buy` (purchase a pack with coins) +- [ ] #16 Add pack opening animation hints to the response + +## Squad Builder +- [ ] #17 Implement chemistry calculation (same club/league/nation bonuses) +- [ ] #18 Add formation validation (11 players, 1 GK, etc.) +- [ ] #19 Add `GET /formations` endpoint listing available formations +- [ ] #20 Add multiple squad support (save/load named squads) + +## Objectives +- [ ] #21 Add weekly objective JSON data +- [ ] #22 Add milestone objective JSON data +- [ ] #23 Implement `POST /objectives/claim` to claim completed reward +- [ ] #24 Add season-level objective tracking +- [ ] #25 Reset daily objectives at midnight + +## SBCs +- [ ] #26 Add 5 more SBC definitions in JSON +- [ ] #27 Add club requirement validation to SBC engine +- [ ] #28 Add max_overall requirement validation +- [ ] #29 Add chemistry requirement validation +- [ ] #30 Add `GET /sbc/:id` endpoint for single SBC + +## Market +- [ ] #31 Schedule automatic daily NPC market refresh (via tokio background task) +- [ ] #32 Add market listing expiry cleanup job +- [ ] #33 Add `GET /market?min_overall=X&position=Y` filtering +- [ ] #34 Add sell price floor/ceiling validation +- [ ] #35 Add market transaction history endpoint + +## Matches +- [ ] #36 Add `GET /matches` history endpoint +- [ ] #37 Implement Squad Battles opponent generator (random AI clubs) +- [ ] #38 Add Draft mode: generate random squad + play matches +- [ ] #39 Add seasonal rank tracking for Squad Battles +- [ ] #40 Add `mode` enum: squad_battles, seasons, draft, friendly + +## Statistics +- [ ] #41 Add per-position goal stats +- [ ] #42 Add win streak tracking +- [ ] #43 Add `GET /statistics/history` (last N matches) + +## Settings +- [ ] #44 Add `GET /settings` and `PUT /settings` endpoints +- [ ] #45 Store difficulty preference in settings +- [ ] #46 Store preferred formation in settings + +## Events +- [ ] #47 Design JSON schema for limited-time events +- [ ] #48 Implement event activation/deactivation +- [ ] #49 Add TOTW event that enables special pack + +## Hardening +- [ ] #50 Add request body size limits +- [ ] #51 Add rate limiting middleware (prevent accidental loops) +- [ ] #52 Add proper logging correlation IDs +- [ ] #53 Write additional integration tests for SBC engine +- [ ] #54 Write integration test for full pack open flow +- [ ] #55 Add SQLite WAL mode for better concurrency diff --git a/openfut-core/data/cards/bronze_sample.json b/openfut-core/data/cards/bronze_sample.json new file mode 100644 index 0000000..c61920c --- /dev/null +++ b/openfut-core/data/cards/bronze_sample.json @@ -0,0 +1,189 @@ +[ + { + "id": "card_bronze_001", + "name": "Lucas Santos", + "overall": 62, + "position": "ST", + "nation": "Brazil", + "league": "Serie B", + "club": "Santos FC", + "pace": 72, + "shooting": 61, + "passing": 52, + "dribbling": 63, + "defending": 30, + "physical": 64, + "rarity": "bronze", + "image_path": null + }, + { + "id": "card_bronze_002", + "name": "Marco Ferri", + "overall": 60, + "position": "CM", + "nation": "Italy", + "league": "Serie C", + "club": "Modena FC", + "pace": 58, + "shooting": 52, + "passing": 63, + "dribbling": 59, + "defending": 55, + "physical": 60, + "rarity": "bronze", + "image_path": null + }, + { + "id": "card_bronze_003", + "name": "Eko Jabari", + "overall": 61, + "position": "CB", + "nation": "Nigeria", + "league": "NPFL", + "club": "Kano Pillars", + "pace": 60, + "shooting": 25, + "passing": 48, + "dribbling": 42, + "defending": 64, + "physical": 70, + "rarity": "bronze", + "image_path": null + }, + { + "id": "card_bronze_004", + "name": "Pieter Van Dijk", + "overall": 63, + "position": "LB", + "nation": "Netherlands", + "league": "Eerste Divisie", + "club": "FC Volendam", + "pace": 65, + "shooting": 38, + "passing": 60, + "dribbling": 61, + "defending": 65, + "physical": 62, + "rarity": "bronze", + "image_path": null + }, + { + "id": "card_bronze_005", + "name": "Tomás Ruiz", + "overall": 62, + "position": "GK", + "nation": "Spain", + "league": "Segunda B", + "club": "SD Compostela", + "pace": 40, + "shooting": 10, + "passing": 35, + "dribbling": 20, + "defending": 62, + "physical": 58, + "rarity": "bronze", + "image_path": null + }, + { + "id": "card_bronze_006", + "name": "James Okafor", + "overall": 60, + "position": "RM", + "nation": "Ghana", + "league": "Ghana Premier League", + "club": "Asante Kotoko", + "pace": 75, + "shooting": 55, + "passing": 58, + "dribbling": 66, + "defending": 32, + "physical": 55, + "rarity": "bronze", + "image_path": null + }, + { + "id": "card_bronze_007", + "name": "Stefan Kovac", + "overall": 61, + "position": "CDM", + "nation": "Serbia", + "league": "Super liga Srbije", + "club": "FK Partizan", + "pace": 55, + "shooting": 50, + "passing": 60, + "dribbling": 56, + "defending": 63, + "physical": 68, + "rarity": "bronze", + "image_path": null + }, + { + "id": "card_bronze_008", + "name": "Hiroshi Tanaka", + "overall": 63, + "position": "CAM", + "nation": "Japan", + "league": "J2 League", + "club": "Gamba Osaka", + "pace": 68, + "shooting": 60, + "passing": 66, + "dribbling": 70, + "defending": 38, + "physical": 52, + "rarity": "bronze", + "image_path": null + }, + { + "id": "card_bronze_009", + "name": "Aleksei Morozov", + "overall": 60, + "position": "RB", + "nation": "Russia", + "league": "FNL", + "club": "Torpedo Moscow", + "pace": 62, + "shooting": 40, + "passing": 56, + "dribbling": 58, + "defending": 62, + "physical": 60, + "rarity": "bronze", + "image_path": null + }, + { + "id": "card_bronze_010", + "name": "Kwame Asante", + "overall": 62, + "position": "LM", + "nation": "Ghana", + "league": "Ghana Premier League", + "club": "Hearts of Oak", + "pace": 73, + "shooting": 58, + "passing": 60, + "dribbling": 65, + "defending": 35, + "physical": 58, + "rarity": "bronze", + "image_path": null + }, + { + "id": "card_bronze_011", + "name": "Ivan Petrov", + "overall": 61, + "position": "CB", + "nation": "Bulgaria", + "league": "First League", + "club": "CSKA Sofia", + "pace": 58, + "shooting": 22, + "passing": 50, + "dribbling": 44, + "defending": 63, + "physical": 67, + "rarity": "bronze", + "image_path": null + } +] diff --git a/openfut-core/data/cards/gold_sample.json b/openfut-core/data/cards/gold_sample.json new file mode 100644 index 0000000..5598d4b --- /dev/null +++ b/openfut-core/data/cards/gold_sample.json @@ -0,0 +1,87 @@ +[ + { + "id": "card_gold_001", + "name": "Alejandro Vargas", + "overall": 82, + "position": "ST", + "nation": "Argentina", + "league": "Primera Division", + "club": "River Plate", + "pace": 85, + "shooting": 84, + "passing": 72, + "dribbling": 80, + "defending": 40, + "physical": 78, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_gold_002", + "name": "Thomas Beaumont", + "overall": 80, + "position": "CM", + "nation": "France", + "league": "Ligue 2", + "club": "FC Toulouse", + "pace": 72, + "shooting": 74, + "passing": 83, + "dribbling": 78, + "defending": 68, + "physical": 74, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_gold_003", + "name": "Dimitri Kovalenko", + "overall": 81, + "position": "CB", + "nation": "Ukraine", + "league": "Premier League UA", + "club": "Shakhtar Donetsk", + "pace": 75, + "shooting": 38, + "passing": 65, + "dribbling": 60, + "defending": 83, + "physical": 82, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_gold_004", + "name": "Felipe Moura", + "overall": 83, + "position": "LW", + "nation": "Brazil", + "league": "Brasileirao", + "club": "Palmeiras", + "pace": 88, + "shooting": 80, + "passing": 76, + "dribbling": 86, + "defending": 44, + "physical": 68, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_gold_005", + "name": "Lars Eriksson", + "overall": 79, + "position": "GK", + "nation": "Sweden", + "league": "Allsvenskan", + "club": "Malmo FF", + "pace": 45, + "shooting": 12, + "passing": 58, + "dribbling": 32, + "defending": 80, + "physical": 76, + "rarity": "gold", + "image_path": null + } +] diff --git a/openfut-core/data/cards/loan_icons.json b/openfut-core/data/cards/loan_icons.json new file mode 100644 index 0000000..299c455 --- /dev/null +++ b/openfut-core/data/cards/loan_icons.json @@ -0,0 +1,19 @@ +[ + { + "id": "card_icon_loan_001", + "name": "The Maestro (Loan)", + "overall": 94, + "position": "CAM", + "nation": "France", + "league": "Icons", + "club": "Icons", + "pace": 82, + "shooting": 88, + "passing": 95, + "dribbling": 92, + "defending": 60, + "physical": 72, + "rarity": "icon", + "image_path": null + } +] diff --git a/openfut-core/data/objectives/daily.json b/openfut-core/data/objectives/daily.json new file mode 100644 index 0000000..8c067b3 --- /dev/null +++ b/openfut-core/data/objectives/daily.json @@ -0,0 +1,24 @@ +[ + { + "id": "daily_play_1_match", + "title": "Daily Match", + "description": "Play 1 match today.", + "objective_type": "daily", + "metric": "matchesplayed", + "target": 1, + "reward_coins": 500, + "reward_pack_id": null, + "reward_xp": 100 + }, + { + "id": "daily_score_3_goals", + "title": "Hat-Trick Hero", + "description": "Score 3 goals in matches today.", + "objective_type": "daily", + "metric": "goalsscored", + "target": 3, + "reward_coins": 750, + "reward_pack_id": null, + "reward_xp": 150 + } +] diff --git a/openfut-core/data/objectives/lifetime.json b/openfut-core/data/objectives/lifetime.json new file mode 100644 index 0000000..d6687a5 --- /dev/null +++ b/openfut-core/data/objectives/lifetime.json @@ -0,0 +1,46 @@ +[ + { + "id": "lifetime_10_wins", + "title": "10 Victories", + "description": "Win 10 matches total.", + "objective_type": "lifetime", + "metric": "matcheswon", + "target": 10, + "reward_coins": 2000, + "reward_pack_id": "gold_pack", + "reward_xp": 500 + }, + { + "id": "lifetime_open_5_packs", + "title": "Pack Opener", + "description": "Open 5 packs.", + "objective_type": "lifetime", + "metric": "packsopened", + "target": 5, + "reward_coins": 1000, + "reward_pack_id": null, + "reward_xp": 250 + }, + { + "id": "lifetime_complete_3_sbcs", + "title": "SBC Enthusiast", + "description": "Complete 3 Squad Building Challenges.", + "objective_type": "lifetime", + "metric": "sbcscompleted", + "target": 3, + "reward_coins": 5000, + "reward_pack_id": "rare_gold_pack", + "reward_xp": 750 + }, + { + "id": "lifetime_earn_50k_coins", + "title": "Coin Collector", + "description": "Earn 50,000 coins total.", + "objective_type": "lifetime", + "metric": "coinsearned", + "target": 50000, + "reward_coins": 10000, + "reward_pack_id": null, + "reward_xp": 1000 + } +] diff --git a/openfut-core/data/packs/pack_definitions.json b/openfut-core/data/packs/pack_definitions.json new file mode 100644 index 0000000..3ed6adc --- /dev/null +++ b/openfut-core/data/packs/pack_definitions.json @@ -0,0 +1,64 @@ +[ + { + "id": "bronze_pack", + "name": "Bronze Pack", + "description": "Contains 12 bronze players.", + "cost_coins": 400, + "slots": [ + { + "count": 12, + "min_overall": null, + "rarity_filter": ["bronze"], + "guaranteed_rare": false + } + ] + }, + { + "id": "silver_pack", + "name": "Silver Pack", + "description": "Contains 12 silver players.", + "cost_coins": 2500, + "slots": [ + { + "count": 12, + "min_overall": 65, + "rarity_filter": null, + "guaranteed_rare": false + } + ] + }, + { + "id": "gold_pack", + "name": "Gold Pack", + "description": "Contains 12 gold players, at least one rare.", + "cost_coins": 7500, + "slots": [ + { + "count": 11, + "min_overall": 75, + "rarity_filter": ["gold"], + "guaranteed_rare": false + }, + { + "count": 1, + "min_overall": 75, + "rarity_filter": ["raregold"], + "guaranteed_rare": true + } + ] + }, + { + "id": "rare_gold_pack", + "name": "Rare Gold Pack", + "description": "Contains 12 rare gold players.", + "cost_coins": 15000, + "slots": [ + { + "count": 12, + "min_overall": 75, + "rarity_filter": ["raregold"], + "guaranteed_rare": true + } + ] + } +] diff --git a/openfut-core/data/sbcs/starter_sbcs.json b/openfut-core/data/sbcs/starter_sbcs.json new file mode 100644 index 0000000..75d70dc --- /dev/null +++ b/openfut-core/data/sbcs/starter_sbcs.json @@ -0,0 +1,50 @@ +[ + { + "id": "sbc_bronze_upgrade", + "name": "Bronze Upgrade", + "description": "Submit 11 bronze players for a silver pack.", + "requirements": { + "squad_size": 11, + "min_overall": null, + "max_overall": 64, + "min_chemistry": null, + "required_leagues": [], + "required_nations": [], + "required_clubs": [], + "min_players_from_same_league": null, + "min_players_from_same_nation": null, + "min_players_from_same_club": null + }, + "reward": { + "coins": 0, + "xp": 200, + "pack_id": "silver_pack" + }, + "expires_at": null, + "repeatable": true + }, + { + "id": "sbc_hybrid_nations", + "name": "Hybrid Nations", + "description": "Build a squad of 11 players with at least one Brazilian and one Argentinian.", + "requirements": { + "squad_size": 11, + "min_overall": 70, + "max_overall": null, + "min_chemistry": null, + "required_leagues": [], + "required_nations": ["Brazil", "Argentina"], + "required_clubs": [], + "min_players_from_same_league": null, + "min_players_from_same_nation": null, + "min_players_from_same_club": null + }, + "reward": { + "coins": 2000, + "xp": 400, + "pack_id": "gold_pack" + }, + "expires_at": null, + "repeatable": false + } +] diff --git a/openfut-core/docs/architecture.md b/openfut-core/docs/architecture.md new file mode 100644 index 0000000..af8a04e --- /dev/null +++ b/openfut-core/docs/architecture.md @@ -0,0 +1,97 @@ +# OpenFUT Core — Architecture + +## Overview + +``` +HTTP Client (Bridge or direct) + │ + ▼ + Axum Router + │ + ┌────┴─────┐ + │ Routes │ ← thin handlers: extract state, call service, return JSON + └────┬─────┘ + │ + ┌────┴──────┐ + │ Services │ ← business logic, DB calls, data loading + └────┬──────┘ + │ + ┌────┴──────┐ + │ SQLite │ ← SQLx + migrations + └───────────┘ + │ + ┌────┴──────┐ + │ Data/ │ ← JSON files: cards, packs, objectives, SBCs + └───────────┘ +``` + +## Module Map + +| Path | Purpose | +|---|---| +| `src/main.rs` | Entry point: tracing, config, pool, migrations, seed, serve | +| `src/lib.rs` | Library root: re-exports modules, exposes `build_app` for tests | +| `src/app.rs` | Router construction, `AppState` definition | +| `src/config.rs` | `Config` struct, loaded from env vars | +| `src/db.rs` | Pool initialization and migration runner | +| `src/error.rs` | `AppError` enum + `IntoResponse` impl | +| `src/models/` | Pure data types (Serde + SQLx `FromRow`) | +| `src/services/` | Business logic; all DB access lives here | +| `src/routes/` | Axum handler functions; one file per domain | +| `src/seed/` | First-run starter pack grant | +| `src/modding/` | Generic JSON directory loader | +| `data/` | Moddable JSON content: cards, packs, objectives, SBCs | +| `migrations/` | SQLx SQL migrations | + +## AppState + +`AppState` is cloned into every request handler via Axum's `State` extractor: + +```rust +pub struct AppState { + pub pool: Pool, // SQLite connection pool + pub card_db: Arc, // in-memory card registry + pub pack_defs: Arc>, + pub obj_defs: Arc>, + pub sbc_defs: Arc>, +} +``` + +All game-content data is loaded at startup from `data/` into `Arc`-wrapped collections. This avoids repeated disk I/O per request and keeps the data shared across the multi-threaded Tokio runtime without locking. + +## Data Flow: Pack Open + +1. `POST /packs/open/:pack_id` → `routes::packs::post_open_pack` +2. Fetch profile + club from DB +3. Call `services::pack::open_pack(pool, card_db, pack_defs, club_id, pack_id)` +4. Validate pack exists + not opened +5. For each slot in the pack definition, randomly select cards (synchronously — no rng held across await) +6. Insert `owned_cards` rows for each card +7. Mark pack as opened +8. Increment pack stats + objective progress +9. Return `PackOpenResult { pack_id, cards }` + +## Data Flow: Match Result + +1. `POST /matches/result` → `routes::matches::post_match_result` +2. Fetch profile + club +3. `services::match_service::process_match(...)` +4. Determine outcome (win/draw/loss), compute coins + XP +5. Insert match record +6. `club::add_coins`, `profile::add_xp` +7. `statistics::record_match` +8. `objective::increment_metric` for matches_played, matches_won, goals_scored, coins_earned +9. Return `MatchRewardResult` + +## Single-Profile Design + +OpenFUT is single-player. Only one profile is allowed per database. All services fetch "the active profile" by selecting the first row. This is intentional and keeps the system simple. + +## Modding + +All game content is data-driven. To add new cards: +1. Create a JSON file in `data/cards/` +2. The file must be an array of `CardDefinition` +3. Restart the server + +The `CardDb` struct loads all JSON files at startup and holds them in a `HashMap`. diff --git a/openfut-core/migrations/0001_initial.sql b/openfut-core/migrations/0001_initial.sql new file mode 100644 index 0000000..2bfcd0c --- /dev/null +++ b/openfut-core/migrations/0001_initial.sql @@ -0,0 +1,126 @@ +-- OpenFUT Core initial schema + +CREATE TABLE IF NOT EXISTS profiles ( + id TEXT PRIMARY KEY NOT NULL, + username TEXT NOT NULL UNIQUE, + level INTEGER NOT NULL DEFAULT 1, + xp INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS clubs ( + id TEXT PRIMARY KEY NOT NULL, + profile_id TEXT NOT NULL REFERENCES profiles(id), + name TEXT NOT NULL, + coins INTEGER NOT NULL DEFAULT 0, + level INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS owned_cards ( + id TEXT PRIMARY KEY NOT NULL, + club_id TEXT NOT NULL REFERENCES clubs(id), + card_id TEXT NOT NULL, + is_loan INTEGER NOT NULL DEFAULT 0, + loan_matches_remaining INTEGER, + acquired_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS packs ( + id TEXT PRIMARY KEY NOT NULL, + club_id TEXT NOT NULL REFERENCES clubs(id), + definition_id TEXT NOT NULL, + opened INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS squads ( + id TEXT PRIMARY KEY NOT NULL, + club_id TEXT NOT NULL REFERENCES clubs(id), + name TEXT NOT NULL DEFAULT 'My Squad', + formation TEXT NOT NULL DEFAULT '4-4-2', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS squad_players ( + id TEXT PRIMARY KEY NOT NULL, + squad_id TEXT NOT NULL REFERENCES squads(id), + owned_card_id TEXT NOT NULL REFERENCES owned_cards(id), + position_index INTEGER NOT NULL, + is_captain INTEGER NOT NULL DEFAULT 0, + is_on_bench INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS objective_progress ( + id TEXT PRIMARY KEY NOT NULL, + profile_id TEXT NOT NULL REFERENCES profiles(id), + objective_id TEXT NOT NULL, + current INTEGER NOT NULL DEFAULT 0, + completed INTEGER NOT NULL DEFAULT 0, + claimed INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + UNIQUE(profile_id, objective_id) +); + +CREATE TABLE IF NOT EXISTS matches ( + id TEXT PRIMARY KEY NOT NULL, + profile_id TEXT NOT NULL REFERENCES profiles(id), + squad_id TEXT NOT NULL, + opponent_name TEXT NOT NULL, + goals_for INTEGER NOT NULL DEFAULT 0, + goals_against INTEGER NOT NULL DEFAULT 0, + outcome TEXT NOT NULL, + coins_awarded INTEGER NOT NULL DEFAULT 0, + xp_awarded INTEGER NOT NULL DEFAULT 0, + mode TEXT NOT NULL DEFAULT 'squad_battles', + played_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS statistics ( + profile_id TEXT PRIMARY KEY NOT NULL REFERENCES profiles(id), + matches_played INTEGER NOT NULL DEFAULT 0, + matches_won INTEGER NOT NULL DEFAULT 0, + matches_drawn INTEGER NOT NULL DEFAULT 0, + matches_lost INTEGER NOT NULL DEFAULT 0, + goals_scored INTEGER NOT NULL DEFAULT 0, + goals_conceded INTEGER NOT NULL DEFAULT 0, + packs_opened INTEGER NOT NULL DEFAULT 0, + sbcs_completed INTEGER NOT NULL DEFAULT 0, + total_coins_earned INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sbc_submissions ( + id TEXT PRIMARY KEY NOT NULL, + profile_id TEXT NOT NULL REFERENCES profiles(id), + sbc_id TEXT NOT NULL, + submitted_card_ids TEXT NOT NULL, + passed INTEGER NOT NULL DEFAULT 0, + submitted_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS market_listings ( + id TEXT PRIMARY KEY NOT NULL, + card_id TEXT NOT NULL, + seller_name TEXT NOT NULL, + price INTEGER NOT NULL DEFAULT 0, + listed_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + sold INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_owned_cards_club ON owned_cards(club_id); +CREATE INDEX IF NOT EXISTS idx_packs_club ON packs(club_id); +CREATE INDEX IF NOT EXISTS idx_squad_players_squad ON squad_players(squad_id); +CREATE INDEX IF NOT EXISTS idx_obj_progress_profile ON objective_progress(profile_id); +CREATE INDEX IF NOT EXISTS idx_matches_profile ON matches(profile_id); +CREATE INDEX IF NOT EXISTS idx_market_active ON market_listings(sold, expires_at); diff --git a/openfut-core/src/app.rs b/openfut-core/src/app.rs new file mode 100644 index 0000000..eb27841 --- /dev/null +++ b/openfut-core/src/app.rs @@ -0,0 +1,74 @@ +use crate::{ + config::Config, + db::Pool, + models::{objective::ObjectiveDefinition, pack::PackDefinition, sbc::SbcDefinition}, + routes, + services::{ + card_db::CardDb, objective::load_objective_definitions, pack::load_pack_definitions, + sbc::load_sbc_definitions, + }, +}; +use anyhow::Result; +use axum::{ + routing::{get, post}, + Router, +}; +use std::sync::Arc; +use tower_http::{cors::CorsLayer, trace::TraceLayer}; + +#[derive(Clone)] +pub struct AppState { + pub pool: Pool, + pub card_db: Arc, + pub pack_defs: Arc>, + pub obj_defs: Arc>, + pub sbc_defs: Arc>, +} + +pub async fn build(pool: Pool, cfg: Config) -> Result { + let card_db = Arc::new(CardDb::load(&cfg.data_dir)?); + let pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?); + let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?); + let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?); + + tracing::info!( + "Loaded {} packs, {} objectives, {} SBCs", + pack_defs.len(), + obj_defs.len(), + sbc_defs.len() + ); + + let state = AppState { + pool, + card_db, + pack_defs, + obj_defs, + sbc_defs, + }; + + let router = Router::new() + .route("/health", get(routes::health::get_health)) + .route("/auth/local", post(routes::auth::post_auth_local)) + .route("/profile", get(routes::profile::get_profile)) + .route("/club", get(routes::club::get_club)) + .route("/cards", get(routes::cards::get_cards)) + .route("/collection", get(routes::cards::get_collection)) + .route("/packs", get(routes::packs::get_packs)) + .route("/packs/open/:pack_id", post(routes::packs::post_open_pack)) + .route("/squad", get(routes::squad::get_squad)) + .route("/squad", post(routes::squad::post_squad)) + .route("/objectives", get(routes::objectives::get_objectives)) + .route("/matches/result", post(routes::matches::post_match_result)) + .route("/sbc", get(routes::sbc::get_sbcs)) + .route("/sbc/submit", post(routes::sbc::post_sbc_submit)) + .route("/market", get(routes::market::get_market)) + .route("/market/buy", post(routes::market::post_market_buy)) + .route("/market/sell", post(routes::market::post_market_sell)) + .route("/market/refresh", post(routes::market::post_market_refresh)) + .route("/statistics", get(routes::statistics::get_statistics)) + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::permissive()) + .with_state(state); + + Ok(router) +} diff --git a/openfut-core/src/config.rs b/openfut-core/src/config.rs new file mode 100644 index 0000000..96c6da7 --- /dev/null +++ b/openfut-core/src/config.rs @@ -0,0 +1,25 @@ +use anyhow::Result; + +#[derive(Debug, Clone)] +pub struct Config { + pub listen_addr: String, + pub database_url: String, + pub data_dir: String, + #[allow(dead_code)] + pub max_connections: u32, +} + +impl Config { + pub fn from_env() -> Result { + Ok(Self { + listen_addr: std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".into()), + database_url: std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "sqlite://openfut.db".into()), + data_dir: std::env::var("DATA_DIR").unwrap_or_else(|_| "data".into()), + max_connections: std::env::var("DB_MAX_CONNECTIONS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5), + }) + } +} diff --git a/openfut-core/src/db.rs b/openfut-core/src/db.rs new file mode 100644 index 0000000..9c724d7 --- /dev/null +++ b/openfut-core/src/db.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; +use tracing::info; + +pub type Pool = SqlitePool; + +pub async fn init_pool(database_url: &str) -> Result { + info!("Connecting to database: {}", database_url); + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect(database_url) + .await?; + Ok(pool) +} + +pub async fn run_migrations(pool: &Pool) -> Result<()> { + info!("Running database migrations"); + sqlx::migrate!("./migrations").run(pool).await?; + Ok(()) +} diff --git a/openfut-core/src/error.rs b/openfut-core/src/error.rs new file mode 100644 index 0000000..0e00654 --- /dev/null +++ b/openfut-core/src/error.rs @@ -0,0 +1,62 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("not found: {0}")] + NotFound(String), + + #[error("bad request: {0}")] + BadRequest(String), + + #[error("conflict: {0}")] + Conflict(String), + + #[error("database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("internal error: {0}")] + Internal(#[from] anyhow::Error), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()), + AppError::Database(e) => { + tracing::error!("Database error: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "database error".into()) + } + AppError::Internal(e) => { + tracing::error!("Internal error: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error".into(), + ) + } + AppError::Io(e) => { + tracing::error!("IO error: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "io error".into()) + } + AppError::Json(e) => (StatusCode::BAD_REQUEST, format!("json parse error: {e}")), + }; + + let body = Json(json!({ "error": message })); + (status, body).into_response() + } +} + +pub type AppResult = Result; diff --git a/openfut-core/src/lib.rs b/openfut-core/src/lib.rs new file mode 100644 index 0000000..971df47 --- /dev/null +++ b/openfut-core/src/lib.rs @@ -0,0 +1,24 @@ +pub mod app; +pub mod config; +pub mod db; +pub mod error; +pub mod modding; +pub mod models; +pub mod routes; +pub mod seed; +pub mod services; + +use anyhow::Result; +use axum::Router; + +/// Build the full Axum application with a provided pool and data directory. +/// Used by tests to create in-process app instances. +pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result { + let cfg = config::Config { + listen_addr: "127.0.0.1:0".into(), + database_url: "sqlite::memory:".into(), + data_dir: data_dir.to_string(), + max_connections: 1, + }; + app::build(pool, cfg).await +} diff --git a/openfut-core/src/main.rs b/openfut-core/src/main.rs new file mode 100644 index 0000000..87d8719 --- /dev/null +++ b/openfut-core/src/main.rs @@ -0,0 +1,33 @@ +use anyhow::Result; +use openfut_core::{config, db, seed}; +use tracing::info; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +#[tokio::main] +async fn main() -> Result<()> { + dotenvy::dotenv().ok(); + + tracing_subscriber::registry() + .with( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "openfut_core=debug,tower_http=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let cfg = config::Config::from_env()?; + info!("OpenFUT Core starting on {}", cfg.listen_addr); + + let pool = db::init_pool(&cfg.database_url).await?; + db::run_migrations(&pool).await?; + + seed::maybe_seed(&pool).await?; + + let app = openfut_core::app::build(pool, cfg.clone()).await?; + + let listener = tokio::net::TcpListener::bind(&cfg.listen_addr).await?; + info!("Listening on http://{}", cfg.listen_addr); + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/openfut-core/src/modding/loader.rs b/openfut-core/src/modding/loader.rs new file mode 100644 index 0000000..9bde1f9 --- /dev/null +++ b/openfut-core/src/modding/loader.rs @@ -0,0 +1,24 @@ +use anyhow::{Context, Result}; +use serde::de::DeserializeOwned; +use std::path::Path; + +#[allow(dead_code)] +/// Generic loader for JSON arrays from a directory. +pub fn load_json_dir(dir: &Path) -> Result> { + let mut items = Vec::new(); + if !dir.exists() { + return Ok(items); + } + for entry in std::fs::read_dir(dir).with_context(|| format!("reading dir {dir:?}"))? { + let entry = entry?; + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + let content = + std::fs::read_to_string(&path).with_context(|| format!("reading {path:?}"))?; + let batch: Vec = + serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?; + items.extend(batch); + } + } + Ok(items) +} diff --git a/openfut-core/src/modding/mod.rs b/openfut-core/src/modding/mod.rs new file mode 100644 index 0000000..b7c2862 --- /dev/null +++ b/openfut-core/src/modding/mod.rs @@ -0,0 +1,4 @@ +//! Modding support: load JSON data files from the data/ directory. +//! All game content (cards, packs, objectives, SBCs) is data-driven. + +pub mod loader; diff --git a/openfut-core/src/models/card.rs b/openfut-core/src/models/card.rs new file mode 100644 index 0000000..57bfe61 --- /dev/null +++ b/openfut-core/src/models/card.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Rarity { + #[default] + Bronze, + Silver, + Gold, + RareGold, + Totw, + Hero, + Icon, +} + +/// A card definition loaded from JSON data files. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CardDefinition { + pub id: String, + pub name: String, + pub overall: u8, + pub position: String, + pub nation: String, + pub league: String, + pub club: String, + pub pace: u8, + pub shooting: u8, + pub passing: u8, + pub dribbling: u8, + pub defending: u8, + pub physical: u8, + pub rarity: Rarity, + pub image_path: Option, +} + +/// A card instance owned by a club (stored in DB). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct OwnedCard { + pub id: String, + pub club_id: String, + pub card_id: String, + pub is_loan: bool, + pub loan_matches_remaining: Option, + pub acquired_at: String, +} diff --git a/openfut-core/src/models/club.rs b/openfut-core/src/models/club.rs new file mode 100644 index 0000000..f5fdd86 --- /dev/null +++ b/openfut-core/src/models/club.rs @@ -0,0 +1,29 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Club { + pub id: String, + pub profile_id: String, + pub name: String, + pub coins: i64, + pub level: i64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Club { + pub fn new(profile_id: impl Into, name: impl Into, coins: i64) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4().to_string(), + profile_id: profile_id.into(), + name: name.into(), + coins, + level: 1, + created_at: now, + updated_at: now, + } + } +} diff --git a/openfut-core/src/models/market.rs b/openfut-core/src/models/market.rs new file mode 100644 index 0000000..1cbcb3b --- /dev/null +++ b/openfut-core/src/models/market.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// NPC transfer market listing +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct MarketListing { + pub id: String, + pub card_id: String, + pub seller_name: String, + pub price: i64, + pub listed_at: String, + pub expires_at: String, + pub sold: bool, +} + +impl MarketListing { + pub fn new(card_id: impl Into, seller_name: impl Into, price: i64) -> Self { + let now = chrono::Utc::now(); + let expires = now + chrono::Duration::hours(24); + Self { + id: Uuid::new_v4().to_string(), + card_id: card_id.into(), + seller_name: seller_name.into(), + price, + listed_at: now.to_rfc3339(), + expires_at: expires.to_rfc3339(), + sold: false, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct BuyListingRequest { + pub listing_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct SellCardRequest { + pub owned_card_id: String, + pub price: i64, +} + +#[derive(Debug, Serialize)] +pub struct MarketListingWithCard { + #[serde(flatten)] + pub listing: MarketListing, + pub card: crate::models::card::CardDefinition, +} diff --git a/openfut-core/src/models/match_result.rs b/openfut-core/src/models/match_result.rs new file mode 100644 index 0000000..b8ce88a --- /dev/null +++ b/openfut-core/src/models/match_result.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MatchOutcome { + Win, + Draw, + Loss, +} + +#[derive(Debug, Deserialize)] +pub struct SubmitMatchRequest { + pub squad_id: String, + pub opponent_name: String, + pub goals_for: i64, + pub goals_against: i64, + pub mode: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Match { + pub id: String, + pub profile_id: String, + pub squad_id: String, + pub opponent_name: String, + pub goals_for: i64, + pub goals_against: i64, + pub outcome: String, + pub coins_awarded: i64, + pub xp_awarded: i64, + pub mode: String, + pub played_at: String, +} + +impl Match { + #[allow(clippy::too_many_arguments)] + pub fn new( + profile_id: &str, + squad_id: &str, + opponent_name: &str, + goals_for: i64, + goals_against: i64, + mode: &str, + coins_awarded: i64, + xp_awarded: i64, + ) -> Self { + let outcome = if goals_for > goals_against { + "win" + } else if goals_for == goals_against { + "draw" + } else { + "loss" + }; + + Self { + id: Uuid::new_v4().to_string(), + profile_id: profile_id.to_string(), + squad_id: squad_id.to_string(), + opponent_name: opponent_name.to_string(), + goals_for, + goals_against, + outcome: outcome.to_string(), + coins_awarded, + xp_awarded, + mode: mode.to_string(), + played_at: chrono::Utc::now().to_rfc3339(), + } + } +} + +#[derive(Debug, Serialize)] +pub struct MatchRewardResult { + pub match_record: Match, + pub coins_awarded: i64, + pub xp_awarded: i64, + pub objectives_updated: Vec, +} diff --git a/openfut-core/src/models/mod.rs b/openfut-core/src/models/mod.rs new file mode 100644 index 0000000..ecc804b --- /dev/null +++ b/openfut-core/src/models/mod.rs @@ -0,0 +1,11 @@ +pub mod card; +pub mod club; +pub mod market; +pub mod match_result; +pub mod objective; +pub mod pack; +pub mod profile; +pub mod reward; +pub mod sbc; +pub mod squad; +pub mod statistics; diff --git a/openfut-core/src/models/objective.rs b/openfut-core/src/models/objective.rs new file mode 100644 index 0000000..0a79bec --- /dev/null +++ b/openfut-core/src/models/objective.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ObjectiveType { + Daily, + Weekly, + Lifetime, + Milestone, + Season, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ObjectiveMetric { + MatchesWon, + MatchesPlayed, + GoalsScored, + PacksOpened, + SbcsCompleted, + CoinsEarned, +} + +/// Objective definition from data/objectives/*.json +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ObjectiveDefinition { + pub id: String, + pub title: String, + pub description: String, + pub objective_type: ObjectiveType, + pub metric: ObjectiveMetric, + pub target: i64, + pub reward_coins: i64, + pub reward_pack_id: Option, + pub reward_xp: i64, +} + +/// Progress row in DB +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ObjectiveProgress { + pub id: String, + pub profile_id: String, + pub objective_id: String, + pub current: i64, + pub completed: bool, + pub claimed: bool, + pub updated_at: String, +} + +#[derive(Debug, Serialize)] +pub struct ObjectiveWithProgress { + #[serde(flatten)] + pub definition: ObjectiveDefinition, + pub current: i64, + pub completed: bool, + pub claimed: bool, +} diff --git a/openfut-core/src/models/pack.rs b/openfut-core/src/models/pack.rs new file mode 100644 index 0000000..b7eeed4 --- /dev/null +++ b/openfut-core/src/models/pack.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +/// Pack definition loaded from data/packs/*.json +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackDefinition { + pub id: String, + pub name: String, + pub description: String, + pub cost_coins: i64, + pub slots: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackSlot { + pub count: u8, + /// Minimum overall rating filter + pub min_overall: Option, + /// Rarity filter: "bronze", "silver", "gold", "rare_gold", "totw", "hero", "icon" + pub rarity_filter: Option>, + /// Whether this slot guarantees rare + pub guaranteed_rare: bool, +} + +/// A pack instance stored in the DB (assigned but unopened). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Pack { + pub id: String, + pub club_id: String, + pub definition_id: String, + pub opened: bool, + pub created_at: String, +} + +/// The result of opening a pack. +#[derive(Debug, Serialize)] +pub struct PackOpenResult { + pub pack_id: String, + pub cards: Vec, +} diff --git a/openfut-core/src/models/profile.rs b/openfut-core/src/models/profile.rs new file mode 100644 index 0000000..1ee911d --- /dev/null +++ b/openfut-core/src/models/profile.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Profile { + pub id: String, + pub username: String, + pub level: i64, + pub xp: i64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Profile { + pub fn new(username: impl Into) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4().to_string(), + username: username.into(), + level: 1, + xp: 0, + created_at: now, + updated_at: now, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct CreateProfileRequest { + pub username: Option, +} diff --git a/openfut-core/src/models/reward.rs b/openfut-core/src/models/reward.rs new file mode 100644 index 0000000..bac98c3 --- /dev/null +++ b/openfut-core/src/models/reward.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Reward { + pub coins: i64, + pub xp: i64, + pub pack_id: Option, +} + +#[allow(dead_code)] +impl Reward { + pub fn coins_only(coins: i64) -> Self { + Self { + coins, + xp: 0, + pack_id: None, + } + } + + pub fn full(coins: i64, xp: i64, pack_id: Option) -> Self { + Self { coins, xp, pack_id } + } +} diff --git a/openfut-core/src/models/sbc.rs b/openfut-core/src/models/sbc.rs new file mode 100644 index 0000000..bafe983 --- /dev/null +++ b/openfut-core/src/models/sbc.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +/// SBC definition from data/sbcs/*.json +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SbcDefinition { + pub id: String, + pub name: String, + pub description: String, + pub requirements: SbcRequirements, + pub reward: SbcReward, + pub expires_at: Option, + pub repeatable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SbcRequirements { + pub squad_size: u8, + pub min_overall: Option, + pub max_overall: Option, + pub min_chemistry: Option, + pub required_leagues: Vec, + pub required_nations: Vec, + pub required_clubs: Vec, + pub min_players_from_same_league: Option, + pub min_players_from_same_nation: Option, + pub min_players_from_same_club: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SbcReward { + pub coins: i64, + pub xp: i64, + pub pack_id: Option, +} + +/// DB record of a completed submission +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SbcSubmission { + pub id: String, + pub profile_id: String, + pub sbc_id: String, + pub submitted_card_ids: String, + pub passed: bool, + pub submitted_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct SubmitSbcRequest { + pub sbc_id: String, + pub owned_card_ids: Vec, +} + +#[derive(Debug, Serialize)] +pub struct SbcResult { + pub passed: bool, + pub failures: Vec, + pub reward: Option, +} diff --git a/openfut-core/src/models/squad.rs b/openfut-core/src/models/squad.rs new file mode 100644 index 0000000..6835ee3 --- /dev/null +++ b/openfut-core/src/models/squad.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Squad { + pub id: String, + pub club_id: String, + pub name: String, + pub formation: String, + pub created_at: String, + pub updated_at: String, +} + +impl Squad { + pub fn new( + club_id: impl Into, + name: impl Into, + formation: impl Into, + ) -> Self { + let now = chrono::Utc::now().to_rfc3339(); + Self { + id: Uuid::new_v4().to_string(), + club_id: club_id.into(), + name: name.into(), + formation: formation.into(), + created_at: now.clone(), + updated_at: now, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SquadPlayer { + pub id: String, + pub squad_id: String, + pub owned_card_id: String, + pub position_index: i64, + pub is_captain: bool, + pub is_on_bench: bool, +} + +#[derive(Debug, Deserialize)] +pub struct SaveSquadRequest { + pub name: Option, + pub formation: Option, + pub players: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct SquadPlayerInput { + pub owned_card_id: String, + pub position_index: i64, + pub is_captain: bool, + pub is_on_bench: bool, +} diff --git a/openfut-core/src/models/statistics.rs b/openfut-core/src/models/statistics.rs new file mode 100644 index 0000000..858c019 --- /dev/null +++ b/openfut-core/src/models/statistics.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Statistics { + pub profile_id: String, + pub matches_played: i64, + pub matches_won: i64, + pub matches_drawn: i64, + pub matches_lost: i64, + pub goals_scored: i64, + pub goals_conceded: i64, + pub packs_opened: i64, + pub sbcs_completed: i64, + pub total_coins_earned: i64, + pub updated_at: String, +} + +impl Statistics { + pub fn new(profile_id: impl Into) -> Self { + Self { + profile_id: profile_id.into(), + matches_played: 0, + matches_won: 0, + matches_drawn: 0, + matches_lost: 0, + goals_scored: 0, + goals_conceded: 0, + packs_opened: 0, + sbcs_completed: 0, + total_coins_earned: 0, + updated_at: chrono::Utc::now().to_rfc3339(), + } + } +} diff --git a/openfut-core/src/routes/auth.rs b/openfut-core/src/routes/auth.rs new file mode 100644 index 0000000..dc79589 --- /dev/null +++ b/openfut-core/src/routes/auth.rs @@ -0,0 +1,30 @@ +use axum::{extract::State, Json}; +use serde_json::{json, Value}; + +use crate::{ + app::AppState, + error::AppResult, + models::{club::Club, profile::CreateProfileRequest}, + seed, + services::{club as club_svc, profile as profile_svc}, +}; + +pub async fn post_auth_local( + State(state): State, + Json(req): Json, +) -> AppResult> { + let username = req.username.unwrap_or_else(|| "Player 1".into()); + + let profile = profile_svc::create_profile(&state.pool, &username).await?; + + let club = Club::new(&profile.id, "OpenFUT FC", 5000); + club_svc::create_club(&state.pool, &club).await?; + + seed::grant_starter_pack(&state.pool, &club.id, &state.pack_defs).await?; + + Ok(Json(json!({ + "profile": profile, + "club": club, + "message": "Welcome to OpenFUT FC! Your club has been created." + }))) +} diff --git a/openfut-core/src/routes/cards.rs b/openfut-core/src/routes/cards.rs new file mode 100644 index 0000000..930b40d --- /dev/null +++ b/openfut-core/src/routes/cards.rs @@ -0,0 +1,75 @@ +use axum::{ + extract::{Query, State}, + Json, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::{ + app::AppState, + error::AppResult, + models::card::OwnedCard, + services::{club as club_svc, profile as profile_svc}, +}; + +#[derive(Debug, Deserialize)] +pub struct CardQuery { + pub rarity: Option, + pub position: Option, +} + +pub async fn get_cards( + State(state): State, + Query(query): Query, +) -> AppResult> { + let cards = state + .card_db + .all() + .into_iter() + .filter(|c| { + query + .rarity + .as_ref() + .map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase()) + .unwrap_or(true) + && query + .position + .as_ref() + .map(|p| c.position.to_lowercase() == p.to_lowercase()) + .unwrap_or(true) + }) + .collect::>(); + + Ok(Json(json!({ "cards": cards, "total": cards.len() }))) +} + +pub async fn get_collection(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let owned = sqlx::query_as::<_, OwnedCard>( + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE club_id = ?" + ) + .bind(&club.id) + .fetch_all(&state.pool) + .await?; + + let with_defs: Vec = owned + .iter() + .filter_map(|o| { + state.card_db.get(&o.card_id).map(|def| { + json!({ + "owned_card_id": o.id, + "is_loan": o.is_loan, + "loan_matches_remaining": o.loan_matches_remaining, + "acquired_at": o.acquired_at, + "card": def, + }) + }) + }) + .collect(); + + Ok(Json( + json!({ "collection": with_defs, "total": with_defs.len() }), + )) +} diff --git a/openfut-core/src/routes/club.rs b/openfut-core/src/routes/club.rs new file mode 100644 index 0000000..4330687 --- /dev/null +++ b/openfut-core/src/routes/club.rs @@ -0,0 +1,13 @@ +use crate::{ + app::AppState, + error::AppResult, + models::club::Club, + services::{club as club_svc, profile as profile_svc}, +}; +use axum::{extract::State, Json}; + +pub async fn get_club(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + Ok(Json(club)) +} diff --git a/openfut-core/src/routes/health.rs b/openfut-core/src/routes/health.rs new file mode 100644 index 0000000..7e0ab10 --- /dev/null +++ b/openfut-core/src/routes/health.rs @@ -0,0 +1,13 @@ +use axum::{http::StatusCode, Json}; +use serde_json::{json, Value}; + +pub async fn get_health() -> (StatusCode, Json) { + ( + StatusCode::OK, + Json(json!({ + "status": "ok", + "service": "openfut-core", + "version": env!("CARGO_PKG_VERSION") + })), + ) +} diff --git a/openfut-core/src/routes/market.rs b/openfut-core/src/routes/market.rs new file mode 100644 index 0000000..e86f69d --- /dev/null +++ b/openfut-core/src/routes/market.rs @@ -0,0 +1,47 @@ +use axum::{extract::State, Json}; +use serde_json::{json, Value}; + +use crate::{ + app::AppState, + error::AppResult, + models::market::{BuyListingRequest, SellCardRequest}, + services::{club as club_svc, market as market_svc, profile as profile_svc}, +}; + +pub async fn get_market(State(state): State) -> AppResult> { + let listings = market_svc::get_active_listings(&state.pool, &state.card_db).await?; + Ok(Json( + json!({ "listings": listings, "total": listings.len() }), + )) +} + +pub async fn post_market_buy( + State(state): State, + Json(req): Json, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?; + Ok(Json( + json!({ "purchased_card": card, "message": "Card purchased successfully" }), + )) +} + +pub async fn post_market_sell( + State(state): State, + Json(req): Json, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let new_balance = market_svc::sell_card(&state.pool, &club.id, &req).await?; + Ok(Json( + json!({ "new_coin_balance": new_balance, "message": "Card sold to NPC market" }), + )) +} + +pub async fn post_market_refresh(State(state): State) -> AppResult> { + let count = market_svc::refresh_npc_listings(&state.pool, &state.card_db).await?; + Ok(Json(json!({ "listings_generated": count }))) +} diff --git a/openfut-core/src/routes/matches.rs b/openfut-core/src/routes/matches.rs new file mode 100644 index 0000000..b744bde --- /dev/null +++ b/openfut-core/src/routes/matches.rs @@ -0,0 +1,22 @@ +use axum::{extract::State, Json}; + +use crate::{ + app::AppState, + error::AppResult, + models::match_result::{MatchRewardResult, SubmitMatchRequest}, + services::{club as club_svc, match_service, profile as profile_svc}, +}; + +pub async fn post_match_result( + State(state): State, + Json(req): Json, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let result = + match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs) + .await?; + + Ok(Json(result)) +} diff --git a/openfut-core/src/routes/mod.rs b/openfut-core/src/routes/mod.rs new file mode 100644 index 0000000..2c2a3a9 --- /dev/null +++ b/openfut-core/src/routes/mod.rs @@ -0,0 +1,12 @@ +pub mod auth; +pub mod cards; +pub mod club; +pub mod health; +pub mod market; +pub mod matches; +pub mod objectives; +pub mod packs; +pub mod profile; +pub mod sbc; +pub mod squad; +pub mod statistics; diff --git a/openfut-core/src/routes/objectives.rs b/openfut-core/src/routes/objectives.rs new file mode 100644 index 0000000..303d7fb --- /dev/null +++ b/openfut-core/src/routes/objectives.rs @@ -0,0 +1,15 @@ +use axum::{extract::State, Json}; +use serde_json::{json, Value}; + +use crate::{ + app::AppState, + error::AppResult, + services::{objective as obj_svc, profile as profile_svc}, +}; + +pub async fn get_objectives(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let objectives = + obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?; + Ok(Json(json!({ "objectives": objectives }))) +} diff --git a/openfut-core/src/routes/packs.rs b/openfut-core/src/routes/packs.rs new file mode 100644 index 0000000..b1232c0 --- /dev/null +++ b/openfut-core/src/routes/packs.rs @@ -0,0 +1,57 @@ +use axum::{ + extract::{Path, State}, + Json, +}; +use serde_json::{json, Value}; + +use crate::{ + app::AppState, + error::AppResult, + models::pack::PackOpenResult, + services::{club as club_svc, objective, pack as pack_svc, profile as profile_svc, statistics}, +}; + +pub async fn get_packs(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?; + + let with_defs: Vec = packs + .iter() + .map(|p| { + let def = state.pack_defs.iter().find(|d| d.id == p.definition_id); + json!({ + "pack_id": p.id, + "definition_id": p.definition_id, + "name": def.map(|d| &d.name), + "description": def.map(|d| &d.description), + "created_at": p.created_at, + }) + }) + .collect(); + + Ok(Json(json!({ "packs": with_defs }))) +} + +pub async fn post_open_pack( + State(state): State, + Path(pack_id): Path, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let result = pack_svc::open_pack( + &state.pool, + &state.card_db, + &state.pack_defs, + &club.id, + &pack_id, + ) + .await?; + + statistics::increment_packs_opened(&state.pool, &profile.id).await?; + objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1) + .await?; + + Ok(Json(result)) +} diff --git a/openfut-core/src/routes/profile.rs b/openfut-core/src/routes/profile.rs new file mode 100644 index 0000000..9477ccb --- /dev/null +++ b/openfut-core/src/routes/profile.rs @@ -0,0 +1,9 @@ +use crate::{ + app::AppState, error::AppResult, models::profile::Profile, services::profile as profile_svc, +}; +use axum::{extract::State, Json}; + +pub async fn get_profile(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + Ok(Json(profile)) +} diff --git a/openfut-core/src/routes/sbc.rs b/openfut-core/src/routes/sbc.rs new file mode 100644 index 0000000..5345f93 --- /dev/null +++ b/openfut-core/src/routes/sbc.rs @@ -0,0 +1,34 @@ +use axum::{extract::State, Json}; +use serde_json::{json, Value}; + +use crate::{ + app::AppState, + error::AppResult, + models::sbc::{SbcResult, SubmitSbcRequest}, + services::{club as club_svc, profile as profile_svc, sbc as sbc_svc}, +}; + +pub async fn get_sbcs(State(state): State) -> AppResult> { + Ok(Json(json!({ "sbcs": state.sbc_defs }))) +} + +pub async fn post_sbc_submit( + State(state): State, + Json(req): Json, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let result = sbc_svc::submit_sbc( + &state.pool, + &state.card_db, + &state.sbc_defs, + &state.obj_defs, + &profile.id, + &club.id, + &req, + ) + .await?; + + Ok(Json(result)) +} diff --git a/openfut-core/src/routes/squad.rs b/openfut-core/src/routes/squad.rs new file mode 100644 index 0000000..d0ee15a --- /dev/null +++ b/openfut-core/src/routes/squad.rs @@ -0,0 +1,49 @@ +use axum::{extract::State, Json}; +use serde_json::{json, Value}; + +use crate::{ + app::AppState, + error::AppResult, + models::squad::SaveSquadRequest, + services::{club as club_svc, profile as profile_svc, squad as squad_svc}, +}; + +pub async fn get_squad(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?; + + let enriched: Vec = players + .iter() + .map(|sp| { + json!({ + "squad_player_id": sp.id, + "owned_card_id": sp.owned_card_id, + "position_index": sp.position_index, + "is_captain": sp.is_captain, + "is_on_bench": sp.is_on_bench, + }) + }) + .collect(); + + Ok(Json(json!({ + "squad": { + "id": squad.id, + "name": squad.name, + "formation": squad.formation, + }, + "players": enriched, + }))) +} + +pub async fn post_squad( + State(state): State, + Json(req): Json, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?; + Ok(Json(json!({ "squad": squad }))) +} diff --git a/openfut-core/src/routes/statistics.rs b/openfut-core/src/routes/statistics.rs new file mode 100644 index 0000000..6e9c019 --- /dev/null +++ b/openfut-core/src/routes/statistics.rs @@ -0,0 +1,14 @@ +use axum::{extract::State, Json}; + +use crate::{ + app::AppState, + error::AppResult, + models::statistics::Statistics, + services::{profile as profile_svc, statistics as stats_svc}, +}; + +pub async fn get_statistics(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?; + Ok(Json(stats)) +} diff --git a/openfut-core/src/seed/mod.rs b/openfut-core/src/seed/mod.rs new file mode 100644 index 0000000..66c3cf4 --- /dev/null +++ b/openfut-core/src/seed/mod.rs @@ -0,0 +1,31 @@ +use crate::{db::Pool, error::AppResult, models::pack::PackDefinition, services::pack as pack_svc}; +use tracing::info; + +/// Seeds the market with NPC listings if empty. +pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> { + // Any one-time startup seeds go here. + // Currently we just ensure the market gets listings on first run. + Ok(()) +} + +/// Grants the starter pack to a newly created club. +pub async fn grant_starter_pack( + pool: &Pool, + club_id: &str, + pack_defs: &[PackDefinition], +) -> AppResult<()> { + // Look for the gold starter pack first; fall back to the first available definition. + let starter_def = pack_defs + .iter() + .find(|p| p.id == "gold_pack") + .or_else(|| pack_defs.first()); + + if let Some(def) = starter_def { + info!("Granting starter pack '{}' to club {}", def.id, club_id); + pack_svc::grant_pack(pool, club_id, &def.id).await?; + } else { + tracing::warn!("No pack definitions loaded; skipping starter pack grant"); + } + + Ok(()) +} diff --git a/openfut-core/src/services/card_db.rs b/openfut-core/src/services/card_db.rs new file mode 100644 index 0000000..7cb609d --- /dev/null +++ b/openfut-core/src/services/card_db.rs @@ -0,0 +1,63 @@ +use crate::models::card::CardDefinition; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::path::Path; + +/// In-memory card registry loaded from data/cards/*.json +pub struct CardDb { + pub cards: HashMap, +} + +impl CardDb { + pub fn load(data_dir: &str) -> Result { + let cards_dir = Path::new(data_dir).join("cards"); + let mut cards = HashMap::new(); + + if !cards_dir.exists() { + tracing::warn!("Card data directory not found: {:?}", cards_dir); + return Ok(Self { cards }); + } + + for entry in std::fs::read_dir(&cards_dir) + .with_context(|| format!("reading cards dir {:?}", cards_dir))? + { + let entry = entry?; + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + let content = std::fs::read_to_string(&path) + .with_context(|| format!("reading {:?}", path))?; + let batch: Vec = serde_json::from_str(&content) + .with_context(|| format!("parsing {:?}", path))?; + for card in batch { + cards.insert(card.id.clone(), card); + } + } + } + + tracing::info!("Loaded {} card definitions", cards.len()); + Ok(Self { cards }) + } + + pub fn get(&self, id: &str) -> Option<&CardDefinition> { + self.cards.get(id) + } + + #[allow(dead_code)] + pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> { + self.cards + .values() + .filter(|c| { + let r = format!("{:?}", c.rarity).to_lowercase(); + r == rarity || rarity == "any" + }) + .collect() + } + + pub fn all(&self) -> Vec<&CardDefinition> { + self.cards.values().collect() + } + + pub fn by_min_overall(&self, min: u8) -> Vec<&CardDefinition> { + self.cards.values().filter(|c| c.overall >= min).collect() + } +} diff --git a/openfut-core/src/services/club.rs b/openfut-core/src/services/club.rs new file mode 100644 index 0000000..3937a80 --- /dev/null +++ b/openfut-core/src/services/club.rs @@ -0,0 +1,71 @@ +use crate::{ + db::Pool, + error::{AppError, AppResult}, + models::club::Club, +}; +use chrono::Utc; + +pub async fn get_club_by_profile(pool: &Pool, profile_id: &str) -> AppResult { + sqlx::query_as::<_, Club>( + "SELECT id, profile_id, name, coins, level, created_at, updated_at FROM clubs WHERE profile_id = ? LIMIT 1" + ) + .bind(profile_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound("club not found".into())) +} + +pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> { + sqlx::query( + "INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + .bind(&club.id) + .bind(&club.profile_id) + .bind(&club.name) + .bind(club.coins) + .bind(club.level) + .bind(club.created_at) + .bind(club.updated_at) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult { + let now = Utc::now(); + sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?") + .bind(amount) + .bind(now) + .bind(club_id) + .execute(pool) + .await?; + + let new_balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?") + .bind(club_id) + .fetch_one(pool) + .await?; + Ok(new_balance) +} + +pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult { + let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?") + .bind(club_id) + .fetch_one(pool) + .await?; + + if balance < amount { + return Err(AppError::BadRequest(format!( + "insufficient coins: have {balance}, need {amount}" + ))); + } + + let now = Utc::now(); + sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?") + .bind(amount) + .bind(now) + .bind(club_id) + .execute(pool) + .await?; + + Ok(balance - amount) +} diff --git a/openfut-core/src/services/market.rs b/openfut-core/src/services/market.rs new file mode 100644 index 0000000..44235d8 --- /dev/null +++ b/openfut-core/src/services/market.rs @@ -0,0 +1,158 @@ +use crate::{ + db::Pool, + error::{AppError, AppResult}, + models::{ + card::CardDefinition, + market::{BuyListingRequest, MarketListing, MarketListingWithCard, SellCardRequest}, + }, + services::{card_db::CardDb, club}, +}; +use rand::Rng; +use uuid::Uuid; + +pub async fn get_active_listings( + pool: &Pool, + card_db: &CardDb, +) -> AppResult> { + let listings = sqlx::query_as::<_, MarketListing>( + "SELECT id, card_id, seller_name, price, listed_at, expires_at, sold FROM market_listings WHERE sold = 0 AND expires_at > datetime('now') ORDER BY listed_at DESC LIMIT 50" + ) + .fetch_all(pool) + .await?; + + let with_cards: Vec = listings + .into_iter() + .filter_map(|l| { + card_db.get(&l.card_id).map(|card| MarketListingWithCard { + listing: l, + card: card.clone(), + }) + }) + .collect(); + + Ok(with_cards) +} + +/// Refresh NPC market with random listings from the card pool. +pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult { + sqlx::query("DELETE FROM market_listings WHERE sold = 0") + .execute(pool) + .await?; + + // Build all listings synchronously before any await — drops &CardDefinition refs before first .await + let listings_to_insert: Vec = { + let all_cards: Vec<&CardDefinition> = card_db.all(); + if all_cards.is_empty() { + return Ok(0); + } + let count = 20usize.min(all_cards.len()); + let npc_names = [ + "FC Rovers NPC", + "Market Bot", + "Transfer AI", + "Club Atletico Bot", + "United NPC", + "City AI Club", + ]; + let mut rng = rand::thread_rng(); + all_cards + .iter() + .take(count) + .map(|card| { + let base_price = price_for_card(card.overall); + let price = rng.gen_range((base_price / 2)..=(base_price * 2)).max(100); + let seller = npc_names[rng.gen_range(0..npc_names.len())]; + MarketListing::new(&card.id, seller, price) + }) + .collect() + }; // all_cards refs and ThreadRng both dropped here + + let mut inserted = 0; + for listing in &listings_to_insert { + sqlx::query( + "INSERT INTO market_listings (id, card_id, seller_name, price, listed_at, expires_at, sold) VALUES (?, ?, ?, ?, ?, ?, 0)" + ) + .bind(&listing.id) + .bind(&listing.card_id) + .bind(&listing.seller_name) + .bind(listing.price) + .bind(&listing.listed_at) + .bind(&listing.expires_at) + .execute(pool) + .await?; + + inserted += 1; + } + + Ok(inserted) +} + +pub async fn buy_listing( + pool: &Pool, + card_db: &CardDb, + club_id: &str, + req: &BuyListingRequest, +) -> AppResult { + let listing = sqlx::query_as::<_, MarketListing>( + "SELECT id, card_id, seller_name, price, listed_at, expires_at, sold FROM market_listings WHERE id = ? AND sold = 0" + ) + .bind(&req.listing_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound("listing not found or already sold".into()))?; + + club::spend_coins(pool, club_id, listing.price).await?; + + sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ?") + .bind(&listing.id) + .execute(pool) + .await?; + + let owned_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) VALUES (?, ?, ?, 0, NULL, ?)" + ) + .bind(&owned_id) + .bind(club_id) + .bind(&listing.card_id) + .bind(chrono::Utc::now().to_rfc3339()) + .execute(pool) + .await?; + + card_db + .get(&listing.card_id) + .cloned() + .ok_or_else(|| AppError::NotFound("card definition not found".into())) +} + +pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult { + let _owned = sqlx::query_as::<_, crate::models::card::OwnedCard>( + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ? AND club_id = ?" + ) + .bind(&req.owned_card_id) + .bind(club_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound("owned card not found".into()))?; + + sqlx::query("DELETE FROM owned_cards WHERE id = ?") + .bind(&req.owned_card_id) + .execute(pool) + .await?; + + // Quick-sell price: 40% of requested price + let coins = (req.price as f64 * 0.4) as i64; + let new_balance = club::add_coins(pool, club_id, coins).await?; + Ok(new_balance) +} + +fn price_for_card(overall: u8) -> i64 { + match overall { + 85..=u8::MAX => 50_000, + 80..=84 => 10_000, + 75..=79 => 3_000, + 70..=74 => 1_000, + 65..=69 => 500, + _ => 200, + } +} diff --git a/openfut-core/src/services/match_service.rs b/openfut-core/src/services/match_service.rs new file mode 100644 index 0000000..ee895db --- /dev/null +++ b/openfut-core/src/services/match_service.rs @@ -0,0 +1,106 @@ +use crate::{ + db::Pool, + error::AppResult, + models::{ + match_result::{Match, MatchRewardResult, SubmitMatchRequest}, + objective::ObjectiveDefinition, + }, + services::{club, objective, profile, statistics}, +}; + +const COINS_WIN: i64 = 400; +const COINS_DRAW: i64 = 150; +const COINS_LOSS: i64 = 75; +const XP_WIN: i64 = 200; +const XP_DRAW: i64 = 75; +const XP_LOSS: i64 = 30; + +pub async fn process_match( + pool: &Pool, + profile_id: &str, + club_id: &str, + req: &SubmitMatchRequest, + obj_defs: &[ObjectiveDefinition], +) -> AppResult { + let outcome = if req.goals_for > req.goals_against { + "win" + } else if req.goals_for == req.goals_against { + "draw" + } else { + "loss" + }; + + let (coins, xp) = match outcome { + "win" => (COINS_WIN, XP_WIN), + "draw" => (COINS_DRAW, XP_DRAW), + _ => (COINS_LOSS, XP_LOSS), + }; + + let match_record = Match::new( + profile_id, + &req.squad_id, + &req.opponent_name, + req.goals_for, + req.goals_against, + &req.mode, + coins, + xp, + ); + + sqlx::query( + "INSERT INTO matches (id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind(&match_record.id) + .bind(&match_record.profile_id) + .bind(&match_record.squad_id) + .bind(&match_record.opponent_name) + .bind(match_record.goals_for) + .bind(match_record.goals_against) + .bind(&match_record.outcome) + .bind(match_record.coins_awarded) + .bind(match_record.xp_awarded) + .bind(&match_record.mode) + .bind(&match_record.played_at) + .execute(pool) + .await?; + + club::add_coins(pool, club_id, coins).await?; + profile::add_xp(pool, profile_id, xp).await?; + statistics::record_match( + pool, + profile_id, + outcome, + req.goals_for, + req.goals_against, + coins, + ) + .await?; + + let mut objectives_updated = Vec::new(); + + let mut completed = + objective::increment_metric(pool, profile_id, obj_defs, "matchesplayed", 1).await?; + objectives_updated.append(&mut completed); + + if outcome == "win" { + let mut c = + objective::increment_metric(pool, profile_id, obj_defs, "matcheswon", 1).await?; + objectives_updated.append(&mut c); + } + + let mut c = + objective::increment_metric(pool, profile_id, obj_defs, "goalsscored", req.goals_for) + .await?; + objectives_updated.append(&mut c); + + let mut c = + objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?; + objectives_updated.append(&mut c); + + Ok(MatchRewardResult { + match_record, + coins_awarded: coins, + xp_awarded: xp, + objectives_updated, + }) +} diff --git a/openfut-core/src/services/mod.rs b/openfut-core/src/services/mod.rs new file mode 100644 index 0000000..a0013d1 --- /dev/null +++ b/openfut-core/src/services/mod.rs @@ -0,0 +1,10 @@ +pub mod card_db; +pub mod club; +pub mod market; +pub mod match_service; +pub mod objective; +pub mod pack; +pub mod profile; +pub mod sbc; +pub mod squad; +pub mod statistics; diff --git a/openfut-core/src/services/objective.rs b/openfut-core/src/services/objective.rs new file mode 100644 index 0000000..35ed48c --- /dev/null +++ b/openfut-core/src/services/objective.rs @@ -0,0 +1,122 @@ +use crate::{ + db::Pool, + error::AppResult, + models::objective::{ObjectiveDefinition, ObjectiveProgress, ObjectiveWithProgress}, +}; +use anyhow::Context; +use std::path::Path; +use uuid::Uuid; + +pub fn load_objective_definitions(data_dir: &str) -> anyhow::Result> { + let dir = Path::new(data_dir).join("objectives"); + let mut defs = Vec::new(); + if !dir.exists() { + return Ok(defs); + } + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + let content = + std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?; + let batch: Vec = + serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?; + defs.extend(batch); + } + } + Ok(defs) +} + +pub async fn get_objectives_with_progress( + pool: &Pool, + profile_id: &str, + defs: &[ObjectiveDefinition], +) -> AppResult> { + let progress_rows = sqlx::query_as::<_, ObjectiveProgress>( + "SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ?" + ) + .bind(profile_id) + .fetch_all(pool) + .await?; + + let result = defs + .iter() + .map(|def| { + let prog = progress_rows.iter().find(|p| p.objective_id == def.id); + ObjectiveWithProgress { + definition: def.clone(), + current: prog.map(|p| p.current).unwrap_or(0), + completed: prog.map(|p| p.completed).unwrap_or(false), + claimed: prog.map(|p| p.claimed).unwrap_or(false), + } + }) + .collect(); + + Ok(result) +} + +/// Increment a metric for all objectives that track it. +pub async fn increment_metric( + pool: &Pool, + profile_id: &str, + defs: &[ObjectiveDefinition], + metric: &str, + amount: i64, +) -> AppResult> { + let mut completed_ids = Vec::new(); + + for def in defs + .iter() + .filter(|d| format!("{:?}", d.metric).to_lowercase() == metric) + { + let existing = sqlx::query_as::<_, ObjectiveProgress>( + "SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?" + ) + .bind(profile_id) + .bind(&def.id) + .fetch_optional(pool) + .await?; + + let now = chrono::Utc::now().to_rfc3339(); + + if let Some(prog) = existing { + if prog.completed { + continue; + } + let new_val = (prog.current + amount).min(def.target); + let now_complete = new_val >= def.target; + sqlx::query( + "UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?" + ) + .bind(new_val) + .bind(now_complete) + .bind(&now) + .bind(&prog.id) + .execute(pool) + .await?; + if now_complete { + completed_ids.push(def.id.clone()); + } + } else { + let new_val = amount.min(def.target); + let now_complete = new_val >= def.target; + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)" + ) + .bind(&id) + .bind(profile_id) + .bind(&def.id) + .bind(new_val) + .bind(now_complete) + .bind(&now) + .execute(pool) + .await?; + if now_complete { + completed_ids.push(def.id.clone()); + } + } + } + + Ok(completed_ids) +} diff --git a/openfut-core/src/services/pack.rs b/openfut-core/src/services/pack.rs new file mode 100644 index 0000000..f985f05 --- /dev/null +++ b/openfut-core/src/services/pack.rs @@ -0,0 +1,144 @@ +use crate::{ + db::Pool, + error::{AppError, AppResult}, + models::{ + card::CardDefinition, + pack::{Pack, PackDefinition, PackOpenResult}, + }, + services::card_db::CardDb, +}; +use anyhow::Context; +use rand::seq::SliceRandom; +use std::path::Path; +use uuid::Uuid; + +pub fn load_pack_definitions(data_dir: &str) -> anyhow::Result> { + let dir = Path::new(data_dir).join("packs"); + let mut defs = Vec::new(); + if !dir.exists() { + return Ok(defs); + } + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + let content = + std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?; + let batch: Vec = + serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?; + defs.extend(batch); + } + } + Ok(defs) +} + +pub async fn grant_pack(pool: &Pool, club_id: &str, definition_id: &str) -> AppResult { + let pack = Pack { + id: Uuid::new_v4().to_string(), + club_id: club_id.to_string(), + definition_id: definition_id.to_string(), + opened: false, + created_at: chrono::Utc::now().to_rfc3339(), + }; + sqlx::query( + "INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(&pack.id) + .bind(&pack.club_id) + .bind(&pack.definition_id) + .bind(pack.opened) + .bind(&pack.created_at) + .execute(pool) + .await?; + Ok(pack) +} + +pub async fn open_pack( + pool: &Pool, + card_db: &CardDb, + pack_defs: &[PackDefinition], + club_id: &str, + pack_id: &str, +) -> AppResult { + let pack = sqlx::query_as::<_, Pack>( + "SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE id = ? AND club_id = ?" + ) + .bind(pack_id) + .bind(club_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("pack {pack_id} not found")))?; + + if pack.opened { + return Err(AppError::BadRequest("pack already opened".into())); + } + + let def = pack_defs + .iter() + .find(|d| d.id == pack.definition_id) + .ok_or_else(|| { + AppError::NotFound(format!("pack definition {} not found", pack.definition_id)) + })?; + + let mut cards: Vec = Vec::new(); + + for slot in &def.slots { + let pool_cards: Vec = if let Some(rarities) = &slot.rarity_filter { + card_db + .all() + .into_iter() + .filter(|c| { + let r = format!("{:?}", c.rarity).to_lowercase(); + rarities.contains(&r) + }) + .cloned() + .collect() + } else if let Some(min) = slot.min_overall { + card_db.by_min_overall(min).into_iter().cloned().collect() + } else { + card_db.all().into_iter().cloned().collect() + }; + + // Choose cards synchronously before any awaits so ThreadRng is not held across .await + let chosen: Vec = { + let mut rng = rand::thread_rng(); + (0..slot.count) + .filter_map(|_| pool_cards.choose(&mut rng).cloned()) + .collect() + }; + + for card in chosen { + let owned_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) VALUES (?, ?, ?, 0, NULL, ?)" + ) + .bind(&owned_id) + .bind(club_id) + .bind(&card.id) + .bind(chrono::Utc::now().to_rfc3339()) + .execute(pool) + .await?; + cards.push(card); + } + } + + sqlx::query("UPDATE packs SET opened = 1 WHERE id = ?") + .bind(pack_id) + .execute(pool) + .await?; + + Ok(PackOpenResult { + pack_id: pack_id.to_string(), + cards, + }) +} + +pub async fn get_unopened_packs(pool: &Pool, club_id: &str) -> AppResult> { + let packs = sqlx::query_as::<_, Pack>( + "SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE club_id = ? AND opened = 0" + ) + .bind(club_id) + .fetch_all(pool) + .await?; + Ok(packs) +} diff --git a/openfut-core/src/services/profile.rs b/openfut-core/src/services/profile.rs new file mode 100644 index 0000000..6b30039 --- /dev/null +++ b/openfut-core/src/services/profile.rs @@ -0,0 +1,52 @@ +use crate::{ + db::Pool, + error::{AppError, AppResult}, + models::profile::Profile, +}; +use chrono::Utc; + +pub async fn get_active_profile(pool: &Pool) -> AppResult { + sqlx::query_as::<_, Profile>( + "SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1" + ) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into())) +} + +pub async fn create_profile(pool: &Pool, username: &str) -> AppResult { + let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles") + .fetch_one(pool) + .await?; + if existing > 0 { + return Err(AppError::Conflict( + "a profile already exists; OpenFUT is single-player only".into(), + )); + } + + let profile = Profile::new(username); + sqlx::query( + "INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(&profile.id) + .bind(&profile.username) + .bind(profile.level) + .bind(profile.xp) + .bind(profile.created_at) + .bind(profile.updated_at) + .execute(pool) + .await?; + + Ok(profile) +} + +pub async fn add_xp(pool: &Pool, profile_id: &str, xp: i64) -> AppResult<()> { + let now = Utc::now(); + sqlx::query("UPDATE profiles SET xp = xp + ?, updated_at = ? WHERE id = ?") + .bind(xp) + .bind(now) + .bind(profile_id) + .execute(pool) + .await?; + Ok(()) +} diff --git a/openfut-core/src/services/sbc.rs b/openfut-core/src/services/sbc.rs new file mode 100644 index 0000000..fdc3eb6 --- /dev/null +++ b/openfut-core/src/services/sbc.rs @@ -0,0 +1,166 @@ +use crate::{ + db::Pool, + error::{AppError, AppResult}, + models::{ + card::CardDefinition, + objective::ObjectiveDefinition, + sbc::{SbcDefinition, SbcResult, SubmitSbcRequest}, + }, + services::{card_db::CardDb, club, objective, statistics}, +}; +use anyhow::Context; +use std::path::Path; +use uuid::Uuid; + +pub fn load_sbc_definitions(data_dir: &str) -> anyhow::Result> { + let dir = Path::new(data_dir).join("sbcs"); + let mut defs = Vec::new(); + if !dir.exists() { + return Ok(defs); + } + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + let content = + std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?; + let batch: Vec = + serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?; + defs.extend(batch); + } + } + Ok(defs) +} + +pub async fn submit_sbc( + pool: &Pool, + card_db: &CardDb, + sbc_defs: &[SbcDefinition], + obj_defs: &[ObjectiveDefinition], + profile_id: &str, + club_id: &str, + req: &SubmitSbcRequest, +) -> AppResult { + let def = sbc_defs + .iter() + .find(|d| d.id == req.sbc_id) + .ok_or_else(|| AppError::NotFound(format!("SBC {} not found", req.sbc_id)))?; + + // Resolve cards from DB + let mut cards: Vec = Vec::new(); + for owned_id in &req.owned_card_ids { + let row = sqlx::query_as::<_, crate::models::card::OwnedCard>( + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ? AND club_id = ?" + ) + .bind(owned_id) + .bind(club_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("owned card {owned_id} not found")))?; + + let card = card_db.get(&row.card_id).ok_or_else(|| { + AppError::NotFound(format!("card definition {} not found", row.card_id)) + })?; + cards.push(card.clone()); + } + + let (passed, failures) = validate_sbc(def, &cards); + + if passed { + // Consume cards + for owned_id in &req.owned_card_ids { + sqlx::query("DELETE FROM owned_cards WHERE id = ?") + .bind(owned_id) + .execute(pool) + .await?; + } + + // Record submission + let sub_id = Uuid::new_v4().to_string(); + let card_ids_json = serde_json::to_string(&req.owned_card_ids)?; + sqlx::query( + "INSERT INTO sbc_submissions (id, profile_id, sbc_id, submitted_card_ids, passed, submitted_at) VALUES (?, ?, ?, ?, 1, ?)" + ) + .bind(&sub_id) + .bind(profile_id) + .bind(&req.sbc_id) + .bind(&card_ids_json) + .bind(chrono::Utc::now().to_rfc3339()) + .execute(pool) + .await?; + + // Grant reward + if def.reward.coins > 0 { + club::add_coins(pool, club_id, def.reward.coins).await?; + } + if let Some(pack_id) = &def.reward.pack_id { + crate::services::pack::grant_pack(pool, club_id, pack_id).await?; + } + + statistics::increment_sbcs_completed(pool, profile_id).await?; + objective::increment_metric(pool, profile_id, obj_defs, "sbcscompleted", 1).await?; + + Ok(SbcResult { + passed: true, + failures: vec![], + reward: Some(def.reward.clone()), + }) + } else { + Ok(SbcResult { + passed: false, + failures, + reward: None, + }) + } +} + +fn validate_sbc(def: &SbcDefinition, cards: &[CardDefinition]) -> (bool, Vec) { + let mut failures = Vec::new(); + let req = &def.requirements; + + if cards.len() != req.squad_size as usize { + failures.push(format!( + "need exactly {} cards, got {}", + req.squad_size, + cards.len() + )); + return (false, failures); + } + + if let Some(min) = req.min_overall { + let avg = cards.iter().map(|c| c.overall as i64).sum::() / cards.len() as i64; + if avg < min as i64 { + failures.push(format!("average overall {avg} < required {min}")); + } + } + + if !req.required_leagues.is_empty() { + for league in &req.required_leagues { + if !cards.iter().any(|c| &c.league == league) { + failures.push(format!("need at least one player from league {league}")); + } + } + } + + if !req.required_nations.is_empty() { + for nation in &req.required_nations { + if !cards.iter().any(|c| &c.nation == nation) { + failures.push(format!("need at least one player from nation {nation}")); + } + } + } + + if let Some(min_same_league) = req.min_players_from_same_league { + let league_counts: std::collections::HashMap<&str, usize> = + cards.iter().fold(Default::default(), |mut m, c| { + *m.entry(c.league.as_str()).or_default() += 1; + m + }); + let max = league_counts.values().copied().max().unwrap_or(0); + if max < min_same_league as usize { + failures.push(format!("need {min_same_league} players from same league")); + } + } + + (failures.is_empty(), failures) +} diff --git a/openfut-core/src/services/squad.rs b/openfut-core/src/services/squad.rs new file mode 100644 index 0000000..61d02ad --- /dev/null +++ b/openfut-core/src/services/squad.rs @@ -0,0 +1,93 @@ +use crate::{ + db::Pool, + error::{AppError, AppResult}, + models::squad::{SaveSquadRequest, Squad, SquadPlayer}, +}; +use uuid::Uuid; + +pub async fn get_squad(pool: &Pool, club_id: &str) -> AppResult<(Squad, Vec)> { + let squad = sqlx::query_as::<_, Squad>( + "SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE club_id = ? ORDER BY created_at DESC LIMIT 1" + ) + .bind(club_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound("no squad found for this club".into()))?; + + let players = sqlx::query_as::<_, SquadPlayer>( + "SELECT id, squad_id, owned_card_id, position_index, is_captain, is_on_bench FROM squad_players WHERE squad_id = ?" + ) + .bind(&squad.id) + .fetch_all(pool) + .await?; + + Ok((squad, players)) +} + +pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult { + let now = chrono::Utc::now().to_rfc3339(); + + let existing = sqlx::query_scalar::<_, Option>( + "SELECT id FROM squads WHERE club_id = ? ORDER BY created_at DESC LIMIT 1", + ) + .bind(club_id) + .fetch_one(pool) + .await?; + + let squad_id = if let Some(id) = existing { + sqlx::query("UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?") + .bind(req.name.as_deref()) + .bind(req.formation.as_deref()) + .bind(&now) + .bind(&id) + .execute(pool) + .await?; + sqlx::query("DELETE FROM squad_players WHERE squad_id = ?") + .bind(&id) + .execute(pool) + .await?; + id + } else { + let squad = Squad::new( + club_id, + req.name.as_deref().unwrap_or("My Squad"), + req.formation.as_deref().unwrap_or("4-4-2"), + ); + sqlx::query( + "INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(&squad.id) + .bind(&squad.club_id) + .bind(&squad.name) + .bind(&squad.formation) + .bind(&squad.created_at) + .bind(&squad.updated_at) + .execute(pool) + .await?; + squad.id + }; + + for player in &req.players { + let sp_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(&sp_id) + .bind(&squad_id) + .bind(&player.owned_card_id) + .bind(player.position_index) + .bind(player.is_captain) + .bind(player.is_on_bench) + .execute(pool) + .await?; + } + + let squad = sqlx::query_as::<_, Squad>( + "SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE id = ?", + ) + .bind(&squad_id) + .fetch_one(pool) + .await?; + + Ok(squad) +} diff --git a/openfut-core/src/services/statistics.rs b/openfut-core/src/services/statistics.rs new file mode 100644 index 0000000..84633e3 --- /dev/null +++ b/openfut-core/src/services/statistics.rs @@ -0,0 +1,90 @@ +use crate::{db::Pool, error::AppResult, models::statistics::Statistics}; + +pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult { + let existing = sqlx::query_as::<_, Statistics>( + "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, updated_at FROM statistics WHERE profile_id = ?" + ) + .bind(profile_id) + .fetch_optional(pool) + .await?; + + if let Some(s) = existing { + return Ok(s); + } + + let stats = Statistics::new(profile_id); + sqlx::query( + "INSERT INTO statistics (profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, updated_at) VALUES (?, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?)" + ) + .bind(profile_id) + .bind(&stats.updated_at) + .execute(pool) + .await?; + + Ok(stats) +} + +pub async fn record_match( + pool: &Pool, + profile_id: &str, + outcome: &str, + goals_for: i64, + goals_against: i64, + coins: i64, +) -> AppResult<()> { + get_or_create(pool, profile_id).await?; + let now = chrono::Utc::now().to_rfc3339(); + + let (w, d, l) = match outcome { + "win" => (1i64, 0i64, 0i64), + "draw" => (0, 1, 0), + _ => (0, 0, 1), + }; + + sqlx::query( + "UPDATE statistics SET + matches_played = matches_played + 1, + matches_won = matches_won + ?, + matches_drawn = matches_drawn + ?, + matches_lost = matches_lost + ?, + goals_scored = goals_scored + ?, + goals_conceded = goals_conceded + ?, + total_coins_earned = total_coins_earned + ?, + updated_at = ? + WHERE profile_id = ?", + ) + .bind(w) + .bind(d) + .bind(l) + .bind(goals_for) + .bind(goals_against) + .bind(coins) + .bind(&now) + .bind(profile_id) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> { + get_or_create(pool, profile_id).await?; + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query("UPDATE statistics SET packs_opened = packs_opened + 1, updated_at = ? WHERE profile_id = ?") + .bind(&now) + .bind(profile_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn increment_sbcs_completed(pool: &Pool, profile_id: &str) -> AppResult<()> { + get_or_create(pool, profile_id).await?; + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query("UPDATE statistics SET sbcs_completed = sbcs_completed + 1, updated_at = ? WHERE profile_id = ?") + .bind(&now) + .bind(profile_id) + .execute(pool) + .await?; + Ok(()) +} diff --git a/openfut-core/tests/integration_test.rs b/openfut-core/tests/integration_test.rs new file mode 100644 index 0000000..33657e8 --- /dev/null +++ b/openfut-core/tests/integration_test.rs @@ -0,0 +1,162 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use serde_json::Value; +use tower::ServiceExt; + +// Bring in the crate modules via the library path +// We test by spinning up the full app against an in-memory SQLite database. + +async fn build_test_app() -> axum::Router { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations"); + + // Seed with test card + pack data + let data_dir = "data"; + openfut_core::build_app(pool, data_dir) + .await + .expect("app build") +} + +#[tokio::test] +async fn test_health_endpoint() { + let app = build_test_app().await; + + let resp = app + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ok"); +} + +#[tokio::test] +async fn test_auth_local_creates_profile_and_club() { + let app = build_test_app().await; + + let payload = serde_json::json!({ "username": "TestPlayer" }); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/local") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["profile"]["username"], "TestPlayer"); + assert_eq!(json["club"]["name"], "OpenFUT FC"); + assert_eq!(json["club"]["coins"], 5000); +} + +#[tokio::test] +async fn test_profile_not_found_before_auth() { + let app = build_test_app().await; + + let resp = app + .oneshot( + Request::builder() + .uri("/profile") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_match_result_awards_coins() { + let app = build_test_app().await; + + // First create a profile + let auth_resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/local") + .header("content-type", "application/json") + .body(Body::from(r#"{"username":"MatchPlayer"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(auth_resp.status(), StatusCode::OK); + + // Submit a match win + let payload = serde_json::json!({ + "squad_id": "dummy-squad", + "opponent_name": "AI Club", + "goals_for": 3, + "goals_against": 1, + "mode": "squad_battles" + }); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/matches/result") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["match_record"]["outcome"], "win"); + assert!(json["coins_awarded"].as_i64().unwrap() > 0); +} + +#[tokio::test] +async fn test_sbc_list_returns_definitions() { + let app = build_test_app().await; + + let resp = app + .oneshot(Request::builder().uri("/sbc").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + assert!(json["sbcs"].is_array()); +}