feat: initial commit - Dota 2 egui overlay with GSI, OpenDota API, SQLite cache and recommendation engine

Made-with: Cursor
This commit is contained in:
voson
2026-03-26 23:52:12 +08:00
commit 975dc748a3
25 changed files with 4954 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -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/

51
AGENTS.md Normal file
View File

@@ -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 在主线程阻塞运行;异步 IOGSI、HTTP 客户端)在独立 tokio runtime 线程。跨线程共享使用 `Arc<Mutex<AppState>>` 等现有模式,避免在 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`,不属于纯单元测试必选项。

2289
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

46
Cargo.toml Normal file
View File

@@ -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

92
README.md Normal file
View File

@@ -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 <repo>
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` 复制到:
```
<Steam>\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<Mutex<AppState>>)
├──► 推荐引擎 ──► 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 (免费) |

View File

@@ -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"
}
}

92
install_gsi.ps1 Normal file
View File

@@ -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

4
src/api/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod models;
pub mod opendota;
pub use opendota::OpenDotaClient;

80
src/api/models.rs Normal file
View File

@@ -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<String>,
pub attack_type: Option<String>,
pub roles: Option<Vec<String>>,
pub base_health: Option<u32>,
pub base_mana: Option<u32>,
pub move_speed: Option<u32>,
}
/// 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<std::collections::HashMap<String, u32>>,
pub early_game_items: Option<std::collections::HashMap<String, u32>>,
pub mid_game_items: Option<std::collections::HashMap<String, u32>>,
pub late_game_items: Option<std::collections::HashMap<String, u32>>,
}
/// 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<u32>,
pub pro_pick: Option<u32>,
#[serde(rename = "1_win")]
pub turbo_win: Option<u32>,
#[serde(rename = "1_pick")]
pub turbo_pick: Option<u32>,
#[serde(rename = "7_win")]
pub ranked_win: Option<u32>,
#[serde(rename = "7_pick")]
pub ranked_pick: Option<u32>,
}
impl HeroStats {
pub fn win_rate_ranked(&self) -> Option<f32> {
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<i32>,
pub dname: Option<String>,
pub cost: Option<u32>,
pub img: Option<String>,
}

83
src/api/opendota.rs Normal file
View File

@@ -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<String>,
}
impl OpenDotaClient {
pub fn new(api_key: Option<String>) -> 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<Vec<Hero>> {
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<Hero> = resp.json().await?;
Ok(heroes)
}
/// Fetch hero stats (win/pick rates) from /api/heroStats
pub async fn get_hero_stats(&self) -> Result<Vec<HeroStats>> {
let url = self.append_key(&format!("{}/heroStats", BASE_URL));
debug!("Fetching hero stats");
let resp = self.client.get(&url).send().await?;
let stats: Vec<HeroStats> = 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<Vec<HeroMatchup>> {
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<HeroMatchup> = 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<ItemPopularity> {
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<HashMap<String, ItemConstant>> {
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<String, ItemConstant> = resp.json().await?;
Ok(items)
}
}

102
src/cache/mod.rs vendored Normal file
View File

@@ -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<Connection>,
}
impl Cache {
/// Open (or create) the SQLite database at `db_path`.
pub fn open(db_path: &str) -> Result<Self> {
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<Self> {
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<String> {
let conn = self.conn.lock().unwrap();
let now = chrono::Utc::now().timestamp();
let result: Option<String> = 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<usize> {
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()
}

484
src/constants.rs Normal file
View File

@@ -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<String, super::api::models::ItemConstant>,
) -> HashMap<i32, String> {
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
}

4
src/gsi/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod models;
pub mod server;
pub use server::build_router;

116
src/gsi/models.rs Normal file
View File

@@ -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<GsiProvider>,
pub map: Option<GsiMap>,
pub player: Option<GsiPlayer>,
pub hero: Option<GsiHero>,
pub abilities: Option<serde_json::Value>,
pub items: Option<GsiItems>,
pub draft: Option<GsiDraft>,
}
#[derive(Debug, Deserialize)]
pub struct GsiProvider {
pub name: Option<String>,
pub appid: Option<u32>,
pub timestamp: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct GsiMap {
/// E.g. "DOTA_GAMERULES_STATE_HERO_SELECTION", "DOTA_GAMERULES_STATE_GAME_IN_PROGRESS"
pub game_state: Option<String>,
pub matchid: Option<String>,
pub game_time: Option<i32>,
pub clock_time: Option<i32>,
pub daytime: Option<bool>,
pub nightstalker_night: Option<bool>,
pub radiant_score: Option<u32>,
pub dire_score: Option<u32>,
pub win_team: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct GsiPlayer {
/// "radiant" or "dire"
pub team_name: Option<String>,
pub steamid: Option<String>,
pub name: Option<String>,
pub activity: Option<String>,
pub kills: Option<u32>,
pub deaths: Option<u32>,
pub assists: Option<u32>,
pub last_hits: Option<u32>,
pub gold: Option<u32>,
pub gpm: Option<u32>,
pub xpm: Option<u32>,
}
#[derive(Debug, Deserialize)]
pub struct GsiHero {
pub id: Option<i32>,
pub name: Option<String>,
pub level: Option<u32>,
pub alive: Option<bool>,
pub health: Option<u32>,
pub max_health: Option<u32>,
pub mana: Option<u32>,
pub max_mana: Option<u32>,
}
#[derive(Debug, Deserialize)]
pub struct GsiItems {
pub slot0: Option<GsiItem>,
pub slot1: Option<GsiItem>,
pub slot2: Option<GsiItem>,
pub slot3: Option<GsiItem>,
pub slot4: Option<GsiItem>,
pub slot5: Option<GsiItem>,
pub backpack0: Option<GsiItem>,
pub backpack1: Option<GsiItem>,
pub backpack2: Option<GsiItem>,
}
#[derive(Debug, Deserialize)]
pub struct GsiItem {
pub name: Option<String>,
pub purchaser: Option<u32>,
pub can_cast: Option<bool>,
pub cooldown: Option<u32>,
pub passive: Option<bool>,
pub charges: Option<u32>,
}
/// Draft data — present only during DOTA_GAMERULES_STATE_HERO_SELECTION
#[derive(Debug, Deserialize)]
pub struct GsiDraft {
pub activeteam: Option<i32>,
pub pick: Option<bool>,
pub activeteam_time_remaining: Option<f32>,
pub radiant_bonus_time: Option<f32>,
pub dire_bonus_time: Option<f32>,
/// team2 = Radiant, team3 = Dire
pub team2: Option<GsiTeamDraft>,
pub team3: Option<GsiTeamDraft>,
}
#[derive(Debug, Deserialize)]
pub struct GsiTeamDraft {
pub home_team: Option<bool>,
#[serde(flatten)]
pub picks_bans: HashMap<String, GsiDraftEntry>,
}
#[derive(Debug, Deserialize)]
pub struct GsiDraftEntry {
pub id: Option<i32>,
#[serde(rename = "class")]
pub hero_class: Option<String>,
pub pick: Option<bool>,
pub active: Option<bool>,
}

130
src/gsi/server.rs Normal file
View File

@@ -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<Mutex<AppState>>;
/// 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<SharedState>,
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
}

111
src/main.rs Normal file
View File

@@ -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<Mutex<AppState>>,
gsi_port: u16,
api_key: Option<String>,
) {
// 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;
}
}

309
src/overlay/mod.rs Normal file
View File

@@ -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<Mutex<AppState>>,
active_tab: Tab,
fonts_initialized: bool,
}
impl OverlayApp {
pub fn new(state: Arc<Mutex<AppState>>) -> 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,
}
}

139
src/overlay/views/draft.rs Normal file
View File

@@ -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<i32> = 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();
}
});
}

View File

@@ -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)
}
}

3
src/overlay/views/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod draft;
pub mod in_game;
pub mod settings;

View File

@@ -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/ 文件夹",
);
});
}

View File

@@ -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<Cache>,
) -> Result<Vec<HeroRecommendation>> {
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<i32, (f32, u32)> = 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<i32> = enemy_hero_ids
.iter()
.chain(ally_ids.iter())
.chain(banned_ids.iter())
.copied()
.collect();
let hero_map: HashMap<i32, &Hero> = all_heroes.iter().map(|h| (h.id, h)).collect();
let mut recommendations: Vec<HeroRecommendation> = 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<Cache>,
) -> Result<Vec<HeroMatchup>> {
let cache_key = format!("matchups:{}", hero_id);
if let Some(cached) = cache.get(&cache_key) {
if let Ok(data) = serde_json::from_str::<Vec<HeroMatchup>>(&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)
}

View File

@@ -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<Cache>,
item_names: &HashMap<i32, String>,
) -> Result<Vec<ItemBuildPhase>> {
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<i32, String>,
) -> Vec<ItemBuildPhase> {
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<ItemRecommendation> = 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<i32, String>) -> String {
if let Ok(id) = id_str.parse::<i32>() {
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<Cache>,
) -> Result<ItemPopularity> {
let cache_key = format!("items:{}", hero_id);
if let Some(cached) = cache.get(&cache_key) {
if let Ok(data) = serde_json::from_str::<ItemPopularity>(&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)
}

204
src/recommend/mod.rs Normal file
View File

@@ -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<OpenDotaClient>,
pub cache: Arc<Cache>,
pub all_heroes: Arc<Mutex<Vec<Hero>>>,
/// item_id (i32) -> display name (Chinese preferred, English fallback)
pub item_names: Arc<Mutex<HashMap<i32, String>>>,
}
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::<Vec<Hero>>(&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::<HashMap<String, crate::api::models::ItemConstant>>(&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<Mutex<AppState>>) {
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::<Vec<_>>(),
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<Mutex<AppState>>,
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<Mutex<AppState>>,
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());
}
}
}
}

190
src/state.rs Normal file
View File

@@ -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<i32>,
pub dire_picks: Vec<i32>,
pub radiant_bans: Vec<i32>,
pub dire_bans: Vec<i32>,
/// Team player is on (2 = Radiant, 3 = Dire)
pub player_team: Option<i32>,
}
impl DraftState {
pub fn all_picks(&self) -> Vec<i32> {
let mut picks = self.radiant_picks.clone();
picks.extend_from_slice(&self.dire_picks);
picks
}
pub fn enemy_picks(&self) -> Vec<i32> {
match self.player_team {
Some(2) => self.dire_picks.clone(),
Some(3) => self.radiant_picks.clone(),
_ => vec![],
}
}
pub fn ally_picks(&self) -> Vec<i32> {
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<ItemRecommendation>,
}
#[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<HeroRecommendation>,
pub item_builds: Vec<ItemBuildPhase>,
pub for_hero_id: Option<i32>,
pub is_loading: bool,
pub last_error: Option<String>,
}
/// 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<String>,
}
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<i32>,
pub hero_name: Option<String>,
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<Instant>,
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<i32>,
pub daytime: Option<bool>,
pub radiant_score: Option<u32>,
pub dire_score: Option<u32>,
}
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;
}
}