feat: initial commit - Dota 2 egui overlay with GSI, OpenDota API, SQLite cache and recommendation engine
Made-with: Cursor
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
51
AGENTS.md
Normal 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 在主线程阻塞运行;异步 IO(GSI、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
2289
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
Cargo.toml
Normal file
46
Cargo.toml
Normal 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
92
README.md
Normal 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 (免费) |
|
||||
18
config/gamestate_integration_assistant.cfg
Normal file
18
config/gamestate_integration_assistant.cfg
Normal 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
92
install_gsi.ps1
Normal 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
4
src/api/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod models;
|
||||
pub mod opendota;
|
||||
|
||||
pub use opendota::OpenDotaClient;
|
||||
80
src/api/models.rs
Normal file
80
src/api/models.rs
Normal 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
83
src/api/opendota.rs
Normal 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
102
src/cache/mod.rs
vendored
Normal 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
484
src/constants.rs
Normal 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
4
src/gsi/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod models;
|
||||
pub mod server;
|
||||
|
||||
pub use server::build_router;
|
||||
116
src/gsi/models.rs
Normal file
116
src/gsi/models.rs
Normal 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
130
src/gsi/server.rs
Normal 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
111
src/main.rs
Normal 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
309
src/overlay/mod.rs
Normal 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
139
src/overlay/views/draft.rs
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
95
src/overlay/views/in_game.rs
Normal file
95
src/overlay/views/in_game.rs
Normal 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
3
src/overlay/views/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod draft;
|
||||
pub mod in_game;
|
||||
pub mod settings;
|
||||
63
src/overlay/views/settings.rs
Normal file
63
src/overlay/views/settings.rs
Normal 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/ 文件夹",
|
||||
);
|
||||
});
|
||||
}
|
||||
112
src/recommend/hero_picker.rs
Normal file
112
src/recommend/hero_picker.rs
Normal 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)
|
||||
}
|
||||
118
src/recommend/item_builder.rs
Normal file
118
src/recommend/item_builder.rs
Normal 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
204
src/recommend/mod.rs
Normal 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
190
src/state.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user