commit 975dc748a3f78daf700d6155fc5938dbd8e549ab Author: voson Date: Thu Mar 26 23:52:12 2026 +0800 feat: initial commit - Dota 2 egui overlay with GSI, OpenDota API, SQLite cache and recommendation engine Made-with: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..825b4c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Windows +Thumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# Rust / Cargo +target/ + +# Local secrets +.env +.env.* +!.env.example + +# Misc +*.log +temp_*.json +response.json + +.cursor/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c754085 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# 协作说明(面向 AI 与贡献者) + +本文件描述 `dota2-assistant` 的职责边界、代码布局与修改时的注意点,便于在仓库内高效、安全地迭代。 + +## 项目是什么 + +Windows 上的 Dota 2 助手:**透明 egui Overlay** + **本地 axum GSI HTTP 服务**(默认 `:3000`),从官方 Game State Integration 接收状态,结合 **OpenDota API** 与 **SQLite 缓存** 做 BP 与出装推荐。详见 [README.md](README.md)。 + +## 目录与模块 + +| 路径 | 职责 | +|------|------| +| `src/main.rs` | 入口:日志、`AppState`、后台 tokio 线程(GSI + 推荐)、主线程 `egui_overlay::start` | +| `src/gsi/` | GSI HTTP 路由与解析 | +| `src/state/` | 共享应用状态 | +| `src/api/` | OpenDota 客户端与模型 | +| `src/cache/` | SQLite 缓存 | +| `src/recommend/` | 推荐引擎与出装构建 | +| `src/overlay/` | Overlay UI(含 `views/` 下各界面) | +| `src/constants.rs` | 常量 | +| `config/` | GSI 配置模板 | +| `install_gsi.ps1` | Windows 下安装 GSI cfg 的脚本 | + +## 技术约束(修改前必读) + +- **egui 版本**:`Cargo.toml` 中 `egui` 须与 `egui_overlay` 所依赖的 egui 主版本一致(当前注释已说明约束)。 +- **线程模型**:Overlay 在主线程阻塞运行;异步 IO(GSI、HTTP 客户端)在独立 tokio runtime 线程。跨线程共享使用 `Arc>` 等现有模式,避免在 egui 线程上长跑 `block_on`。 +- **合规与安全**:仅 GSI + 透明窗口;不要引入读进程内存、注入或违反 VAC 期望的行为。若文档或代码暗示「可作弊」类能力,应保持与 README 一致的限制说明。 +- **GSI 数据范围**:仅能使用玩家可见的官方 GSI 字段;不要假设能拿到对方未展示的信息。 + +## 开发命令 + +```powershell +cargo build +cargo build --release +cargo clippy +cargo test # 若有测试 +``` + +本地产物在 `target/`,勿提交;若根目录存在 `.gitignore` 已忽略则保持现状。 + +## 修改原则 + +- 只改任务所需文件与逻辑,避免无关重构、大范围格式化或「顺手」改 README/新增文档(除非任务明确要求)。 +- 命名、错误处理(`anyhow`、`tracing`)、模块划分与现有代码保持一致。 +- 涉及 GSI 的改动时,核对 Dota 侧配置与 `README.md`/`install_gsi.ps1` 是否仍正确;端口等默认值与 `AppState`/设置路径对齐。 + +## 外部依赖 + +- 英雄/对局等数据以 **OpenDota** 为准;网络不可用时依赖本地缓存行为应可接受(可降级、日志清晰)。 +- 运行与调试真实 GSI 需要本机安装 Dota 2 并配置 `gamestate_integration`,不属于纯单元测试必选项。 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ea4753f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2289 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "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 = "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.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "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", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +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 = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx", + "num-traits", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dota2-assistant" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "egui", + "egui_overlay", + "once_cell", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "tokio", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "ecolor" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "egui" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" +dependencies = [ + "ahash", + "emath", + "epaint", + "nohash-hasher", +] + +[[package]] +name = "egui_overlay" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c928290ab3322c8dfa5fa643b4a5d3ac69b54a57f3aa5016816a1c4b74c4094f" +dependencies = [ + "egui", + "egui_render_three_d", + "egui_window_glfw_passthrough", + "raw-window-handle 0.6.2", + "tracing", +] + +[[package]] +name = "egui_render_glow" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f69182a6c61cea98ad625ed2ae90708817016b9be569087834b0b61d4c81a32" +dependencies = [ + "bytemuck", + "egui", + "getrandom 0.2.17", + "glow", + "js-sys", + "raw-window-handle 0.6.2", + "tracing", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "egui_render_three_d" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e5e5f6295065ce95e848898bd7b9cbf986127883f6ac27b083e0bdad0e6fb9a" +dependencies = [ + "egui", + "egui_render_glow", + "raw-window-handle 0.6.2", + "three-d", +] + +[[package]] +name = "egui_window_glfw_passthrough" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d885a0049c676cd5619ce30b90e82cc3293b5bcfd48141e123b666bf7a27e8d6" +dependencies = [ + "egui", + "glfw-passthrough", + "tracing", +] + +[[package]] +name = "emath" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "epaint" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "nohash-hasher", + "parking_lot", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea" + +[[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 = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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-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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "glfw-passthrough" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2872aeb75dc8d6f2b0f0c8f5e30b525f1c647d94799ab426d215b08eea0ac49" +dependencies = [ + "bitflags 1.3.2", + "glfw-sys-passthrough", + "objc", + "raw-window-handle 0.5.2", + "raw-window-handle 0.6.2", + "winapi", +] + +[[package]] +name = "glfw-sys-passthrough" +version = "4.0.4+3.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e9f4bbaf3039648a0fdd382acc5cfcb12066b477ed3fe373a9c25cfd5e2300" +dependencies = [ + "cmake", +] + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +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", +] + +[[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", + "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.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +dependencies = [ + "memchr", + "serde", +] + +[[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.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "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.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[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", + "libm", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[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 = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[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 = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "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 = "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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +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 = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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" +dependencies = [ + "futures-core", +] + +[[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 = "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", +] + +[[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", +] + +[[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 = "three-d" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07faa59e1c1db457218631f1342c43b7c9d0b3657a0864b876a268c49cd0eebe" +dependencies = [ + "cgmath", + "glow", + "instant", + "thiserror 1.0.69", + "three-d-asset", +] + +[[package]] +name = "three-d-asset" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8207e9cdb58d83b02b320412d75ba6503d718f0cafa37de9cf0511839f3d86e1" +dependencies = [ + "cgmath", + "half", + "thiserror 1.0.69", + "web-sys", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +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.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +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.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "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 2.11.0", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[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 = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[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.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +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.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.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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.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 0.52.6", + "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-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +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/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9e86d47 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "dota2-assistant" +version = "0.1.0" +edition = "2021" +description = "Dota 2 hero picker and item build recommendation overlay" + +[[bin]] +name = "dota2-assistant" +path = "src/main.rs" + +[dependencies] +# Async runtime +tokio = { version = "1", features = ["full"] } + +# Overlay UI (transparent GLFW window with egui) +egui_overlay = "0.9" +# Must match exactly the egui version egui_overlay 0.9 uses internally (0.29.x) +egui = "0.29" + +# HTTP server for Dota 2 GSI +axum = { version = "0.7", features = ["json"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["trace"] } + +# HTTP client for OpenDota API +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# SQLite cache +rusqlite = { version = "0.31", features = ["bundled"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +# Error handling & utilities +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } +once_cell = "1" + +[profile.release] +opt-level = 3 +lto = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..69a3af6 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Dota 2 助手 (Dota 2 Assistant) + +Windows 上的 Dota 2 英雄选择与出装推荐 Overlay 应用。实时读取游戏状态,在 BP 阶段提供英雄克制推荐,游戏中提供出装建议。 + +## 功能 + +- **选将阶段 (BP)**: 根据对方已选英雄,通过 OpenDota 数据推荐克制英雄 +- **游戏中**: 根据当前英雄推荐各阶段最优出装路线 +- **游戏内 Overlay**: 透明悬浮窗叠加在 Dota 2 之上,支持鼠标穿透 +- **本地缓存**: 英雄/物品数据缓存 24 小时,减少 API 请求 + +## 安装 + +### 依赖 + +- Windows 10/11 +- Dota 2 (Steam) +- Visual Studio C++ 构建工具 (用于编译) + +### 编译 + +```powershell +# 安装 Rust (如果未安装) +winget install Rustlang.Rustup + +# 克隆并编译 +git clone +cd dota2-assistant +cargo build --release +``` + +### 配置 Dota 2 GSI + +运行安装脚本 (需要知道 Dota 2 安装路径): + +```powershell +.\install_gsi.ps1 +# 或指定自定义路径: +.\install_gsi.ps1 -DotaPath "D:\Steam\steamapps\common\dota 2 beta" +``` + +### 手动配置 + +1. 将 `config\gamestate_integration_assistant.cfg` 复制到: + ``` + \steamapps\common\dota 2 beta\game\dota\cfg\gamestate_integration\ + ``` + +2. 在 Steam 中给 Dota 2 添加启动参数: `-gamestateintegration` + +3. 将 Dota 2 设置为**无边框窗口 (Borderless Window)** 模式 + +## 使用 + +1. 先启动 `dota2-assistant.exe` +2. 再启动 Dota 2 +3. 进入游戏后 Overlay 会自动出现 +4. 按 **F12** 切换显示/隐藏 + +## 架构 + +``` +Dota 2 Game (GSI) + │ HTTP POST (每 0.5 秒) + ▼ +GSI HTTP Server (axum, :3000) + │ + ▼ +State Manager (Arc>) + │ + ├──► 推荐引擎 ──► OpenDota API ──► SQLite 缓存 + │ + └──► egui Overlay UI (主线程, GLFW 透明窗口) +``` + +## 注意事项 + +- **VAC 安全**: 仅使用 GSI 官方接口 + 透明窗口技术,不涉及内存读取或 DLL 注入 +- **GSI 限制**: 只能获取玩家自己可见的信息(不能读取对方未展示的选择) +- **网络依赖**: 首次运行需联网拉取英雄数据,之后可离线使用缓存 + +## 技术栈 + +| 组件 | 技术 | +|------|------| +| 语言 | Rust | +| 异步运行时 | tokio | +| Overlay UI | egui_overlay 0.9 (GLFW + egui 0.29) | +| GSI 服务器 | axum 0.7 | +| API 客户端 | reqwest 0.12 | +| 本地缓存 | rusqlite (SQLite, bundled) | +| 数据源 | OpenDota API (免费) | diff --git a/config/gamestate_integration_assistant.cfg b/config/gamestate_integration_assistant.cfg new file mode 100644 index 0000000..2045904 --- /dev/null +++ b/config/gamestate_integration_assistant.cfg @@ -0,0 +1,18 @@ +"dota2-assistant" +{ + "uri" "http://127.0.0.1:3000" + "timeout" "5.0" + "buffer" "0.1" + "throttle" "0.5" + "heartbeat" "30.0" + "data" + { + "draft" "1" + "hero" "1" + "abilities" "1" + "items" "1" + "map" "1" + "player" "1" + "provider" "1" + } +} diff --git a/install_gsi.ps1 b/install_gsi.ps1 new file mode 100644 index 0000000..4959105 --- /dev/null +++ b/install_gsi.ps1 @@ -0,0 +1,92 @@ +# Dota 2 Assistant - GSI Setup Script +# Run as Administrator if needed (or ensure write access to Dota 2 directory) +# Usage: .\install_gsi.ps1 + +param( + [string]$DotaPath = "C:\Program Files (x86)\Steam\steamapps\common\dota 2 beta" +) + +$ErrorActionPreference = "Stop" + +Write-Host "=== Dota 2 Assistant - GSI Configuration Setup ===" -ForegroundColor Cyan + +# Find Dota 2 installation +$gsiDir = Join-Path $DotaPath "game\dota\cfg\gamestate_integration" + +if (-not (Test-Path (Join-Path $DotaPath "game\dota"))) { + # Try common Steam library paths + $possiblePaths = @( + "C:\Program Files (x86)\Steam\steamapps\common\dota 2 beta", + "D:\Steam\steamapps\common\dota 2 beta", + "C:\Steam\steamapps\common\dota 2 beta" + ) + foreach ($path in $possiblePaths) { + if (Test-Path (Join-Path $path "game\dota")) { + $DotaPath = $path + $gsiDir = Join-Path $DotaPath "game\dota\cfg\gamestate_integration" + break + } + } +} + +if (-not (Test-Path (Join-Path $DotaPath "game\dota"))) { + Write-Host "ERROR: Could not find Dota 2 installation." -ForegroundColor Red + Write-Host "Please specify the path with: .\install_gsi.ps1 -DotaPath 'D:\Steam\steamapps\common\dota 2 beta'" + exit 1 +} + +Write-Host "Found Dota 2 at: $DotaPath" -ForegroundColor Green + +# Create GSI directory +if (-not (Test-Path $gsiDir)) { + New-Item -ItemType Directory -Path $gsiDir -Force | Out-Null + Write-Host "Created directory: $gsiDir" -ForegroundColor Green +} else { + Write-Host "GSI directory already exists: $gsiDir" +} + +# Copy config file +$srcCfg = Join-Path $PSScriptRoot "config\gamestate_integration_assistant.cfg" +$dstCfg = Join-Path $gsiDir "gamestate_integration_assistant.cfg" + +if (Test-Path $srcCfg) { + Copy-Item $srcCfg $dstCfg -Force + Write-Host "Installed GSI config to: $dstCfg" -ForegroundColor Green +} else { + Write-Host "WARNING: Config file not found at $srcCfg" -ForegroundColor Yellow + Write-Host "Writing config directly..." + $cfgContent = @' +"dota2-assistant" +{ + "uri" "http://127.0.0.1:3000" + "timeout" "5.0" + "buffer" "0.1" + "throttle" "0.5" + "heartbeat" "30.0" + "data" + { + "draft" "1" + "hero" "1" + "abilities" "1" + "items" "1" + "map" "1" + "player" "1" + "provider" "1" + } +} +'@ + Set-Content -Path $dstCfg -Value $cfgContent -Encoding UTF8 + Write-Host "Created GSI config at: $dstCfg" -ForegroundColor Green +} + +Write-Host "" +Write-Host "=== IMPORTANT: Next Steps ===" -ForegroundColor Yellow +Write-Host "1. Add '-gamestateintegration' to Dota 2 launch options in Steam:" +Write-Host " Right-click Dota 2 -> Properties -> General -> Launch Options" +Write-Host "" +Write-Host "2. Set Dota 2 to 'Borderless Windowed' mode:" +Write-Host " Dota 2 Settings -> Video -> Display Mode -> Borderless Window" +Write-Host "" +Write-Host "3. Run dota2-assistant.exe BEFORE launching Dota 2" +Write-Host "" +Write-Host "Setup complete!" -ForegroundColor Green diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..09c67fd --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,4 @@ +pub mod models; +pub mod opendota; + +pub use opendota::OpenDotaClient; diff --git a/src/api/models.rs b/src/api/models.rs new file mode 100644 index 0000000..ac8dfa3 --- /dev/null +++ b/src/api/models.rs @@ -0,0 +1,80 @@ +/// Response models for the OpenDota API. +use serde::{Deserialize, Serialize}; + +/// GET /api/heroes — one hero entry +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Hero { + pub id: i32, + pub name: String, + pub localized_name: String, + pub primary_attr: Option, + pub attack_type: Option, + pub roles: Option>, + pub base_health: Option, + pub base_mana: Option, + pub move_speed: Option, +} + +/// GET /api/heroes/{hero_id}/matchups — one matchup entry +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct HeroMatchup { + pub hero_id: i32, + pub games_played: u32, + pub wins: u32, +} + +impl HeroMatchup { + /// Win rate of hero_id AGAINST the queried hero (higher = this is a counter) + pub fn win_rate(&self) -> f32 { + if self.games_played == 0 { + 0.5 + } else { + self.wins as f32 / self.games_played as f32 + } + } +} + +/// GET /api/heroes/{hero_id}/itemPopularity +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ItemPopularity { + pub start_game_items: Option>, + pub early_game_items: Option>, + pub mid_game_items: Option>, + pub late_game_items: Option>, +} + +/// GET /api/heroStats — combined hero stats (win rates, pick rates etc.) +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct HeroStats { + pub id: i32, + pub localized_name: String, + pub pro_win: Option, + pub pro_pick: Option, + #[serde(rename = "1_win")] + pub turbo_win: Option, + #[serde(rename = "1_pick")] + pub turbo_pick: Option, + #[serde(rename = "7_win")] + pub ranked_win: Option, + #[serde(rename = "7_pick")] + pub ranked_pick: Option, +} + +impl HeroStats { + pub fn win_rate_ranked(&self) -> Option { + match (self.ranked_win, self.ranked_pick) { + (Some(w), Some(p)) if p > 0 => Some(w as f32 / p as f32), + _ => None, + } + } +} + +/// One item entry from GET /api/constants/items +/// Keys in the outer map are internal names like "blink", "tango", etc. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ItemConstant { + pub id: Option, + pub dname: Option, + pub cost: Option, + pub img: Option, +} diff --git a/src/api/opendota.rs b/src/api/opendota.rs new file mode 100644 index 0000000..cf56303 --- /dev/null +++ b/src/api/opendota.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use reqwest::Client; +use tracing::{debug, warn}; + +use std::collections::HashMap; +use super::models::{Hero, HeroMatchup, HeroStats, ItemConstant, ItemPopularity}; + +const BASE_URL: &str = "https://api.opendota.com/api"; + +pub struct OpenDotaClient { + client: Client, + api_key: Option, +} + +impl OpenDotaClient { + pub fn new(api_key: Option) -> Self { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent("dota2-assistant/0.1") + .build() + .expect("Failed to build reqwest client"); + + Self { client, api_key } + } + + fn append_key(&self, url: &str) -> String { + if let Some(key) = &self.api_key { + format!("{}?api_key={}", url, key) + } else { + url.to_string() + } + } + + /// Fetch all heroes from /api/heroes + pub async fn get_heroes(&self) -> Result> { + let url = self.append_key(&format!("{}/heroes", BASE_URL)); + debug!("Fetching heroes from: {}", url); + let resp = self.client.get(&url).send().await?; + let heroes: Vec = resp.json().await?; + Ok(heroes) + } + + /// Fetch hero stats (win/pick rates) from /api/heroStats + pub async fn get_hero_stats(&self) -> Result> { + let url = self.append_key(&format!("{}/heroStats", BASE_URL)); + debug!("Fetching hero stats"); + let resp = self.client.get(&url).send().await?; + let stats: Vec = resp.json().await?; + Ok(stats) + } + + /// Fetch hero matchups (counters) from /api/heroes/{hero_id}/matchups + pub async fn get_hero_matchups(&self, hero_id: i32) -> Result> { + let url = self.append_key(&format!("{}/heroes/{}/matchups", BASE_URL, hero_id)); + debug!("Fetching matchups for hero {}", hero_id); + let resp = self.client.get(&url).send().await?; + let matchups: Vec = resp.json().await.map_err(|e| { + warn!("Failed to parse matchups for hero {}: {}", hero_id, e); + e + })?; + Ok(matchups) + } + + /// Fetch item popularity for a hero from /api/heroes/{hero_id}/itemPopularity + pub async fn get_item_popularity(&self, hero_id: i32) -> Result { + let url = + self.append_key(&format!("{}/heroes/{}/itemPopularity", BASE_URL, hero_id)); + debug!("Fetching item popularity for hero {}", hero_id); + let resp = self.client.get(&url).send().await?; + let popularity: ItemPopularity = resp.json().await?; + Ok(popularity) + } + + /// Fetch all item constants from /api/constants/items + /// Returns a map of internal_name -> ItemConstant + pub async fn get_item_constants(&self) -> Result> { + let url = self.append_key(&format!("{}/constants/items", BASE_URL)); + debug!("Fetching item constants"); + let resp = self.client.get(&url).send().await?; + let items: HashMap = resp.json().await?; + Ok(items) + } +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..61809ec --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,102 @@ +use anyhow::Result; +use rusqlite::{params, Connection}; +use std::path::Path; +use std::sync::Mutex; +use tracing::{debug, info}; + +/// TTL for cached API responses: 24 hours +const CACHE_TTL_SECONDS: i64 = 86_400; + +pub struct Cache { + conn: Mutex, +} + +impl Cache { + /// Open (or create) the SQLite database at `db_path`. + pub fn open(db_path: &str) -> Result { + let conn = Connection::open(db_path)?; + let cache = Self { + conn: Mutex::new(conn), + }; + cache.init_schema()?; + Ok(cache) + } + + /// Open an in-memory database (useful for testing or first run). + pub fn open_in_memory() -> Result { + let conn = Connection::open_in_memory()?; + let cache = Self { + conn: Mutex::new(conn), + }; + cache.init_schema()?; + Ok(cache) + } + + fn init_schema(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at INTEGER NOT NULL + );", + )?; + info!("Cache schema initialised"); + Ok(()) + } + + /// Get a cached JSON string if present and not expired. + pub fn get(&self, key: &str) -> Option { + let conn = self.conn.lock().unwrap(); + let now = chrono::Utc::now().timestamp(); + let result: Option = conn + .query_row( + "SELECT value FROM cache WHERE key = ?1 AND (created_at + ?2) > ?3", + params![key, CACHE_TTL_SECONDS, now], + |row| row.get(0), + ) + .ok(); + if result.is_some() { + debug!("Cache hit: {}", key); + } + result + } + + /// Insert or replace a JSON string in the cache. + pub fn set(&self, key: &str, value: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let now = chrono::Utc::now().timestamp(); + conn.execute( + "INSERT OR REPLACE INTO cache (key, value, created_at) VALUES (?1, ?2, ?3)", + params![key, value, now], + )?; + debug!("Cache set: {}", key); + Ok(()) + } + + /// Remove entries older than TTL. + pub fn evict_expired(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let now = chrono::Utc::now().timestamp(); + let deleted = conn.execute( + "DELETE FROM cache WHERE (created_at + ?1) <= ?2", + params![CACHE_TTL_SECONDS, now], + )?; + Ok(deleted) + } + + /// Wipe all cache entries. + pub fn clear(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM cache", [])?; + Ok(()) + } +} + +/// Return the path to use for the application cache database. +pub fn default_cache_path() -> String { + let app_data = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()); + let dir = Path::new(&app_data).join("dota2-assistant"); + std::fs::create_dir_all(&dir).ok(); + dir.join("cache.db").to_string_lossy().to_string() +} diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..3d748ef --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,484 @@ +use std::collections::HashMap; + +/// Map hero_id to Chinese display name. +pub fn chinese_hero_name(hero_id: i32) -> Option<&'static str> { + match hero_id { + 1 => Some("敌法师"), + 2 => Some("斧王"), + 3 => Some("祸乱之源"), + 4 => Some("嗜血狂魔"), + 5 => Some("水晶室女"), + 6 => Some("卓尔游侠"), + 7 => Some("撼地者"), + 8 => Some("主宰"), + 9 => Some("米拉娜"), + 10 => Some("变体精灵"), + 11 => Some("影魔"), + 12 => Some("幻影长矛手"), + 13 => Some("帕克"), + 14 => Some("帕吉"), + 15 => Some("剃刀"), + 16 => Some("沙王"), + 17 => Some("风暴之灵"), + 18 => Some("斯温"), + 19 => Some("小小"), + 20 => Some("复仇之魂"), + 21 => Some("风行者"), + 22 => Some("宙斯"), + 23 => Some("昆卡"), + 25 => Some("莉娜"), + 26 => Some("莱恩"), + 27 => Some("暗影萨满"), + 28 => Some("斯拉达"), + 29 => Some("潮汐猎人"), + 30 => Some("巫医"), + 31 => Some("巫妖"), + 32 => Some("力丸"), + 33 => Some("谜团"), + 34 => Some("修补匠"), + 35 => Some("狙击手"), + 36 => Some("瘟疫法师"), + 37 => Some("术士"), + 38 => Some("兽王"), + 39 => Some("痛苦女王"), + 40 => Some("剧毒术士"), + 41 => Some("虚空假面"), + 42 => Some("冥魂大帝"), + 43 => Some("死亡先知"), + 44 => Some("幻影刺客"), + 45 => Some("帕格纳"), + 46 => Some("圣堂刺客"), + 47 => Some("冥界亚龙"), + 48 => Some("露娜"), + 49 => Some("龙骑士"), + 50 => Some("戴泽"), + 51 => Some("发条技师"), + 52 => Some("拉席克"), + 53 => Some("先知"), + 54 => Some("噬魂鬼"), + 55 => Some("黑暗贤者"), + 56 => Some("克林克兹"), + 57 => Some("全能骑士"), + 58 => Some("魅惑魔女"), + 59 => Some("哈斯卡"), + 60 => Some("暗夜魔王"), + 61 => Some("育母蜘蛛"), + 62 => Some("赏金猎人"), + 63 => Some("编织者"), + 64 => Some("杰奇洛"), + 65 => Some("蝙蝠骑士"), + 66 => Some("陈"), + 67 => Some("幽鬼"), + 68 => Some("远古冰魄"), + 69 => Some("末日使者"), + 70 => Some("熊战士"), + 71 => Some("裂魂人"), + 72 => Some("矮人直升机"), + 73 => Some("炼金术士"), + 74 => Some("祈求者"), + 75 => Some("沉默术士"), + 76 => Some("殁境神蚀者"), + 77 => Some("狼人"), + 78 => Some("酒仙"), + 79 => Some("暗影恶魔"), + 80 => Some("德鲁伊"), + 81 => Some("混沌骑士"), + 82 => Some("米波"), + 83 => Some("树精卫士"), + 84 => Some("食人魔魔法师"), + 85 => Some("不朽尸王"), + 86 => Some("拉比克"), + 87 => Some("干扰者"), + 88 => Some("司夜刺客"), + 89 => Some("娜迦海妖"), + 90 => Some("光之守卫"), + 91 => Some("艾欧"), + 92 => Some("维萨吉"), + 93 => Some("斯拉克"), + 94 => Some("美杜莎"), + 95 => Some("巨魔战将"), + 96 => Some("半人马战行者"), + 97 => Some("马格纳斯"), + 98 => Some("伐木机"), + 99 => Some("钢背兽"), + 100 => Some("巨牙海民"), + 101 => Some("天怒法师"), + 102 => Some("亚巴顿"), + 103 => Some("上古巨神"), + 104 => Some("军团指挥官"), + 105 => Some("工程师"), + 106 => Some("灰烬之灵"), + 107 => Some("大地之灵"), + 108 => Some("孽主"), + 109 => Some("恐怖利刃"), + 110 => Some("凤凰"), + 111 => Some("神谕者"), + 112 => Some("寒冬飞龙"), + 113 => Some("天穹守望者"), + 114 => Some("齐天大圣"), + 119 => Some("邪影芳灵"), + 120 => Some("石鳞剑士"), + 121 => Some("天涯墨客"), + 123 => Some("森海飞霞"), + 126 => Some("虚无之灵"), + 128 => Some("电炎绝手"), + 129 => Some("玛尔斯"), + 131 => Some("马戏团长"), + 135 => Some("破晓辰星"), + 136 => Some("玛西"), + 137 => Some("原始兽"), + 138 => Some("缪尔塔"), + _ => None, + } +} + +/// Map item internal_name (from OpenDota /api/constants/items) to Chinese display name. +pub fn chinese_item_name(internal_name: &str) -> Option<&'static str> { + // Skip all recipe items — they share the base item name + if internal_name.starts_with("recipe_") { + return Some("卷轴"); + } + match internal_name { + // ==== Consumables ==== + "branches" => Some("铁树枝干"), + "tango" => Some("树之祭品"), + "tango_single" => Some("树之祭品(共享)"), + "flask" => Some("治疗药膏"), + "clarity" => Some("净化药水"), + "faerie_fire" => Some("仙灵之火"), + "enchanted_mango" => Some("魔法芒果"), + "ward_observer" => Some("侦察守卫"), + "ward_sentry" => Some("岗哨守卫"), + "ward_dispenser" => Some("侦察和岗哨守卫"), + "smoke_of_deceit" => Some("诡计之雾"), + "dust" => Some("显影之尘"), + "tome_of_knowledge" => Some("知识之书"), + "bottle" => Some("魔瓶"), + "tpscroll" => Some("回城卷轴"), + "cheese" => Some("奶酪"), + "aegis" => Some("不朽之守护"), + "courier" => Some("动物信使"), + "flying_courier" => Some("飞行信使"), + "blood_grenade" => Some("血腥手雷"), + "famango" => Some("治疗莲花"), + "great_famango" => Some("大治疗莲花"), + "greater_famango" => Some("超级治疗莲花"), + "spark_of_courage" => Some("勇气火花"), + "royal_jelly" => Some("蜂王浆"), + + // ==== Basic Components ==== + "quelling_blade" => Some("压制之刃"), + "blades_of_attack" => Some("攻击之爪"), + "broadsword" => Some("阔剑"), + "chainmail" => Some("锁子甲"), + "claymore" => Some("大剑"), + "helm_of_iron_will" => Some("铁意头盔"), + "javelin" => Some("标枪"), + "mithril_hammer" => Some("秘银锤"), + "platemail" => Some("板甲"), + "quarterstaff" => Some("短棍"), + "ring_of_protection" => Some("守护指环"), + "gauntlets" => Some("力量手套"), + "slippers" => Some("敏捷便鞋"), + "mantle" => Some("智力斗篷"), + "circlet" => Some("圆环"), + "belt_of_strength" => Some("力量腰带"), + "boots_of_elves" => Some("精灵布带"), + "robe" => Some("法师长袍"), + "ogre_axe" => Some("食人魔之斧"), + "blade_of_alacrity" => Some("敏捷之刃"), + "staff_of_wizardry" => Some("法师之杖"), + "ultimate_orb" => Some("终极法球"), + "gloves" => Some("加速手套"), + "lifesteal" => Some("吸血面具"), + "ring_of_regen" => Some("回复指环"), + "sobi_mask" => Some("贤者面罩"), + "ring_of_health" => Some("活力之戒"), + "void_stone" => Some("虚无宝石"), + "mystic_staff" => Some("神秘法杖"), + "energy_booster" => Some("能量之球"), + "point_booster" => Some("精气之球"), + "vitality_booster" => Some("活力之球"), + "demon_edge" => Some("恶魔刀锋"), + "eagle" => Some("鹰歌弓"), + "reaver" => Some("掠夺者之斧"), + "relic" => Some("圣者遗物"), + "hyperstone" => Some("振奋宝石"), + "ring_of_tarrasque" => Some("塔拉斯克之戒"), + "cloak" => Some("抗魔斗篷"), + "talisman_of_evasion" => Some("闪避护符"), + "ghost" => Some("幽魂权杖"), + "shadow_amulet" => Some("暗影护符"), + "blight_stone" => Some("枯萎之石"), + "wind_lace" => Some("风灵之纹"), + "gem" => Some("真视宝石"), + "magic_stick" => Some("魔棒"), + "orb_of_venom" => Some("淬毒之珠"), + "orb_of_corrosion" => Some("腐蚀之珠"), + "fluffy_hat" => Some("毛毛帽"), + "infused_raindrop" => Some("凝魂之露"), + "oblivion_staff" => Some("遗忘法杖"), + "pers" => Some("坚韧球"), + "soul_booster" => Some("魂之助力"), + "crown" => Some("王冠"), + "diadem" => Some("王冠"), + "cornucopia" => Some("丰饶之角"), + "tiara_of_selemene" => Some("月神之冕"), + "voodoo_mask" => Some("巫毒面具"), + "blitz_knuckles" => Some("闪电指套"), + "chipped_vest" => Some("碎裂背心"), + "orb_of_frost" => Some("霜冻之珠"), + "splintmail" => Some("铁片甲"), + "shawl" => Some("披肩"), + "wizard_hat" => Some("巫师帽"), + "doubloon" => Some("达布隆金币"), + + // ==== Boots ==== + "boots" => Some("速度之靴"), + "power_treads" => Some("动力鞋"), + "phase_boots" => Some("相位鞋"), + "arcane_boots" => Some("秘法鞋"), + "tranquil_boots" => Some("静谧之鞋"), + "travel_boots" => Some("远行鞋"), + "travel_boots_2" => Some("远行鞋"), + "guardian_greaves" => Some("卫士胫甲"), + "boots_of_bearing" => Some("韧鼓之靴"), + + // ==== Early Game ==== + "magic_wand" => Some("魔杖"), + "bracer" => Some("护腕"), + "wraith_band" => Some("怨灵之带"), + "null_talisman" => Some("空灵挂件"), + "soul_ring" => Some("灵魂之戒"), + "ring_of_basilius" => Some("圣殿指环"), + "headdress" => Some("恢复头巾"), + "buckler" => Some("圆盾"), + "urn_of_shadows" => Some("影之灵龛"), + "medallion_of_courage" => Some("勇气勋章"), + "veil_of_discord" => Some("纷争面纱"), + "pavise" => Some("盾牌"), + "ring_of_aquila" => Some("天鹰之戒"), + "witch_blade" => Some("巫祝之刃"), + "falcon_blade" => Some("猎鹰战刃"), + "hand_of_midas" => Some("迈达斯之手"), + + // ==== Mid Game ==== + "blink" => Some("闪烁匕首"), + "force_staff" => Some("原力法杖"), + "glimmer_cape" => Some("微光披风"), + "rod_of_atos" => Some("阿托斯之棍"), + "cyclone" => Some("风杖"), + "orchid" => Some("紫怨"), + "diffusal_blade" | "diffusal_blade_2" => Some("净魂之刃"), + "manta" => Some("幻影斧"), + "maelstrom" => Some("漩涡"), + "desolator" => Some("黯灭"), + "black_king_bar" => Some("黑皇杖"), + "echo_sabre" => Some("回音战刃"), + "mask_of_madness" => Some("疯狂面具"), + "dragon_lance" => Some("魔龙枪"), + "invis_sword" => Some("影刃"), + "blade_mail" => Some("刃甲"), + "vanguard" => Some("先锋盾"), + "hood_of_defiance" => Some("挑战头巾"), + "pipe" => Some("洞察烟斗"), + "mekansm" => Some("梅肯斯姆"), + "vladmir" => Some("弗拉迪米尔的祭品"), + "spirit_vessel" => Some("魂之挽歌"), + "solar_crest" => Some("炎阳纹章"), + "crimson_guard" => Some("赤红甲"), + "aether_lens" => Some("以太之镜"), + "aghanims_shard" | "aghanims_shard_roshan" => Some("阿哈利姆碎片"), + "holy_locket" => Some("圣洁吊坠"), + "ancient_janggo" => Some("韧鼓"), + "helm_of_the_dominator" => Some("支配头盔"), + "helm_of_the_overlord" => Some("统御头盔"), + "meteor_hammer" => Some("陨星锤"), + "armlet" => Some("臂章"), + "sange" => Some("散夜"), + "yasha" => Some("夜叉"), + "kaya" => Some("慧光"), + "sange_and_yasha" => Some("散夜对剑"), + "kaya_and_sange" => Some("散慧对剑"), + "yasha_and_kaya" => Some("夜慧对剑"), + "eternal_shroud" => Some("永恒之幕"), + "harpoon" => Some("鱼叉"), + "phylactery" => Some("命匣"), + "lesser_crit" => Some("水晶剑"), + "moon_shard" => Some("银月之晶"), + "mage_slayer" => Some("法师克星"), + "bfury" => Some("狂战斧"), + + // ==== Late Game ==== + "ultimate_scepter" => Some("阿哈利姆神杖"), + "ultimate_scepter_2" | "ultimate_scepter_roshan" => Some("阿哈利姆的祝福"), + "monkey_king_bar" => Some("金箍棒"), + "radiance" => Some("辉耀"), + "butterfly" => Some("蝴蝶"), + "greater_crit" => Some("代达罗斯之殇"), + "rapier" => Some("圣剑"), + "abyssal_blade" => Some("深渊之刃"), + "assault" => Some("强袭胸甲"), + "heart" => Some("恐鳌之心"), + "satanic" => Some("撒旦之邪力"), + "skadi" => Some("散华"), + "mjollnir" => Some("雷神之锤"), + "basher" => Some("碎骨锤"), + "refresher" => Some("刷新球"), + "refresher_shard" => Some("刷新球碎片"), + "sheepstick" => Some("邪恶镰刀"), + "shivas_guard" => Some("希瓦的守护"), + "bloodstone" => Some("血精石"), + "sphere" => Some("林肯法球"), + "ethereal_blade" => Some("虚灵刀"), + "octarine_core" => Some("玲珑心"), + "aeon_disk" => Some("永恒之盘"), + "hurricane_pike" => Some("飓风长戟"), + "silver_edge" => Some("白银之锋"), + "bloodthorn" => Some("血棘"), + "nullifier" => Some("否决坠饰"), + "lotus_orb" => Some("清莲宝珠"), + "overwhelming_blink" => Some("盛势闪光"), + "swift_blink" => Some("迅疾闪光"), + "arcane_blink" => Some("秘奥闪光"), + "wind_waker" => Some("风之杖"), + "gungir" => Some("缚灵索"), + "wraith_pact" => Some("怨灵之契"), + "revenants_brooch" => Some("亡灵胸针"), + "heavens_halberd" => Some("天堂之戟"), + "disperser" => Some("散射器"), + "devastator" => Some("灵能之翼"), + "angels_demise" => Some("寇达"), + "dagon" | "dagon_2" | "dagon_3" | "dagon_4" | "dagon_5" => Some("达贡之神力"), + "necronomicon" | "necronomicon_2" | "necronomicon_3" => Some("死灵书"), + "trident" => Some("三叉戟"), + + // ==== Neutral Items ==== + "keen_optic" => Some("敏锐目镜"), + "grove_bow" => Some("林野神弓"), + "quickening_charm" => Some("加速咒符"), + "philosophers_stone" => Some("贤者之石"), + "force_boots" => Some("原力鞋"), + "vampire_fangs" => Some("吸血鬼獠牙"), + "craggy_coat" => Some("崎岖外套"), + "greater_faerie_fire" => Some("大仙灵之火"), + "timeless_relic" => Some("永恒遗物"), + "mirror_shield" => Some("镜盾"), + "elixer" => Some("万灵药"), + "ironwood_tree" => Some("铁木树"), + "pupils_gift" => Some("学徒之礼"), + "tome_of_aghanim" => Some("阿哈利姆之书"), + "repair_kit" => Some("修复工具"), + "mind_breaker" => Some("心智毁灭者"), + "third_eye" => Some("第三只眼"), + "spell_prism" => Some("法术棱镜"), + "princes_knife" => Some("王子短刀"), + "spider_legs" => Some("蛛腿"), + "helm_of_the_undying" => Some("不朽头盔"), + "mango_tree" => Some("芒果树"), + "witless_shako" => Some("愚者军帽"), + "vambrace" => Some("臂铠"), + "imp_claw" => Some("小鬼之爪"), + "flicker" => Some("闪灵"), + "spy_gadget" => Some("望远镜"), + "arcane_ring" => Some("奥术指环"), + "ocean_heart" => Some("海洋之心"), + "broom_handle" => Some("扫帚柄"), + "trusty_shovel" => Some("可靠铁锹"), + "nether_shawl" => Some("幽冥披肩"), + "dragon_scale" => Some("龙鳞"), + "essence_ring" => Some("精华指环"), + "clumsy_net" => Some("笨拙之网"), + "enchanted_quiver" => Some("魔力箭筒"), + "ninja_gear" => Some("忍者装备"), + "illusionsts_cape" => Some("幻术师斗篷"), + "havoc_hammer" => Some("浩劫巨锤"), + "panic_button" => Some("魔法明灯"), + "apex" => Some("巅峰"), + "ballista" => Some("弩炮"), + "woodland_striders" => Some("林地漫游者"), + "demonicon" => Some("亡灵之书"), + "fallen_sky" => Some("陨落天幕"), + "pirate_hat" => Some("海盗帽"), + "ex_machina" => Some("机械降神"), + "faded_broach" => Some("褪色胸针"), + "paladin_sword" => Some("圣骑士之剑"), + "minotaur_horn" => Some("牛头人之角"), + "orb_of_destruction" => Some("毁灭之珠"), + "the_leveller" => Some("平衡者"), + "titan_sliver" => Some("泰坦碎银"), + "bullwhip" => Some("牛鞭"), + "quicksilver_amulet" => Some("水银护符"), + "psychic_headband" => Some("灵能头带"), + "ceremonial_robe" => Some("仪式长袍"), + "book_of_shadows" => Some("暗影之书"), + "giants_ring" => Some("巨人之戒"), + "ascetic_cap" => Some("苦行帽"), + "misericorde" => Some("狠刃"), + "force_field" => Some("奥术师之甲"), + "black_powder_bag" => Some("爆破器"), + "paintball" => Some("精灵手雷"), + "heavy_blade" => Some("巫弊"), + "unstable_wand" => Some("猪猪棒"), + "pogo_stick" => Some("翻滚玩具"), + "trickster_cloak" => Some("骗术斗篷"), + "elven_tunic" => Some("精灵外衣"), + "cloak_of_flames" => Some("烈焰斗篷"), + "possessed_mask" => Some("附魂面具"), + "stormcrafter" => Some("风暴法师"), + "mysterious_hat" => Some("仙灵饰品"), + "penta_edged_sword" => Some("五锋长剑"), + "seeds_of_serenity" => Some("宁静之种"), + "lance_of_pursuit" => Some("追击之矛"), + "occult_bracelet" => Some("神秘手镯"), + "ogre_seal_totem" => Some("食人魔海豹图腾"), + "defiant_shell" => Some("不屈之壳"), + "eye_of_the_vizier" => Some("维齐尔之眼"), + "specialists_array" => Some("专家阵列"), + "dagger_of_ristul" => Some("瑞斯图尔之匕"), + "desolator_2" => Some("黯灭之劫"), + "phoenix_ash" => Some("凤凰灰烬"), + "seer_stone" => Some("先知之石"), + "fusion_rune" => Some("融合符文"), + "stonefeather_satchel" => Some("石羽背包"), + "enchanters_bauble" => Some("附魔者饰品"), + "harmonizer" => Some("调谐器"), + "conjurers_catalyst" => Some("咒术师催化剂"), + "essence_distiller" => Some("精华蒸馏器"), + "consecrated_wraps" => Some("神圣裹布"), + "crellas_crozier" => Some("克蕾拉的权杖"), + "eldwurms_edda" => Some("远古巨龙传说"), + "hydras_breath" => Some("九头蛇之息"), + "spellslinger" => Some("法术投手"), + "prophets_pendulum" => Some("先知的钟摆"), + "foragers_kit" => Some("采集者工具"), + "chasm_stone" => Some("深渊之石"), + "partisans_brand" => Some("游击之印"), + "vindicators_axe" => Some("复仇者之斧"), + "duelist_gloves" => Some("决斗者手套"), + "dandelion_amulet" => Some("蒲公英护符"), + "martyrs_plate" => Some("殉道者之甲"), + "gossamer_cape" => Some("蛛丝披风"), + + _ => None, + } +} + +/// Build a combined item ID → display name map. +/// `api_items`: internal_name -> ItemConstant { id, dname } from OpenDota API +/// Returns: id -> Chinese name (fallback to English dname) +pub fn build_item_display_map( + api_items: &HashMap, +) -> HashMap { + let mut map = HashMap::new(); + for (internal_name, info) in api_items { + if let Some(id) = info.id { + let display = chinese_item_name(internal_name) + .map(|s| s.to_string()) + .or_else(|| info.dname.clone()) + .unwrap_or_else(|| internal_name.replace('_', " ")); + map.insert(id, display); + } + } + map +} diff --git a/src/gsi/mod.rs b/src/gsi/mod.rs new file mode 100644 index 0000000..b738649 --- /dev/null +++ b/src/gsi/mod.rs @@ -0,0 +1,4 @@ +pub mod models; +pub mod server; + +pub use server::build_router; diff --git a/src/gsi/models.rs b/src/gsi/models.rs new file mode 100644 index 0000000..a3c3b33 --- /dev/null +++ b/src/gsi/models.rs @@ -0,0 +1,116 @@ +/// Raw JSON models for Dota 2 Game State Integration (GSI) data. +/// These mirror the exact JSON structure sent by the Dota 2 client. +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Debug, Deserialize, Default)] +pub struct GsiPayload { + pub provider: Option, + pub map: Option, + pub player: Option, + pub hero: Option, + pub abilities: Option, + pub items: Option, + pub draft: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GsiProvider { + pub name: Option, + pub appid: Option, + pub timestamp: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GsiMap { + /// E.g. "DOTA_GAMERULES_STATE_HERO_SELECTION", "DOTA_GAMERULES_STATE_GAME_IN_PROGRESS" + pub game_state: Option, + pub matchid: Option, + pub game_time: Option, + pub clock_time: Option, + pub daytime: Option, + pub nightstalker_night: Option, + pub radiant_score: Option, + pub dire_score: Option, + pub win_team: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GsiPlayer { + /// "radiant" or "dire" + pub team_name: Option, + pub steamid: Option, + pub name: Option, + pub activity: Option, + pub kills: Option, + pub deaths: Option, + pub assists: Option, + pub last_hits: Option, + pub gold: Option, + pub gpm: Option, + pub xpm: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GsiHero { + pub id: Option, + pub name: Option, + pub level: Option, + pub alive: Option, + pub health: Option, + pub max_health: Option, + pub mana: Option, + pub max_mana: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GsiItems { + pub slot0: Option, + pub slot1: Option, + pub slot2: Option, + pub slot3: Option, + pub slot4: Option, + pub slot5: Option, + pub backpack0: Option, + pub backpack1: Option, + pub backpack2: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GsiItem { + pub name: Option, + pub purchaser: Option, + pub can_cast: Option, + pub cooldown: Option, + pub passive: Option, + pub charges: Option, +} + +/// Draft data — present only during DOTA_GAMERULES_STATE_HERO_SELECTION +#[derive(Debug, Deserialize)] +pub struct GsiDraft { + pub activeteam: Option, + pub pick: Option, + pub activeteam_time_remaining: Option, + pub radiant_bonus_time: Option, + pub dire_bonus_time: Option, + /// team2 = Radiant, team3 = Dire + pub team2: Option, + pub team3: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GsiTeamDraft { + pub home_team: Option, + #[serde(flatten)] + pub picks_bans: HashMap, +} + +#[derive(Debug, Deserialize)] +pub struct GsiDraftEntry { + pub id: Option, + #[serde(rename = "class")] + pub hero_class: Option, + pub pick: Option, + pub active: Option, +} diff --git a/src/gsi/server.rs b/src/gsi/server.rs new file mode 100644 index 0000000..bff97f7 --- /dev/null +++ b/src/gsi/server.rs @@ -0,0 +1,130 @@ +use std::sync::{Arc, Mutex}; + +use axum::{ + extract::State, + http::StatusCode, + routing::post, + Router, +}; +use tracing::{info, warn}; + +use crate::state::{AppState, DraftState, GamePhase, HeroState}; +use super::models::GsiPayload; + +pub type SharedState = Arc>; + +/// Build the Axum router for the GSI HTTP endpoint. +pub fn build_router(state: SharedState) -> Router { + Router::new() + .route("/", post(handle_gsi)) + .with_state(state) +} + +/// Handler for POST / — receives the Dota 2 GSI JSON payload. +async fn handle_gsi( + State(state): State, + body: String, +) -> StatusCode { + let payload: GsiPayload = match serde_json::from_str(&body) { + Ok(p) => p, + Err(e) => { + warn!("Failed to parse GSI payload: {}", e); + return StatusCode::BAD_REQUEST; + } + }; + + let mut app = state.lock().unwrap(); + app.is_gsi_connected = true; + app.last_gsi_update = Some(std::time::Instant::now()); + + // ---- game phase + map data ---- + if let Some(map) = &payload.map { + let new_phase = map + .game_state + .as_deref() + .map(GamePhase::from_str) + .unwrap_or(GamePhase::Unknown); + + if new_phase != app.game_phase { + info!("Game phase changed: {:?} -> {:?}", app.game_phase, new_phase); + if matches!(new_phase, GamePhase::Lobby | GamePhase::Unknown) { + app.reset_match_data(); + } + app.game_phase = new_phase; + } + + app.clock_time = map.clock_time; + app.daytime = map.daytime; + app.radiant_score = map.radiant_score; + app.dire_score = map.dire_score; + } + + // ---- player team ---- + if let Some(player) = &payload.player { + let team_id = match player.team_name.as_deref() { + Some("radiant") => Some(2), + Some("dire") => Some(3), + _ => None, + }; + app.draft.player_team = team_id; + } + + // ---- draft ---- + if let Some(draft) = &payload.draft { + let mut new_draft = DraftState { + player_team: app.draft.player_team, + ..Default::default() + }; + + if let Some(team2) = &draft.team2 { + for (key, entry) in &team2.picks_bans { + if key.starts_with("pick") || key.starts_with("ban") { + if let Some(id) = entry.id { + if id > 0 { + if entry.pick.unwrap_or(false) { + new_draft.radiant_picks.push(id); + } else { + new_draft.radiant_bans.push(id); + } + } + } + } + } + } + + if let Some(team3) = &draft.team3 { + for (key, entry) in &team3.picks_bans { + if key.starts_with("pick") || key.starts_with("ban") { + if let Some(id) = entry.id { + if id > 0 { + if entry.pick.unwrap_or(false) { + new_draft.dire_picks.push(id); + } else { + new_draft.dire_bans.push(id); + } + } + } + } + } + } + + app.draft = new_draft; + } + + // ---- hero ---- + if let Some(hero) = &payload.hero { + app.my_hero = HeroState { + hero_id: hero.id, + hero_name: hero.name.clone(), + level: hero.level.unwrap_or(0), + alive: hero.alive.unwrap_or(true), + }; + } + + app.status_message = format!( + "已连接 | 阶段: {}", + app.game_phase.display_name() + ); + + StatusCode::OK +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d73a9a8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,111 @@ +mod api; +mod cache; +mod constants; +mod gsi; +mod overlay; +mod recommend; +mod state; + +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; + +use crate::{ + api::OpenDotaClient, + cache::{default_cache_path, Cache}, + gsi::build_router, + overlay::OverlayApp, + recommend::RecommendEngine, + state::AppState, +}; + +fn main() { + // Logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + info!("Dota 2 Assistant starting..."); + + // Shared application state + let app_state = Arc::new(Mutex::new(AppState::new())); + + // Read initial settings (port may have been changed by user) + let gsi_port = app_state.lock().unwrap().settings.gsi_port; + let api_key = app_state.lock().unwrap().settings.opendota_api_key.clone(); + + // Background thread: tokio runtime for GSI server + recommendation engine + let bg_state = app_state.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("Failed to build tokio runtime"); + + rt.block_on(async_background(bg_state, gsi_port, api_key)); + }); + + // Main thread: egui overlay (blocking, never returns) + let overlay = OverlayApp::new(app_state); + egui_overlay::start(overlay); +} + +/// Runs the GSI server and recommendation refresh loop. +async fn async_background( + app_state: Arc>, + gsi_port: u16, + api_key: Option, +) { + // Build cache + let cache_path = default_cache_path(); + let cache = match Cache::open(&cache_path) { + Ok(c) => c, + Err(e) => { + error!("Failed to open cache (falling back to in-memory): {}", e); + Cache::open_in_memory().expect("In-memory cache must succeed") + } + }; + + // Recommendation engine + let client = OpenDotaClient::new(api_key); + let engine = Arc::new(RecommendEngine::new(client, cache)); + + // Pre-load hero list (best-effort; overlay still works without it) + if let Err(e) = engine.preload_heroes().await { + error!("Failed to preload heroes: {}", e); + app_state.lock().unwrap().status_message = + "英雄数据加载失败,请检查网络连接".to_string(); + } + + // Start GSI HTTP server + let router = build_router(app_state.clone()); + let addr = SocketAddr::from(([127, 0, 0, 1], gsi_port)); + info!("GSI server listening on http://{}", addr); + + let listener = tokio::net::TcpListener::bind(addr) + .await + .expect("Failed to bind GSI port"); + + let engine_clone = engine.clone(); + let state_clone = app_state.clone(); + + // Spawn axum server + tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("GSI server error"); + }); + + // Recommendation refresh loop (every 3 seconds) + let mut interval = tokio::time::interval(Duration::from_secs(3)); + loop { + interval.tick().await; + engine_clone.refresh(&state_clone).await; + } +} diff --git a/src/overlay/mod.rs b/src/overlay/mod.rs new file mode 100644 index 0000000..b0b7233 --- /dev/null +++ b/src/overlay/mod.rs @@ -0,0 +1,309 @@ +pub mod views; + +use std::sync::{Arc, Mutex}; + +use egui_overlay::EguiOverlay; +use egui_overlay::egui_render_three_d::ThreeDBackend; +use egui_overlay::egui_window_glfw_passthrough::GlfwBackend; + +use crate::state::{AppState, GamePhase}; + +/// Active tab in the overlay +#[derive(Debug, Clone, PartialEq)] +enum Tab { + Auto, + Draft, + InGame, + Settings, +} + +impl Default for Tab { + fn default() -> Self { + Tab::Auto + } +} + +/// The main overlay application struct. +pub struct OverlayApp { + state: Arc>, + active_tab: Tab, + fonts_initialized: bool, +} + +impl OverlayApp { + pub fn new(state: Arc>) -> Self { + Self { + state, + active_tab: Tab::Auto, + fonts_initialized: false, + } + } +} + +impl EguiOverlay for OverlayApp { + fn gui_run( + &mut self, + egui_context: &egui::Context, + _default_gfx_backend: &mut ThreeDBackend, + glfw_backend: &mut GlfwBackend, + ) { + // First-frame initialization: load CJK font + expand window to full screen + if !self.fonts_initialized { + setup_cjk_fonts(egui_context); + + // Expand the transparent GLFW window to nearly cover the primary monitor. + // We subtract a few pixels so Windows DWM does NOT treat this as a + // "fullscreen exclusive" surface — that optimization disables the + // transparent framebuffer compositing and turns the background black. + let (w, h) = glfw_backend.glfw.with_primary_monitor(|_, mon| { + mon.and_then(|m| m.get_video_mode()) + .map(|vm| (vm.width as i32, vm.height as i32)) + .unwrap_or((1920, 1080)) + }); + glfw_backend.window.set_size(w - 1, h - 1); + glfw_backend.window.set_pos(0, 0); + + self.fonts_initialized = true; + } + + // Always request repaint so the overlay stays responsive + egui_context.request_repaint(); + + // F11: toggle collapse + if egui_context.input(|i| i.key_pressed(egui::Key::F11)) { + let collapsing_id = egui::Id::new("Dota 2 助手").with("collapsing"); + let mut state = egui::collapsing_header::CollapsingState::load_with_default_open( + egui_context, + collapsing_id, + true, + ); + state.set_open(!state.is_open()); + state.store(egui_context); + } + + let state_snapshot = { + let mut s = self.state.lock().unwrap(); + + // No GSI data for 60s → treat as disconnected and clear stale data + let stale = s + .last_gsi_update + .map(|t| t.elapsed().as_secs() > 60) + .unwrap_or(false); + if stale && s.is_gsi_connected { + s.is_gsi_connected = false; + s.status_message = "等待 Dota 2 连接...".to_string(); + s.reset_match_data(); + } + + ( + s.game_phase.clone(), + s.draft.clone(), + s.my_hero.clone(), + s.recommendations.clone(), + s.settings.clone(), + s.is_gsi_connected, + s.status_message.clone(), + s.last_gsi_update, + s.clock_time, + s.daytime, + s.radiant_score, + s.dire_score, + ) + }; + + let ( + phase, draft, hero, recommendations, mut settings, + is_connected, status, last_update, + clock_time, daytime, radiant_score, dire_score, + ) = state_snapshot; + + // Determine effective tab (Auto mode follows game phase) + let effective_tab = match &self.active_tab { + Tab::Auto => match phase { + GamePhase::Draft => Tab::Draft, + GamePhase::InGame | GamePhase::PreGame => Tab::InGame, + _ => Tab::Draft, + }, + other => other.clone(), + }; + + configure_visuals(egui_context); + + egui::Window::new("Dota 2 助手") + .resizable(true) + .collapsible(true) + .default_width(340.0) + .default_height(600.0) + .min_width(280.0) + .min_height(200.0) + .frame(dark_frame()) + .show(egui_context, |ui| { + // ---- Status bar (always visible, outside scroll) ---- + ui.horizontal(|ui| { + let (dot_color, dot_label) = if is_connected { + (egui::Color32::from_rgb(80, 220, 80), "●") + } else { + (egui::Color32::from_rgb(200, 80, 80), "●") + }; + ui.colored_label(dot_color, dot_label); + ui.colored_label( + egui::Color32::LIGHT_GRAY, + format!("{} | F11 折叠", status), + ); + }); + + if is_connected { + // Game clock + score + if clock_time.is_some() || radiant_score.is_some() { + ui.horizontal(|ui| { + if let Some(t) = clock_time { + let sign = if t < 0 { "-" } else { "" }; + let abs = t.unsigned_abs(); + let m = abs / 60; + let s = abs % 60; + let day_icon = match daytime { + Some(true) => "☀", + Some(false) => "☾", + None => "⏱", + }; + ui.colored_label( + egui::Color32::from_rgb(200, 200, 220), + format!("{} {}{:02}:{:02}", day_icon, sign, m, s), + ); + } + if let (Some(r), Some(d)) = (radiant_score, dire_score) { + ui.colored_label( + egui::Color32::from_rgb(100, 180, 255), + format!("{}", r), + ); + ui.colored_label( + egui::Color32::from_rgb(160, 160, 160), + ":", + ); + ui.colored_label( + egui::Color32::from_rgb(255, 100, 100), + format!("{}", d), + ); + } + }); + } + } + + ui.separator(); + + // ---- Tab bar (always visible, outside scroll) ---- + ui.horizontal(|ui| { + tab_button(ui, &mut self.active_tab, Tab::Auto, "自动"); + tab_button(ui, &mut self.active_tab, Tab::Draft, "选将"); + tab_button(ui, &mut self.active_tab, Tab::InGame, "出装"); + tab_button(ui, &mut self.active_tab, Tab::Settings, "设置"); + }); + + ui.separator(); + + // ---- Scrollable content area ---- + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + match effective_tab { + Tab::Draft | Tab::Auto => { + views::draft::render(ui, &draft, &recommendations); + } + Tab::InGame => { + views::in_game::render(ui, &hero, &recommendations); + } + Tab::Settings => { + views::settings::render(ui, &mut settings); + let mut state = self.state.lock().unwrap(); + state.settings = settings; + } + } + }); + }); + + // Allow click-through when mouse is not over any egui widget + let wants_input = + egui_context.wants_pointer_input() || egui_context.wants_keyboard_input(); + glfw_backend.window.set_mouse_passthrough(!wants_input); + } +} + +/// Load a CJK-capable font from the Windows system fonts directory. +/// Tries Microsoft YaHei first, falls back to SimHei, then NSimSun. +fn setup_cjk_fonts(ctx: &egui::Context) { + let candidates = [ + r"C:\Windows\Fonts\msyh.ttc", // 微软雅黑 (Win 7+, recommended) + r"C:\Windows\Fonts\msyhbd.ttc", // 微软雅黑 Bold + r"C:\Windows\Fonts\simhei.ttf", // 黑体 + r"C:\Windows\Fonts\simsun.ttc", // 宋体 + r"C:\Windows\Fonts\simkai.ttf", // 楷体 + ]; + + let font_data = candidates.iter().find_map(|path| { + std::fs::read(path).ok().map(|data| (*path, data)) + }); + + let Some((path, data)) = font_data else { + tracing::warn!("No CJK system font found; Chinese text will display as squares"); + return; + }; + + tracing::info!("Loading CJK font from: {}", path); + + let mut fonts = egui::FontDefinitions::default(); + fonts.font_data.insert( + "cjk".to_owned(), + egui::FontData::from_owned(data), + ); + + // Append CJK font as fallback for both proportional and monospace families. + // Inserting at position 1 (after the default font) ensures Latin glyphs + // still use the crisp built-in font while CJK glyphs fall through to msyh. + fonts + .families + .entry(egui::FontFamily::Proportional) + .or_default() + .push("cjk".to_owned()); + + fonts + .families + .entry(egui::FontFamily::Monospace) + .or_default() + .push("cjk".to_owned()); + + ctx.set_fonts(fonts); + tracing::info!("CJK font loaded successfully"); +} + +fn tab_button(ui: &mut egui::Ui, active: &mut Tab, tab: Tab, label: &str) { + let is_active = *active == tab; + let text = if is_active { + egui::RichText::new(label) + .color(egui::Color32::WHITE) + .strong() + } else { + egui::RichText::new(label).color(egui::Color32::GRAY) + }; + + if ui.button(text).clicked() { + *active = tab; + } +} + +fn configure_visuals(ctx: &egui::Context) { + let mut visuals = egui::Visuals::dark(); + visuals.window_fill = egui::Color32::from_rgba_premultiplied(20, 20, 30, 210); + visuals.panel_fill = egui::Color32::from_rgba_premultiplied(15, 15, 25, 200); + ctx.set_visuals(visuals); +} + +fn dark_frame() -> egui::Frame { + egui::Frame { + fill: egui::Color32::from_rgba_premultiplied(20, 20, 30, 210), + stroke: egui::Stroke::new(1.0, egui::Color32::from_rgb(60, 70, 100)), + rounding: egui::Rounding::same(6.0), + inner_margin: egui::Margin::same(8.0), + outer_margin: egui::Margin::ZERO, + shadow: egui::epaint::Shadow::NONE, + } +} diff --git a/src/overlay/views/draft.rs b/src/overlay/views/draft.rs new file mode 100644 index 0000000..ecfda40 --- /dev/null +++ b/src/overlay/views/draft.rs @@ -0,0 +1,139 @@ +use egui::Ui; +use crate::constants::chinese_hero_name; +use crate::state::{DraftState, HeroRecommendation, Recommendations}; + +/// Helper: display a hero ID as Chinese name. +fn hero_display(id: i32) -> String { + chinese_hero_name(id) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("#{}", id)) +} + +/// Render the BP (ban-pick) phase UI. +pub fn render( + ui: &mut Ui, + draft: &DraftState, + recommendations: &Recommendations, +) { + // ---- Header ---- + ui.horizontal(|ui| { + ui.heading( + egui::RichText::new("选将推荐") + .color(egui::Color32::from_rgb(255, 200, 50)) + .size(18.0), + ); + if recommendations.is_loading { + ui.spinner(); + } + }); + + ui.separator(); + + // ---- Current draft state ---- + render_draft_summary(ui, draft); + + ui.separator(); + + // ---- Error ---- + if let Some(err) = &recommendations.last_error { + ui.colored_label(egui::Color32::RED, format!("⚠ {}", err)); + ui.separator(); + } + + // ---- Recommendations ---- + if recommendations.hero_picks.is_empty() && !recommendations.is_loading { + if draft.enemy_picks().is_empty() { + ui.colored_label(egui::Color32::GRAY, "等待对方选将..."); + } else { + ui.colored_label(egui::Color32::GRAY, "推荐加载中,请稍候..."); + } + } else { + ui.label( + egui::RichText::new("推荐英雄 (针对敌方阵容):") + .color(egui::Color32::from_rgb(180, 220, 255)) + .size(13.0), + ); + ui.add_space(4.0); + render_hero_list(ui, &recommendations.hero_picks); + } +} + +fn render_draft_summary(ui: &mut Ui, draft: &DraftState) { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.colored_label(egui::Color32::from_rgb(100, 180, 255), "天辉"); + for id in &draft.radiant_picks { + ui.label(format!(" {}", hero_display(*id))); + } + if draft.radiant_picks.is_empty() { + ui.colored_label(egui::Color32::DARK_GRAY, " (空)"); + } + }); + + ui.add_space(20.0); + + ui.vertical(|ui| { + ui.colored_label(egui::Color32::from_rgb(255, 100, 100), "夜魇"); + for id in &draft.dire_picks { + ui.label(format!(" {}", hero_display(*id))); + } + if draft.dire_picks.is_empty() { + ui.colored_label(egui::Color32::DARK_GRAY, " (空)"); + } + }); + + ui.add_space(20.0); + + ui.vertical(|ui| { + ui.colored_label(egui::Color32::GRAY, "禁用"); + let all_bans: Vec = draft + .radiant_bans + .iter() + .chain(draft.dire_bans.iter()) + .copied() + .collect(); + if all_bans.is_empty() { + ui.colored_label(egui::Color32::DARK_GRAY, " (无)"); + } + for id in &all_bans { + ui.colored_label( + egui::Color32::from_rgb(180, 80, 80), + format!(" {}", hero_display(*id)), + ); + } + }); + }); +} + +fn render_hero_list(ui: &mut Ui, heroes: &[HeroRecommendation]) { + egui::Grid::new("hero_recommendations") + .num_columns(3) + .striped(true) + .min_col_width(80.0) + .show(ui, |ui| { + ui.colored_label(egui::Color32::GRAY, "英雄"); + ui.colored_label(egui::Color32::GRAY, "克制优势"); + ui.colored_label(egui::Color32::GRAY, "样本量"); + ui.end_row(); + + for hero in heroes { + // Use Chinese name from constants, fallback to localized_name from API + let name = chinese_hero_name(hero.hero_id) + .unwrap_or(&hero.localized_name); + + let advantage = hero.win_rate_advantage * 100.0; + let color = if advantage > 5.0 { + egui::Color32::from_rgb(100, 220, 100) + } else if advantage > 0.0 { + egui::Color32::from_rgb(200, 220, 100) + } else { + egui::Color32::from_rgb(220, 130, 100) + }; + + ui.label(name); + ui.colored_label(color, format!("{:+.1}%", advantage)); + ui.colored_label(egui::Color32::GRAY, format!("{}", hero.games_played)); + ui.end_row(); + } + }); +} diff --git a/src/overlay/views/in_game.rs b/src/overlay/views/in_game.rs new file mode 100644 index 0000000..3341771 --- /dev/null +++ b/src/overlay/views/in_game.rs @@ -0,0 +1,95 @@ +use egui::Ui; +use crate::constants::chinese_hero_name; +use crate::state::{HeroState, Recommendations}; + +/// Render the in-game view with item build recommendations. +pub fn render(ui: &mut Ui, hero: &HeroState, recommendations: &Recommendations) { + // ---- Header ---- + ui.horizontal(|ui| { + ui.heading( + egui::RichText::new("出装推荐") + .color(egui::Color32::from_rgb(255, 200, 50)) + .size(18.0), + ); + if recommendations.is_loading { + ui.spinner(); + } + }); + + // Hero name (Chinese preferred) + if let Some(name) = &hero.hero_name { + let display = hero + .hero_id + .and_then(chinese_hero_name) + .unwrap_or_else(|| name.strip_prefix("npc_dota_hero_").unwrap_or(name)); + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::LIGHT_GRAY, "英雄: "); + ui.label( + egui::RichText::new(display) + .color(egui::Color32::WHITE) + .strong(), + ); + ui.colored_label(egui::Color32::GRAY, format!("Lv.{}", hero.level)); + if !hero.alive { + ui.colored_label(egui::Color32::RED, " [阵亡]"); + } + }); + } + + ui.separator(); + + // ---- Error ---- + if let Some(err) = &recommendations.last_error { + ui.colored_label(egui::Color32::RED, format!("⚠ {}", err)); + return; + } + + if recommendations.item_builds.is_empty() { + if recommendations.is_loading { + ui.colored_label(egui::Color32::GRAY, "正在加载出装数据..."); + } else if hero.hero_id.is_none() { + ui.colored_label(egui::Color32::GRAY, "等待英雄信息..."); + } else { + ui.colored_label(egui::Color32::GRAY, "暂无出装数据"); + } + return; + } + + // ---- Item phases ---- + for phase in &recommendations.item_builds { + ui.collapsing( + egui::RichText::new(&phase.phase_name) + .color(egui::Color32::from_rgb(255, 210, 80)) + .size(13.0), + |ui| { + egui::Grid::new(format!("items_{}", &phase.phase_name)) + .num_columns(2) + .striped(true) + .min_col_width(100.0) + .show(ui, |ui| { + ui.colored_label(egui::Color32::GRAY, "物品"); + ui.colored_label(egui::Color32::GRAY, "流行度"); + ui.end_row(); + + for item in &phase.items { + ui.label(&item.display_name); + let color = popularity_color(item.popularity_pct); + ui.colored_label(color, format!("{:.0}%", item.popularity_pct)); + ui.end_row(); + } + }); + }, + ); + ui.add_space(2.0); + } +} + +fn popularity_color(pct: f32) -> egui::Color32 { + if pct >= 40.0 { + egui::Color32::from_rgb(100, 230, 100) + } else if pct >= 20.0 { + egui::Color32::from_rgb(220, 220, 80) + } else { + egui::Color32::from_rgb(180, 180, 180) + } +} diff --git a/src/overlay/views/mod.rs b/src/overlay/views/mod.rs new file mode 100644 index 0000000..1bc3e0f --- /dev/null +++ b/src/overlay/views/mod.rs @@ -0,0 +1,3 @@ +pub mod draft; +pub mod in_game; +pub mod settings; diff --git a/src/overlay/views/settings.rs b/src/overlay/views/settings.rs new file mode 100644 index 0000000..646d406 --- /dev/null +++ b/src/overlay/views/settings.rs @@ -0,0 +1,63 @@ +use egui::Ui; +use crate::state::Settings; + +/// Render the settings panel. +pub fn render(ui: &mut Ui, settings: &mut Settings) { + ui.heading( + egui::RichText::new("设置") + .color(egui::Color32::from_rgb(255, 200, 50)) + .size(18.0), + ); + ui.separator(); + + egui::Grid::new("settings_grid") + .num_columns(2) + .spacing([16.0, 8.0]) + .show(ui, |ui| { + ui.label("显示覆盖层:"); + ui.checkbox(&mut settings.show_overlay, ""); + ui.end_row(); + + ui.label("UI 缩放:"); + ui.add(egui::Slider::new(&mut settings.ui_scale, 0.5..=2.0).step_by(0.1)); + ui.end_row(); + + ui.label("GSI 端口:"); + ui.add( + egui::DragValue::new(&mut settings.gsi_port) + .range(1024..=65535u16), + ); + ui.end_row(); + + ui.label("OpenDota API Key:"); + let mut key_buf = settings + .opendota_api_key + .clone() + .unwrap_or_default(); + if ui.text_edit_singleline(&mut key_buf).changed() { + settings.opendota_api_key = if key_buf.is_empty() { + None + } else { + Some(key_buf) + }; + } + ui.end_row(); + + ui.label("Dota 2 安装路径:"); + ui.text_edit_singleline(&mut settings.steam_dota_path); + ui.end_row(); + }); + + ui.separator(); + + ui.collapsing("帮助 & 说明", |ui| { + ui.label("1. 确保 Dota 2 以「无边框窗口」模式运行"); + ui.label("2. 在 Steam 启动参数中添加 -gamestateintegration"); + ui.label("3. 将 GSI 配置文件复制到 Dota 2 的 cfg/gamestate_integration/ 目录"); + ui.add_space(4.0); + ui.colored_label( + egui::Color32::from_rgb(100, 180, 255), + "配置文件位于应用目录下 config/ 文件夹", + ); + }); +} diff --git a/src/recommend/hero_picker.rs b/src/recommend/hero_picker.rs new file mode 100644 index 0000000..0e4855d --- /dev/null +++ b/src/recommend/hero_picker.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::Result; +use tracing::{info, warn}; + +use crate::api::models::{Hero, HeroMatchup}; +use crate::api::OpenDotaClient; +use crate::cache::Cache; +use crate::state::HeroRecommendation; + +const TOP_N: usize = 8; + +/// Recommend hero picks based on the current draft state. +/// +/// Algorithm: +/// 1. For each enemy hero already picked, fetch matchup data (cached). +/// 2. For every candidate hero, compute the average win-rate advantage +/// when played against the enemies. +/// 3. Return the top-N candidates sorted by advantage descending. +pub async fn recommend_picks( + enemy_hero_ids: &[i32], + banned_ids: &[i32], + ally_ids: &[i32], + all_heroes: &[Hero], + client: &OpenDotaClient, + cache: &Arc, +) -> Result> { + if enemy_hero_ids.is_empty() { + info!("No enemy heroes picked yet — returning empty recommendations"); + return Ok(vec![]); + } + + // hero_id -> win rate advantage sum, count + let mut scores: HashMap = HashMap::new(); + + for &enemy_id in enemy_hero_ids { + let matchups = fetch_matchups(enemy_id, client, cache).await?; + for m in &matchups { + // win_rate here is enemy_hero winning against m.hero_id + // We want heroes that BEAT the enemies, so advantage = (m.wins / m.games) means + // enemy wins vs m.hero_id → enemy loses to m.hero_id is what we want. + // OpenDota's /matchups returns: for queried hero vs hero_id, how often queried hero wins. + // So if enemy wins 45% vs hero X → hero X counters the enemy. + let advantage = 0.5 - m.win_rate(); // positive = m.hero_id beats enemy + let entry = scores.entry(m.hero_id).or_insert((0.0, 0)); + entry.0 += advantage; + entry.1 += 1; + } + } + + let already_picked: std::collections::HashSet = enemy_hero_ids + .iter() + .chain(ally_ids.iter()) + .chain(banned_ids.iter()) + .copied() + .collect(); + + let hero_map: HashMap = all_heroes.iter().map(|h| (h.id, h)).collect(); + + let mut recommendations: Vec = scores + .into_iter() + .filter(|(id, _)| !already_picked.contains(id)) + .filter_map(|(id, (sum, count))| { + let hero = hero_map.get(&id)?; + let avg_advantage = if count > 0 { sum / count as f32 } else { 0.0 }; + // Only recommend if we have reasonable sample size + Some(HeroRecommendation { + hero_id: id, + hero_name: hero.name.clone(), + localized_name: hero.localized_name.clone(), + win_rate_advantage: avg_advantage, + games_played: count, + }) + }) + .collect(); + + recommendations.sort_by(|a, b| { + b.win_rate_advantage + .partial_cmp(&a.win_rate_advantage) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + recommendations.truncate(TOP_N); + Ok(recommendations) +} + +/// Fetch matchup data for `hero_id`, using cache when available. +async fn fetch_matchups( + hero_id: i32, + client: &OpenDotaClient, + cache: &Arc, +) -> Result> { + let cache_key = format!("matchups:{}", hero_id); + + if let Some(cached) = cache.get(&cache_key) { + if let Ok(data) = serde_json::from_str::>(&cached) { + return Ok(data); + } + } + + let matchups = client.get_hero_matchups(hero_id).await.map_err(|e| { + warn!("Failed to fetch matchups for hero {}: {}", hero_id, e); + e + })?; + + if let Ok(json) = serde_json::to_string(&matchups) { + cache.set(&cache_key, &json).ok(); + } + + Ok(matchups) +} diff --git a/src/recommend/item_builder.rs b/src/recommend/item_builder.rs new file mode 100644 index 0000000..c9b3409 --- /dev/null +++ b/src/recommend/item_builder.rs @@ -0,0 +1,118 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::Result; +use tracing::warn; + +use crate::api::models::ItemPopularity; +use crate::api::OpenDotaClient; +use crate::cache::Cache; +use crate::state::{ItemBuildPhase, ItemRecommendation}; + +const TOP_ITEMS: usize = 6; + +/// Fetch item build recommendations for `hero_id`, grouped by game phase. +/// `item_names`: mapping from item_id (i32) to display name (Chinese/English). +pub async fn recommend_items( + hero_id: i32, + client: &OpenDotaClient, + cache: &Arc, + item_names: &HashMap, +) -> Result> { + let popularity = fetch_item_popularity(hero_id, client, cache).await?; + let phases = build_phases(popularity, item_names); + Ok(phases) +} + +fn build_phases( + pop: ItemPopularity, + item_names: &HashMap, +) -> Vec { + let mut phases = vec![]; + + let phase_data = [ + ("开局装备", pop.start_game_items), + ("前期装备", pop.early_game_items), + ("中期装备", pop.mid_game_items), + ("后期装备", pop.late_game_items), + ]; + + for (phase_name, items_opt) in phase_data { + let items_map = match items_opt { + Some(m) if !m.is_empty() => m, + _ => continue, + }; + + let total: u32 = items_map.values().sum(); + if total == 0 { + continue; + } + + let mut sorted_items: Vec<(&String, &u32)> = items_map.iter().collect(); + sorted_items.sort_by(|a, b| b.1.cmp(a.1)); + sorted_items.truncate(TOP_ITEMS); + + let recommendations: Vec = sorted_items + .into_iter() + .filter_map(|(id_str, &count)| { + if id_str == "0" || id_str.is_empty() { + return None; + } + let display = resolve_item_name(id_str, item_names); + Some(ItemRecommendation { + item_name: id_str.clone(), + display_name: display, + popularity_pct: count as f32 / total as f32 * 100.0, + }) + }) + .collect(); + + if !recommendations.is_empty() { + phases.push(ItemBuildPhase { + phase_name: phase_name.to_string(), + items: recommendations, + }); + } + } + + phases +} + +/// Look up the display name for an item ID string. +fn resolve_item_name(id_str: &str, item_names: &HashMap) -> String { + if let Ok(id) = id_str.parse::() { + if let Some(name) = item_names.get(&id) { + return name.clone(); + } + } + // Fallback: just show the raw value + format!("物品#{}", id_str) +} + +async fn fetch_item_popularity( + hero_id: i32, + client: &OpenDotaClient, + cache: &Arc, +) -> Result { + let cache_key = format!("items:{}", hero_id); + + if let Some(cached) = cache.get(&cache_key) { + if let Ok(data) = serde_json::from_str::(&cached) { + return Ok(data); + } + } + + let popularity = client + .get_item_popularity(hero_id) + .await + .map_err(|e| { + warn!("Failed to fetch item popularity for hero {}: {}", hero_id, e); + e + })?; + + if let Ok(json) = serde_json::to_string(&popularity) { + cache.set(&cache_key, &json).ok(); + } + + Ok(popularity) +} diff --git a/src/recommend/mod.rs b/src/recommend/mod.rs new file mode 100644 index 0000000..8631b99 --- /dev/null +++ b/src/recommend/mod.rs @@ -0,0 +1,204 @@ +pub mod hero_picker; +pub mod item_builder; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use anyhow::Result; +use tracing::{error, info, warn}; + +use crate::api::models::Hero; +use crate::api::OpenDotaClient; +use crate::cache::Cache; +use crate::constants; +use crate::state::{AppState, GamePhase}; + +pub struct RecommendEngine { + pub client: Arc, + pub cache: Arc, + pub all_heroes: Arc>>, + /// item_id (i32) -> display name (Chinese preferred, English fallback) + pub item_names: Arc>>, +} + +impl RecommendEngine { + pub fn new(client: OpenDotaClient, cache: Cache) -> Self { + Self { + client: Arc::new(client), + cache: Arc::new(cache), + all_heroes: Arc::new(Mutex::new(vec![])), + item_names: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Pre-fetch and cache hero + item data on startup. + pub async fn preload_heroes(&self) -> Result<()> { + info!("Pre-loading hero data from OpenDota..."); + + // ---- Heroes ---- + if let Some(cached) = self.cache.get("heroes_list") { + if let Ok(heroes) = serde_json::from_str::>(&cached) { + info!("Loaded {} heroes from cache", heroes.len()); + *self.all_heroes.lock().unwrap() = heroes; + } + } else { + match self.client.get_heroes().await { + Ok(heroes) => { + info!("Loaded {} heroes from OpenDota", heroes.len()); + if let Ok(json) = serde_json::to_string(&heroes) { + self.cache.set("heroes_list", &json).ok(); + } + *self.all_heroes.lock().unwrap() = heroes; + } + Err(e) => warn!("Failed to load heroes: {}", e), + } + } + + // ---- Item constants ---- + self.preload_items().await; + + Ok(()) + } + + async fn preload_items(&self) { + // Try cache first + if let Some(cached) = self.cache.get("item_constants") { + if let Ok(items) = serde_json::from_str::>(&cached) { + let map = constants::build_item_display_map(&items); + info!("Loaded {} item names from cache", map.len()); + *self.item_names.lock().unwrap() = map; + return; + } + } + + match self.client.get_item_constants().await { + Ok(items) => { + info!("Loaded {} item definitions from OpenDota", items.len()); + if let Ok(json) = serde_json::to_string(&items) { + self.cache.set("item_constants", &json).ok(); + } + let map = constants::build_item_display_map(&items); + info!("Built {} item display names", map.len()); + *self.item_names.lock().unwrap() = map; + } + Err(e) => warn!("Failed to load item constants: {}", e), + } + } + + /// Run a recommendation cycle. Updates `AppState` in-place. + pub async fn refresh(&self, app_state: &Arc>) { + let (phase, enemy_ids, ally_ids, banned_ids, hero_id) = { + let state = app_state.lock().unwrap(); + ( + state.game_phase.clone(), + state.draft.enemy_picks(), + state.draft.ally_picks(), + state.draft + .radiant_bans + .iter() + .chain(&state.draft.dire_bans) + .copied() + .collect::>(), + state.my_hero.hero_id, + ) + }; + + match phase { + GamePhase::Draft => { + self.refresh_draft_recommendations( + app_state, + &enemy_ids, + &ally_ids, + &banned_ids, + ) + .await; + } + GamePhase::InGame | GamePhase::PreGame => { + if let Some(id) = hero_id { + self.refresh_item_recommendations(app_state, id).await; + } + } + _ => {} + } + } + + async fn refresh_draft_recommendations( + &self, + app_state: &Arc>, + enemy_ids: &[i32], + ally_ids: &[i32], + banned_ids: &[i32], + ) { + { + let mut state = app_state.lock().unwrap(); + state.recommendations.is_loading = true; + } + + let heroes = self.all_heroes.lock().unwrap().clone(); + let result = hero_picker::recommend_picks( + enemy_ids, + banned_ids, + ally_ids, + &heroes, + &self.client, + &self.cache, + ) + .await; + + let mut state = app_state.lock().unwrap(); + state.recommendations.is_loading = false; + + match result { + Ok(recs) => { + info!("Updated {} hero recommendations", recs.len()); + state.recommendations.hero_picks = recs; + state.recommendations.last_error = None; + } + Err(e) => { + error!("Hero recommendation failed: {}", e); + state.recommendations.last_error = Some(e.to_string()); + } + } + } + + async fn refresh_item_recommendations( + &self, + app_state: &Arc>, + hero_id: i32, + ) { + { + let state = app_state.lock().unwrap(); + if state.recommendations.for_hero_id == Some(hero_id) + && !state.recommendations.item_builds.is_empty() + { + return; + } + } + + { + let mut state = app_state.lock().unwrap(); + state.recommendations.is_loading = true; + } + + let item_names = self.item_names.lock().unwrap().clone(); + let result = + item_builder::recommend_items(hero_id, &self.client, &self.cache, &item_names) + .await; + + let mut state = app_state.lock().unwrap(); + state.recommendations.is_loading = false; + + match result { + Ok(builds) => { + info!("Updated item builds for hero {}", hero_id); + state.recommendations.item_builds = builds; + state.recommendations.for_hero_id = Some(hero_id); + state.recommendations.last_error = None; + } + Err(e) => { + error!("Item recommendation failed: {}", e); + state.recommendations.last_error = Some(e.to_string()); + } + } + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..e430070 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,190 @@ +use std::time::Instant; + +/// Game phase derived from GSI map.game_state field +#[derive(Debug, Clone, PartialEq)] +pub enum GamePhase { + Lobby, + /// Hero draft / ban-pick phase + Draft, + /// Pre-game (hero selection done, game loading) + PreGame, + /// Active game + InGame, + PostGame, + Unknown, +} + +impl Default for GamePhase { + fn default() -> Self { + Self::Unknown + } +} + +impl GamePhase { + pub fn from_str(s: &str) -> Self { + match s { + "DOTA_GAMERULES_STATE_LOBBY" => GamePhase::Lobby, + "DOTA_GAMERULES_STATE_HERO_SELECTION" => GamePhase::Draft, + "DOTA_GAMERULES_STATE_STRATEGY_TIME" | "DOTA_GAMERULES_STATE_PRE_GAME" => { + GamePhase::PreGame + } + "DOTA_GAMERULES_STATE_GAME_IN_PROGRESS" => GamePhase::InGame, + "DOTA_GAMERULES_STATE_POST_GAME" => GamePhase::PostGame, + _ => GamePhase::Unknown, + } + } + + pub fn display_name(&self) -> &'static str { + match self { + GamePhase::Lobby => "大厅", + GamePhase::Draft => "选将阶段", + GamePhase::PreGame => "准备阶段", + GamePhase::InGame => "游戏中", + GamePhase::PostGame => "游戏结束", + GamePhase::Unknown => "未知", + } + } +} + +/// Draft state tracking all picks and bans +#[derive(Debug, Clone, Default)] +pub struct DraftState { + pub radiant_picks: Vec, + pub dire_picks: Vec, + pub radiant_bans: Vec, + pub dire_bans: Vec, + /// Team player is on (2 = Radiant, 3 = Dire) + pub player_team: Option, +} + +impl DraftState { + pub fn all_picks(&self) -> Vec { + let mut picks = self.radiant_picks.clone(); + picks.extend_from_slice(&self.dire_picks); + picks + } + + pub fn enemy_picks(&self) -> Vec { + match self.player_team { + Some(2) => self.dire_picks.clone(), + Some(3) => self.radiant_picks.clone(), + _ => vec![], + } + } + + pub fn ally_picks(&self) -> Vec { + match self.player_team { + Some(2) => self.radiant_picks.clone(), + Some(3) => self.dire_picks.clone(), + _ => vec![], + } + } +} + +/// A single hero recommendation with counter/synergy analysis +#[derive(Debug, Clone)] +pub struct HeroRecommendation { + pub hero_id: i32, + pub hero_name: String, + pub localized_name: String, + /// Positive means this hero beats the enemy lineup + pub win_rate_advantage: f32, + pub games_played: u32, +} + +/// Items recommended for a specific game phase +#[derive(Debug, Clone)] +pub struct ItemBuildPhase { + pub phase_name: String, + pub items: Vec, +} + +#[derive(Debug, Clone)] +pub struct ItemRecommendation { + pub item_name: String, + pub display_name: String, + pub popularity_pct: f32, +} + +/// All computed recommendations +#[derive(Debug, Clone, Default)] +pub struct Recommendations { + pub hero_picks: Vec, + pub item_builds: Vec, + pub for_hero_id: Option, + pub is_loading: bool, + pub last_error: Option, +} + +/// User-configurable settings +#[derive(Debug, Clone)] +pub struct Settings { + pub show_overlay: bool, + pub ui_scale: f32, + pub steam_dota_path: String, + pub gsi_port: u16, + pub opendota_api_key: Option, +} + +impl Default for Settings { + fn default() -> Self { + Self { + show_overlay: true, + ui_scale: 1.0, + steam_dota_path: default_dota_path(), + gsi_port: 3000, + opendota_api_key: None, + } + } +} + +fn default_dota_path() -> String { + "C:\\Program Files (x86)\\Steam\\steamapps\\common\\dota 2 beta".to_string() +} + +/// Current in-game hero state (my hero) +#[derive(Debug, Clone, Default)] +pub struct HeroState { + pub hero_id: Option, + pub hero_name: Option, + pub level: u32, + pub alive: bool, +} + +/// Full application state shared across threads +#[derive(Debug, Default)] +pub struct AppState { + pub game_phase: GamePhase, + pub draft: DraftState, + pub my_hero: HeroState, + pub recommendations: Recommendations, + pub settings: Settings, + pub last_gsi_update: Option, + pub is_gsi_connected: bool, + pub status_message: String, + /// In-game clock (seconds since 0:00 horn, can be negative during pre-game) + pub clock_time: Option, + pub daytime: Option, + pub radiant_score: Option, + pub dire_score: Option, +} + +impl AppState { + pub fn new() -> Self { + Self { + status_message: "等待 Dota 2 连接...".to_string(), + ..Default::default() + } + } + + /// Called when game resets to lobby/post-game to clear stale data + pub fn reset_match_data(&mut self) { + self.draft = DraftState::default(); + self.my_hero = HeroState::default(); + self.recommendations = Recommendations::default(); + self.clock_time = None; + self.daytime = None; + self.radiant_score = None; + self.dire_score = None; + } +}