数据提供模块编写

This commit is contained in:
hxuanyu 2025-04-02 09:35:00 +08:00
parent 125dfc3507
commit b014895061
16 changed files with 3979 additions and 0 deletions

149
README.md Normal file
View File

@ -0,0 +1,149 @@
# 云顶之弈阵容推荐器
## 项目概述
本项目是一个基于Python的云顶之弈阵容推荐工具通过Riot官方提供的游戏数据职业、特质、棋子实现自动化的阵容分析与推荐。项目具有灵活的评分机制能够根据用户需求如指定人口、羁绊、棋子等智能推荐最优阵容。
## 主要功能
1. **数据提供模块**
- 从官方接口获取最新游戏数据并支持本地缓存
- 提供丰富的数据查询API如获取羁绊下的棋子、棋子的羁绊详情等
- 高效的数据处理与缓存机制
2. **阵容推荐模块** (开发中)
- 根据用户指定的人口、必须羁绊、必选棋子自动生成阵容
- 支持多种约束条件组合,如指定多个羁绊、多个棋子等
3. **阵容评分模块** (开发中)
- 综合考虑羁绊数量、等级、棋子费用等因素
- 可配置的评分权重,支持自定义评分策略
4. **接口模块** (开发中)
- 提供编程接口,方便集成到其他应用中
- 命令行界面,便于直接使用
## 环境与依赖
- **Python版本:** Python 3.8+
- **主要依赖:**
- requests: 用于获取在线数据
- pandas: 用于数据处理
- pyyaml: 用于配置文件解析
- pytest: 用于单元测试
## 项目安装
1. 克隆项目代码
```bash
git clone https://github.com/username/TFT-Strategist.git
cd TFT-Strategist
```
2. 安装依赖
```bash
pip install -r requirements.txt
```
## 快速开始
### 数据提供模块演示
运行以下命令启动数据提供模块的演示程序:
```bash
python main.py data
```
演示程序将展示以下功能:
- 加载最新的游戏数据
- 显示当前版本职业、特质和棋子的基本信息
- 提供交互式查询界面,可以查询羁绊的棋子、棋子的羁绊等信息
## 项目结构
```
TFT-Strategist/
├── data/ # 数据存储目录
│ ├── chess.json # 棋子数据
│ ├── job.json # 职业数据
│ └── race.json # 特质数据
├── src/ # 源代码
│ ├── data_provider/ # 数据提供模块
│ │ ├── __init__.py
│ │ ├── data_loader.py # 数据加载器
│ │ └── data_query_api.py # 数据查询API
│ ├── recommendation/ # 阵容推荐模块 (开发中)
│ ├── scoring/ # 阵容评分模块 (开发中)
│ ├── interface/ # 接口模块 (开发中)
│ ├── __init__.py
│ └── data_provider_demo.py # 数据提供模块演示
├── tests/ # 测试代码
│ └── test_data_provider.py # 数据提供模块测试
├── config/ # 配置文件目录
├── main.py # 主程序入口
├── requirements.txt # 项目依赖
└── README.md # 项目文档
```
## 数据提供模块详解
数据提供模块是整个阵容推荐系统的基础,负责从官方接口获取最新的游戏数据,并提供丰富的查询功能。
### 主要组件
1. **DataLoader**: 负责数据获取与缓存
- 支持从本地或网络加载数据
- 实现数据缓存机制,避免频繁请求
- 自动处理数据更新
2. **DataQueryAPI**: 提供丰富的数据查询接口
- 获取所有职业/特质/棋子数据
- 根据ID或名称查询具体数据
- 查询羁绊下的棋子、棋子的羁绊
- 获取羁绊等级信息
- 查询棋子费用分布
### 使用示例
```python
from src.data_provider import DataQueryAPI
# 初始化API
api = DataQueryAPI()
# 获取所有职业
jobs = api.get_all_jobs()
# 查询特定羁绊的棋子
heavy_warrior = api.get_job_by_name("重装战士")
if heavy_warrior:
chess_list = api.get_chess_by_job(heavy_warrior['jobId'])
print(f"重装战士棋子: {[chess['displayName'] for chess in chess_list]}")
# 查询棋子的羁绊
brand = api.get_chess_by_name("布兰德")
if brand:
synergies = api.get_synergies_of_chess(brand['chessId'])
print(f"布兰德的羁绊: {[synergy['name'] for synergy in synergies]}")
```
## 开发计划
- [x] 数据提供模块
- [ ] 阵容推荐模块
- [ ] 阵容评分模块
- [ ] 接口模块
- [ ] 图形用户界面
## 贡献指南
欢迎贡献代码、报告问题或提出新功能建议请先fork本仓库然后提交拉取请求。
## 许可证
MIT
## 联系方式
如有任何问题或建议请提交issue或联系项目维护者。

2376
data/chess.json Normal file

File diff suppressed because it is too large Load Diff

213
data/job.json Normal file
View File

@ -0,0 +1,213 @@
{
"version": "15.7",
"season": "2025.S14",
"modeId": "1",
"time": "2025-04-01 14:31:07",
"data": [
{
"jobId": "10155",
"name": "超频战士",
"traitId": "10155",
"introduce": "【超频战士】们可以通过该羁绊专有的【超频】属性来给他们的技能提供独特增强。",
"alias": "10155.png",
"level": {
"2": "+1 【超频】属性, 100 生命值",
"3": "+2 【超频】属性, 200 生命值",
"4": "+3 【超频】属性, 350 生命值",
"5": "+4 【超频】属性, 500 生命值"
},
"TFTID": "10155",
"characterid": "TFT14_Supercharge",
"id": "2623",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10155.png",
"job_color_list": "2:1,3:2,4:3,5:3"
},
{
"jobId": "10157",
"name": "重装战士",
"traitId": "10157",
"introduce": "【重装战士】们在有护盾时获得10%伤害减免。\r\n战斗开始时和50%生命值时获得一部分最大生命值的护盾值持续10秒。",
"alias": "10157.png",
"level": {
"2": "16%最大生命值",
"4": "32%最大生命值",
"6": "40%最大生命值在有护盾时获得16% 伤害减免"
},
"TFTID": "10157",
"characterid": "TFT14_Vanguard",
"id": "2624",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10157.png",
"job_color_list": "2:1,4:2,6:3"
},
{
"jobId": "10158",
"name": "杀手",
"traitId": "10158",
"introduce": "【杀手】们获得攻击力和全能吸血。过量治疗将转而为百分比生命值最低的【杀手】回复50%的溢出额。",
"alias": "10158.png",
"level": {
"2": "15% 攻击力, 15% 全能吸血",
"4": "40% 攻击力, 15% 全能吸血",
"6": "70% 攻击力, 20% 全能吸血"
},
"TFTID": "10158",
"characterid": "TFT14_Strong",
"id": "2625",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10158.png",
"job_color_list": "2:1,4:2,6:3"
},
{
"jobId": "10161",
"name": "裁决使",
"traitId": "10161",
"introduce": "【裁决使】的技能可以暴击。他们会获得额外的暴击几率和暴击伤害。\r\n如果目标的生命值低于20%,这个加成的暴击伤害会翻倍。",
"alias": "10161.png",
"level": {
"2": "25% 暴击几率5% 暴击伤害",
"3": "35% 暴击几率10% 暴击伤害",
"4": "45% 暴击几率15% 暴击伤害",
"5": "50% 暴击几率15% 暴击伤害还会获得12%伤害减免"
},
"TFTID": "10161",
"characterid": "TFT14_Cutter",
"id": "2626",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10161.png",
"job_color_list": "2:1,3:2,4:3,5:3"
},
{
"jobId": "10162",
"name": "堡垒卫士",
"traitId": "10162",
"introduce": "你的小队获得10护甲和魔法抗性。【堡垒卫士】们获得更多。\r\n战斗开始后的最初10秒【堡垒卫士】们将其加成提升100%。",
"alias": "10162.png",
"level": {
"2": "18 护甲和魔抗",
"4": "40 护甲和魔抗",
"6": "70 护甲和魔抗 非【堡垒卫士】弈子们获得额外的30 护甲和魔抗。"
},
"TFTID": "10162",
"characterid": "TFT14_Armorclad",
"id": "2627",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10162.png",
"job_color_list": "2:1,4:2,6:3"
},
{
"jobId": "10166",
"name": "迅捷射手",
"traitId": "10166",
"introduce": "你的小队获得10%攻击速度。【迅捷射手】们在每次攻击时获得更多至多可叠加10次。",
"alias": "10166.png",
"level": {
"2": "每层4% 攻击速度",
"4": "每层10% 攻击速度",
"6": "每层24% 攻击速度"
},
"TFTID": "10166",
"characterid": "TFT14_Swift",
"id": "2628",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10166.png",
"job_color_list": "2:1,4:2,6:3"
},
{
"jobId": "10167",
"name": "战略分析师",
"traitId": "10167",
"introduce": "战斗开始时后2排的友军获得伤害增幅。前2排的友军获得伤害减免。【战略分析师】们获得三倍。",
"alias": "10167.png",
"level": {
"2": "6% 伤害增幅4% 伤害减免",
"3": "9% 伤害增幅6% 伤害减免",
"4": "12% 伤害增幅8% 伤害减免",
"5": "15% 伤害增幅10% 伤害减免"
},
"TFTID": "10167",
"characterid": "TFT14_Controller",
"id": "2629",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10167.png",
"job_color_list": "2:1,3:2,4:3,5:3"
},
{
"jobId": "10168",
"name": "斗士",
"traitId": "10168",
"introduce": "你的小队获得100生命值。【斗士】们获得更多。",
"alias": "10168.png",
"level": {
"2": "20% 生命值",
"4": "45% 生命值",
"6": "70% 生命值"
},
"TFTID": "10168",
"characterid": "TFT14_Bruiser",
"id": "2630",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10168.png",
"job_color_list": "2:1,4:2,6:3"
},
{
"jobId": "10169",
"name": "强袭射手",
"traitId": "10169",
"introduce": "【强袭射手】们获得攻击力。在战斗8秒后他们将其加成提升100%。",
"alias": "10169.png",
"level": {
"2": "18% 攻击力",
"4": "35% 攻击力。在8秒后每6秒获得20% 攻击力。"
},
"TFTID": "10169",
"characterid": "TFT14_Marksman",
"id": "2631",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10169.png",
"job_color_list": "2:1,4:3"
},
{
"jobId": "10172",
"name": "高级工程师",
"traitId": "10172",
"introduce": "【高级工程师】们获得法术强度。\r\n他们的技能在命中敌人们后会对这些敌人造成持续3秒的10%伤害削减。",
"alias": "10172.png",
"level": {
"2": "20 法术强度",
"4": "50 法术强度",
"6": "85 法术强度",
"8": "125 法术强度 对命中的敌人们造成18%伤害削减。"
},
"TFTID": "10172",
"characterid": "TFT14_Techie",
"id": "2632",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10172.png",
"job_color_list": "2:1,4:2,6:3,8:4"
},
{
"jobId": "10173",
"name": "人造人",
"traitId": "10173",
"introduce": "每3秒你的小队获得法力值。【人造人】们多获得100%。",
"alias": "10173.png",
"level": {
"2": "5 法力值",
"3": "7 法力值",
"4": "10 法力值"
},
"TFTID": "10173",
"characterid": "TFT14_Thirsty",
"id": "2633",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10173.png",
"job_color_list": "2:1,3:2,4:3"
},
{
"jobId": "10178",
"name": "召唤物",
"traitId": "10178",
"introduce": "召唤物",
"alias": "10178.png",
"level": {
"1": "召唤物"
},
"TFTID": "10178",
"characterid": "",
"id": "2634",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/classes/10178.png",
"job_color_list": "1:1"
}
]
}

244
data/race.json Normal file
View File

@ -0,0 +1,244 @@
{
"version": "15.7",
"season": "2025.S14",
"modeId": "1",
"time": "2025-04-01 14:31:07",
"data": [
{
"raceId": "10153",
"name": "圣灵使者",
"traitId": "10153",
"introduce": "【圣灵使者】弈子会为你的小队提供独特的属性,该加成会随着已登场的【圣灵使者】弈子数量而提升。\r\n【圣灵使者】弈子们获得双倍。",
"alias": "10153.png",
"level": {
"1": "100% 加成。",
"2": "110% 加成。",
"3": "125% 加成。",
"4": "140% 加成。",
"5": "160% 加成。",
"6": "180% 加成。",
"7": "200% 加成。"
},
"TFTID": "10153",
"characterid": "TFT14_Divinicorp",
"id": "2727",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10153.png",
"race_color_list": "1:1,2:2,3:2,4:3,5:3,6:3,7:4"
},
{
"raceId": "10154",
"name": "街头恶魔",
"traitId": "10154",
"introduce": "在【彩绘格】中的友军将获得生命值、法术强度和攻击力。一些格子是【签名格】并多提供50%此加成。\r\n【街头恶魔】使所有此加成翻倍。",
"alias": "10154.png",
"level": {
"3": "+6% 生命值,6 法术强度,6% 攻击力",
"5": "+10% 生命值,10 法术强度,10% 攻击力",
"7": "+6% 生命值,15 法术强度,15% 攻击力",
"10": "+50% 生命值,50 法术强度,50% 攻击力,尽情绘画!"
},
"TFTID": "10154",
"characterid": "TFT14_StreetDemon",
"id": "2728",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10154.png",
"race_color_list": "3:1,5:2,7:3,10:4"
},
{
"raceId": "10156",
"name": "幻灵战队",
"traitId": "10156",
"introduce": "每个层级,挑出一件随机【幻灵战队】武器,用于在战斗期间周期性开火。【幻灵战队】弈子们获得护甲、魔抗和伤害增幅。",
"alias": "10156.png",
"level": {
"3": "10 护甲和魔抗, 5% 伤害增幅,选择一件武器",
"5": "30 护甲和魔抗, 12% 伤害增幅,选择一件武器",
"7": "45 护甲和魔抗, 20% 伤害增幅,选择一件武器",
"10": "75 护甲和魔抗, 25% 伤害增幅,选择一件终极武器。"
},
"TFTID": "10156",
"characterid": "TFT14_AnimaSquad",
"id": "2729",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10156.png",
"race_color_list": "3:1,5:2,7:3,10:4"
},
{
"raceId": "10159",
"name": "弑魂者",
"traitId": "10159",
"introduce": "获得一个全息投影模仿【佛耶戈】上回合参与击杀的最高费敌人。投影拥有900/1350/9001生命值造成30%/40%/200%伤害并拥有1件推荐装备。",
"alias": "10159.png",
"level": {
"1": "获得一个全息投影模仿【佛耶戈】上回合参与击杀的最高费敌人。投影拥有900/1350/9001生命值造成30%/40%/200%伤害并拥有1件推荐装备。"
},
"TFTID": "10159",
"characterid": "TFT14_ViegoUniqueTrait",
"id": "2730",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10159.png",
"race_color_list": "1:5"
},
{
"raceId": "10160",
"name": "赛博老大",
"traitId": "10160",
"introduce": "你最强大的那个【赛博老大】会升级为它的最终形态并获得生命值、法术强度、并且它的技能会命中更多敌人。",
"alias": "10160.png",
"level": {
"2": "25% 生命值, 20 法术强度",
"3": "33% 生命值, 30 法术强度",
"4": "所有【赛博老大】升级。你最强大的那个【赛博老大】获得40% 生命值和40 法术强度"
},
"TFTID": "10160",
"characterid": "TFT14_Cyberboss",
"id": "2731",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10160.png",
"race_color_list": "2:1,3:2,4:3"
},
{
"raceId": "10163",
"name": "源计划",
"traitId": "10163",
"introduce": "获得仅供【源计划】弈子们携带的独特装备。他们每携带一件装备就会获得生命值和攻击速度。",
"alias": "10163.png",
"level": {
"3": "50 生命值, 2% 攻击速度,第一件源计划装备",
"5": "150 生命值, 5% 攻击速度,第二件源计划装备",
"7": "225 生命值, 9% 攻击速度,第三件源计划装备",
"10": "500 生命值, 40% 攻击速度,全部源计划装备"
},
"TFTID": "10163",
"characterid": "TFT14_EdgeRunner",
"id": "2732",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10163.png",
"race_color_list": "3:1,5:2,7:3,10:4"
},
{
"raceId": "10164",
"name": "鳄霸",
"traitId": "10164",
"introduce": "【鳄霸】咬一口他身后格子内的弈子造成该弈子40%最大生命值的真实伤害。他获得该弈子的40%生命值和该弈子的33%攻击力。",
"alias": "10164.png",
"level": {
"1": "【鳄霸】咬一口他身后格子内的弈子造成该弈子40%最大生命值的真实伤害。他获得该弈子的40%生命值和该弈子的33%攻击力。"
},
"TFTID": "10164",
"characterid": "TFT14_Overlord",
"id": "2733",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10164.png",
"race_color_list": "1:5"
},
{
"raceId": "10165",
"name": "网络之神",
"traitId": "10165",
"introduce": "2个玩家对战回合后打开一个武器库内含的羁绊改件可永久对一个弈子进行重新编程使其受益于一个羁绊但不贡献羁绊计数。\r\n你每次获得一个【羁绊改件】时下一个所需的回合数就需要增加1。\r\n弈子仅能拥有一个【羁绊改件】。",
"alias": "10165.png",
"level": {
"1": "2个玩家对战回合后打开一个武器库内含的羁绊改件可永久对一个弈子进行重新编程使其受益于一个羁绊但不贡献羁绊计数。你每次获得一个【羁绊改件】时下一个所需的回合数就需要增加1。弈子仅能拥有一个【羁绊改件】。"
},
"TFTID": "10165",
"characterid": "TFT14_Netgod",
"id": "2734",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10165.png",
"race_color_list": "1:5"
},
{
"raceId": "10170",
"name": "福牛守护者",
"traitId": "10170",
"introduce": "【福牛守护者】们获得伤害增幅,并且在击杀时有一定几率掉落金币。\r\n如果你在单个回合内花费8金币用于刷新或经验值则会永久提升其伤害增幅以及下次加成所需的金币数量。在计算金币花费时刷新所用的金币算作双倍。\r\n(【福牛守护者】必须在上个回合进行过战斗)",
"alias": "10170.png",
"level": {
"2": "13% 伤害增幅20%金币",
"4": "20% 伤害增幅50%金币",
"6": "25% 伤害增幅75%战利品战利品有7%几率是一件基础装备"
},
"TFTID": "10170",
"characterid": "TFT14_Immortal",
"id": "2735",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10170.png",
"race_color_list": "2:1,4:2,6:3"
},
{
"raceId": "10171",
"name": "战地机甲",
"traitId": "10171",
"introduce": "【战地机甲】们每造成400伤害就会向一个附近的敌人发射一颗造成魔法伤害的导弹。8%的承受伤害会被计入造成伤害。",
"alias": "10171.png",
"level": {
"2": "150魔法伤害",
"4": "325魔法伤害",
"6": "发射两颗导弹每颗造成225魔法伤害"
},
"TFTID": "10171",
"characterid": "TFT14_BallisTek",
"id": "2736",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10171.png",
"race_color_list": "2:1,4:2,6:3"
},
{
"raceId": "10174",
"name": "病毒魔人",
"traitId": "10174",
"introduce": "【病毒魔人】有10%几率感染你的商店感染时会生成一个细胞组织。购买后细胞组织会合并并使最强大的那个【扎克】提升3%最大生命值和4法术强度。",
"alias": "10174.png",
"level": {
"1": "【病毒魔人】有10%几率感染你的商店感染时会生成一个细胞组织。购买后细胞组织会合并并使最强大的那个【扎克】提升3%最大生命值和4法术强度。"
},
"TFTID": "10174",
"characterid": "TFT14_Virus",
"id": "2737",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10174.png",
"race_color_list": "1:5"
},
{
"raceId": "10175",
"name": "辛迪加",
"traitId": "10175",
"introduce": "获得一个【主理人之帽】,用于将一个【辛迪加】弈子的技能进行独特升级。\r\n【辛迪加】弈子们获得生命值和伤害增幅。",
"alias": "10175.png",
"level": {
"3": "100 生命值5% 伤害增幅1个【主理人】",
"5": "400 生命值15% 伤害增幅2个【主理人】",
"7": "500 生命值20% 伤害增幅,升级【主理人】效果。"
},
"TFTID": "10175",
"characterid": "TFT14_Mob",
"id": "2738",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10175.png",
"race_color_list": "3:1,5:2,7:3"
},
{
"raceId": "10176",
"name": "魔装机神",
"traitId": "10176",
"introduce": "每回合独特的【魔装机神】们都会给R-080T提供基于各自星级的【铬】。每份【铬】提供14生命值和1法术强度。\r\n在200份【铬】时它会升级为T-43X",
"alias": "10176.png",
"level": {
"3": "召唤R-080T",
"4": "发射一束巨大激光!"
},
"TFTID": "10176",
"characterid": "TFT14_HotRod",
"id": "2739",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10176.png",
"race_color_list": "3:1,4:3"
},
{
"raceId": "10177",
"name": "执事",
"traitId": "10177",
"introduce": "通过输掉战斗获得【情报】,获取量会在连败时提升。击杀敌人时获得少量【情报】。\r\n你可以用你的【情报】进行一次战利品交易在以下回合进行交易\r\n3-3、3-7、4-3, 4-7、或5-5。\r\n在交易【情报】后【执事】们获得攻击力和法术强度。",
"alias": "10177.png",
"level": {
"3": "1x【情报】30% 攻击力和法术强度",
"4": "1.5x【情报】45% 攻击力和法术强度",
"5": "2x【情报】55% 攻击力和法术强度"
},
"TFTID": "10177",
"characterid": "TFT14_Suits",
"id": "2740",
"imagePath": "https://game.gtimg.cn/images/lol/act/img/tft/origins/10177.png",
"race_color_list": "3:1,4:2,5:3"
}
]
}

33
main.py Normal file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""
云顶之弈阵容推荐器 - 主程序
"""
import sys
from src.data_provider_demo import main as data_provider_demo
def main():
"""主函数"""
# 检查命令行参数
if len(sys.argv) > 1:
command = sys.argv[1]
if command == "data":
# 运行数据提供模块演示
data_provider_demo()
else:
print(f"未知命令: {command}")
print_usage()
else:
# 默认运行数据提供模块演示
data_provider_demo()
def print_usage():
"""打印使用帮助"""
print("使用方法: python main.py [命令]")
print("命令:")
print(" data 运行数据提供模块演示")
if __name__ == "__main__":
main()

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
requests>=2.25.0
pandas>=1.3.0
pyyaml>=6.0
pytest>=7.0.0

3
src/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""
云顶之弈阵容推荐器
"""

View File

@ -0,0 +1,4 @@
from .data_loader import DataLoader
from .data_query_api import DataQueryAPI
__all__ = ['DataLoader', 'DataQueryAPI']

View File

@ -0,0 +1,181 @@
import os
import json
import time
import requests
from typing import Dict, List, Optional, Any, Union
import logging
# 配置日志
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("TFT-Strategist-DataLoader")
class DataLoader:
"""
数据加载器负责从本地文件或在线接口获取云顶之弈的职业特质和棋子数据
"""
# 数据源URL
DATA_URLS = {
'chess': 'https://game.gtimg.cn/images/lol/act/img/tft/js/chess.js',
'job': 'https://game.gtimg.cn/images/lol/act/img/tft/js/job.js',
'race': 'https://game.gtimg.cn/images/lol/act/img/tft/js/race.js'
}
# 本地数据文件路径
DATA_FILES = {
'chess': 'data/chess.json',
'job': 'data/job.json',
'race': 'data/race.json'
}
def __init__(self, use_local_if_exists: bool = True, update_interval: int = 86400):
"""
初始化数据加载器
Args:
use_local_if_exists: 是否优先使用本地文件如果存在且未过期
update_interval: 数据更新间隔默认为24小时
"""
self.use_local_if_exists = use_local_if_exists
self.update_interval = update_interval
self.data_cache = {
'chess': None,
'job': None,
'race': None
}
# 确保数据目录存在
os.makedirs('data', exist_ok=True)
def load_all_data(self) -> bool:
"""
加载所有云顶之弈数据
Returns:
bool: 加载是否成功
"""
success = True
for data_type in self.DATA_URLS.keys():
if not self.load_data(data_type):
success = False
return success
def load_data(self, data_type: str) -> bool:
"""
加载指定类型的数据
Args:
data_type: 数据类型可选值为 'chess', 'job', 'race'
Returns:
bool: 加载是否成功
"""
if data_type not in self.DATA_URLS:
logger.error(f"未知的数据类型: {data_type}")
return False
# 检查本地文件是否存在且未过期
local_file = self.DATA_FILES[data_type]
if self.use_local_if_exists and os.path.exists(local_file):
# 检查文件修改时间
file_mtime = os.path.getmtime(local_file)
if time.time() - file_mtime < self.update_interval:
try:
with open(local_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.data_cache[data_type] = data
logger.info(f"已从本地文件加载 {data_type} 数据")
return True
except Exception as e:
logger.warning(f"从本地加载 {data_type} 数据失败: {e}")
# 从在线接口获取数据
try:
logger.info(f"正在从在线接口获取 {data_type} 数据...")
response = requests.get(self.DATA_URLS[data_type], timeout=10)
response.raise_for_status()
data = response.json()
# 保存到本地文件
with open(local_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
self.data_cache[data_type] = data
logger.info(f"已从在线接口获取并保存 {data_type} 数据")
return True
except Exception as e:
logger.error(f"从在线接口获取 {data_type} 数据失败: {e}")
return False
def get_data(self, data_type: str) -> Optional[Dict[str, Any]]:
"""
获取指定类型的数据
Args:
data_type: 数据类型可选值为 'chess', 'job', 'race'
Returns:
Optional[Dict[str, Any]]: 数据字典加载失败时返回None
"""
if data_type not in self.DATA_URLS:
logger.error(f"未知的数据类型: {data_type}")
return None
# 如果数据尚未加载,则先加载
if self.data_cache[data_type] is None:
if not self.load_data(data_type):
return None
return self.data_cache[data_type]
def reload_data(self, force: bool = False) -> bool:
"""
重新加载所有数据
Args:
force: 是否强制从在线接口获取忽略本地文件
Returns:
bool: 重新加载是否成功
"""
prev_use_local = self.use_local_if_exists
if force:
self.use_local_if_exists = False
success = self.load_all_data()
# 恢复原来的设置
self.use_local_if_exists = prev_use_local
return success
def get_latest_version(self) -> Optional[str]:
"""
获取当前数据的版本号
Returns:
Optional[str]: 版本号数据未加载时返回None
"""
# 优先使用棋子数据的版本,如果没有则尝试其他数据源
for data_type in ['chess', 'job', 'race']:
data = self.get_data(data_type)
if data and 'version' in data:
return data['version']
return None
if __name__ == "__main__":
# 测试代码
loader = DataLoader()
if loader.load_all_data():
print(f"数据加载成功,当前版本: {loader.get_latest_version()}")
print(f"棋子数量: {len(loader.get_data('chess')['data'])}")
print(f"职业数量: {len(loader.get_data('job')['data'])}")
print(f"特质数量: {len(loader.get_data('race')['data'])}")
else:
print("数据加载失败")

View File

@ -0,0 +1,460 @@
from typing import Dict, List, Optional, Any, Set, Tuple, Union
import logging
from .data_loader import DataLoader
# 配置日志
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("TFT-Strategist-DataQueryAPI")
class DataQueryAPI:
"""
数据查询API提供对游戏数据的各种查询功能
"""
def __init__(self, data_loader: Optional[DataLoader] = None):
"""
初始化数据查询API
Args:
data_loader: 数据加载器实例如果为None则创建一个新的实例
"""
self.data_loader = data_loader if data_loader else DataLoader()
self._job_cache = {} # 职业缓存
self._race_cache = {} # 特质缓存
self._chess_cache = {} # 棋子缓存
self._job_chess_map = {} # 职业->棋子映射
self._race_chess_map = {} # 特质->棋子映射
self._chess_job_map = {} # 棋子->职业映射
self._chess_race_map = {} # 棋子->特质映射
# 初始化数据
self._init_data()
def _init_data(self) -> bool:
"""
初始化并处理数据
Returns:
bool: 初始化是否成功
"""
if not self.data_loader.load_all_data():
logger.error("数据加载失败无法初始化数据查询API")
return False
# 处理职业数据
job_data = self.data_loader.get_data('job')
if job_data:
for job in job_data['data']:
job_id = job['jobId']
self._job_cache[job_id] = job
self._job_chess_map[job_id] = []
# 处理特质数据
race_data = self.data_loader.get_data('race')
if race_data:
for race in race_data['data']:
race_id = race['raceId']
self._race_cache[race_id] = race
self._race_chess_map[race_id] = []
# 处理棋子数据并建立关联
chess_data = self.data_loader.get_data('chess')
if chess_data:
for chess in chess_data['data']:
chess_id = chess['chessId']
self._chess_cache[chess_id] = chess
# 建立棋子与职业的关联
job_ids = chess['jobIds'].split(',') if chess['jobIds'] else []
self._chess_job_map[chess_id] = job_ids
for job_id in job_ids:
if job_id and job_id in self._job_chess_map:
self._job_chess_map[job_id].append(chess_id)
# 建立棋子与特质的关联
race_ids = chess['raceIds'].split(',') if chess['raceIds'] else []
self._chess_race_map[chess_id] = race_ids
for race_id in race_ids:
if race_id and race_id in self._race_chess_map:
self._race_chess_map[race_id].append(chess_id)
logger.info(f"数据初始化完成: {len(self._chess_cache)}个棋子, {len(self._job_cache)}个职业, {len(self._race_cache)}个特质")
return True
def reload_data(self, force: bool = False) -> bool:
"""
重新加载数据
Args:
force: 是否强制从在线接口获取
Returns:
bool: 重新加载是否成功
"""
if self.data_loader.reload_data(force=force):
# 清空缓存
self._job_cache.clear()
self._race_cache.clear()
self._chess_cache.clear()
self._job_chess_map.clear()
self._race_chess_map.clear()
self._chess_job_map.clear()
self._chess_race_map.clear()
# 重新初始化数据
return self._init_data()
return False
def get_all_jobs(self) -> List[Dict[str, Any]]:
"""
获取所有职业数据
Returns:
List[Dict[str, Any]]: 职业数据列表
"""
return list(self._job_cache.values())
def get_all_races(self) -> List[Dict[str, Any]]:
"""
获取所有特质数据
Returns:
List[Dict[str, Any]]: 特质数据列表
"""
return list(self._race_cache.values())
def get_all_chess(self) -> List[Dict[str, Any]]:
"""
获取所有棋子数据
Returns:
List[Dict[str, Any]]: 棋子数据列表
"""
return list(self._chess_cache.values())
def get_job_by_id(self, job_id: str) -> Optional[Dict[str, Any]]:
"""
根据ID获取职业数据
Args:
job_id: 职业ID
Returns:
Optional[Dict[str, Any]]: 职业数据如果不存在则返回None
"""
return self._job_cache.get(job_id)
def get_race_by_id(self, race_id: str) -> Optional[Dict[str, Any]]:
"""
根据ID获取特质数据
Args:
race_id: 特质ID
Returns:
Optional[Dict[str, Any]]: 特质数据如果不存在则返回None
"""
return self._race_cache.get(race_id)
def get_chess_by_id(self, chess_id: str) -> Optional[Dict[str, Any]]:
"""
根据ID获取棋子数据
Args:
chess_id: 棋子ID
Returns:
Optional[Dict[str, Any]]: 棋子数据如果不存在则返回None
"""
return self._chess_cache.get(chess_id)
def get_job_by_name(self, name: str) -> Optional[Dict[str, Any]]:
"""
根据名称获取职业数据
Args:
name: 职业名称
Returns:
Optional[Dict[str, Any]]: 职业数据如果不存在则返回None
"""
for job in self._job_cache.values():
if job['name'] == name:
return job
return None
def get_race_by_name(self, name: str) -> Optional[Dict[str, Any]]:
"""
根据名称获取特质数据
Args:
name: 特质名称
Returns:
Optional[Dict[str, Any]]: 特质数据如果不存在则返回None
"""
for race in self._race_cache.values():
if race['name'] == name:
return race
return None
def get_chess_by_name(self, name: str) -> Optional[Dict[str, Any]]:
"""
根据名称获取棋子数据
Args:
name: 棋子名称displayName
Returns:
Optional[Dict[str, Any]]: 棋子数据如果不存在则返回None
"""
for chess in self._chess_cache.values():
if chess.get('displayName') == name:
return chess
return None
def get_chess_by_job(self, job_id: str) -> List[Dict[str, Any]]:
"""
获取指定职业的所有棋子
Args:
job_id: 职业ID
Returns:
List[Dict[str, Any]]: 棋子数据列表
"""
if job_id not in self._job_chess_map:
return []
result = []
for chess_id in self._job_chess_map[job_id]:
chess = self._chess_cache.get(chess_id)
if chess:
result.append(chess)
return result
def get_chess_by_race(self, race_id: str) -> List[Dict[str, Any]]:
"""
获取指定特质的所有棋子
Args:
race_id: 特质ID
Returns:
List[Dict[str, Any]]: 棋子数据列表
"""
if race_id not in self._race_chess_map:
return []
result = []
for chess_id in self._race_chess_map[race_id]:
chess = self._chess_cache.get(chess_id)
if chess:
result.append(chess)
return result
def get_jobs_of_chess(self, chess_id: str) -> List[Dict[str, Any]]:
"""
获取指定棋子的所有职业
Args:
chess_id: 棋子ID
Returns:
List[Dict[str, Any]]: 职业数据列表
"""
if chess_id not in self._chess_job_map:
return []
result = []
for job_id in self._chess_job_map[chess_id]:
job = self._job_cache.get(job_id)
if job:
result.append(job)
return result
def get_races_of_chess(self, chess_id: str) -> List[Dict[str, Any]]:
"""
获取指定棋子的所有特质
Args:
chess_id: 棋子ID
Returns:
List[Dict[str, Any]]: 特质数据列表
"""
if chess_id not in self._chess_race_map:
return []
result = []
for race_id in self._chess_race_map[chess_id]:
race = self._race_cache.get(race_id)
if race:
result.append(race)
return result
def get_all_synergies(self) -> List[Dict[str, Any]]:
"""
获取所有羁绊职业和特质数据
Returns:
List[Dict[str, Any]]: 羁绊数据列表
"""
return self.get_all_jobs() + self.get_all_races()
def get_synergy_by_id(self, synergy_id: str) -> Optional[Dict[str, Any]]:
"""
根据ID获取羁绊数据
Args:
synergy_id: 羁绊ID
Returns:
Optional[Dict[str, Any]]: 羁绊数据如果不存在则返回None
"""
# 先在职业中查找
job = self.get_job_by_id(synergy_id)
if job:
return job
# 再在特质中查找
return self.get_race_by_id(synergy_id)
def get_synergy_by_name(self, name: str) -> Optional[Dict[str, Any]]:
"""
根据名称获取羁绊数据
Args:
name: 羁绊名称
Returns:
Optional[Dict[str, Any]]: 羁绊数据如果不存在则返回None
"""
# 先在职业中查找
job = self.get_job_by_name(name)
if job:
return job
# 再在特质中查找
return self.get_race_by_name(name)
def get_chess_by_synergy(self, synergy_id: str) -> List[Dict[str, Any]]:
"""
获取指定羁绊的所有棋子
Args:
synergy_id: 羁绊ID
Returns:
List[Dict[str, Any]]: 棋子数据列表
"""
# 先在职业中查找
chess_list = self.get_chess_by_job(synergy_id)
if chess_list:
return chess_list
# 再在特质中查找
return self.get_chess_by_race(synergy_id)
def get_synergies_of_chess(self, chess_id: str) -> List[Dict[str, Any]]:
"""
获取指定棋子的所有羁绊
Args:
chess_id: 棋子ID
Returns:
List[Dict[str, Any]]: 羁绊数据列表
"""
return self.get_jobs_of_chess(chess_id) + self.get_races_of_chess(chess_id)
def get_chess_cost_distribution(self) -> Dict[str, int]:
"""
获取棋子费用分布
Returns:
Dict[str, int]: 费用分布统计键为费用值为数量
"""
result = {}
for chess in self._chess_cache.values():
cost = chess.get('price', '0')
if cost not in result:
result[cost] = 0
result[cost] += 1
return result
def get_chess_by_cost(self, cost: str) -> List[Dict[str, Any]]:
"""
获取指定费用的所有棋子
Args:
cost: 棋子费用
Returns:
List[Dict[str, Any]]: 棋子数据列表
"""
result = []
for chess in self._chess_cache.values():
if chess.get('price') == cost:
result.append(chess)
return result
def get_synergy_levels(self, synergy_id: str) -> Dict[str, str]:
"""
获取指定羁绊的等级信息
Args:
synergy_id: 羁绊ID
Returns:
Dict[str, str]: 羁绊等级信息键为等级值为效果描述
"""
synergy = self.get_synergy_by_id(synergy_id)
if not synergy:
return {}
return synergy.get('level', {})
if __name__ == "__main__":
# 测试代码
api = DataQueryAPI()
# 获取所有职业和特质
print(f"职业数量: {len(api.get_all_jobs())}")
print(f"特质数量: {len(api.get_all_races())}")
print(f"棋子数量: {len(api.get_all_chess())}")
# 获取特定羁绊的棋子
heavy_warriors = api.get_job_by_name("重装战士")
if heavy_warriors:
heavy_warriors_id = heavy_warriors['jobId']
chess_list = api.get_chess_by_job(heavy_warriors_id)
print(f"\n重装战士棋子 ({len(chess_list)}个):")
for chess in chess_list:
print(f" - {chess['displayName']} (费用: {chess['price']})")
# 获取某个棋子的所有羁绊
some_chess = api.get_all_chess()[0]
if some_chess:
chess_id = some_chess['chessId']
synergies = api.get_synergies_of_chess(chess_id)
print(f"\n{some_chess['displayName']}的羁绊:")
for synergy in synergies:
print(f" - {synergy['name']}")
# 获取费用分布
cost_distribution = api.get_chess_cost_distribution()
print("\n棋子费用分布:")
for cost, count in sorted(cost_distribution.items()):
if cost != '0': # 排除费用为0的棋子通常是召唤物
print(f" {cost}费棋子: {count}")

110
src/data_provider_demo.py Normal file
View File

@ -0,0 +1,110 @@
"""
数据提供模块演示脚本
"""
from .data_provider import DataLoader, DataQueryAPI
def main():
"""主函数"""
print("=== 云顶之弈数据提供模块演示 ===")
# 初始化数据查询API
print("\n正在加载数据...")
api = DataQueryAPI()
# 显示数据基本信息
print(f"\n当前游戏版本: {api.data_loader.get_latest_version()}")
print(f"职业数量: {len(api.get_all_jobs())}")
print(f"特质数量: {len(api.get_all_races())}")
print(f"棋子数量: {len(api.get_all_chess())}")
# 显示所有羁绊(职业和特质)
print("\n=== 所有羁绊 ===")
all_jobs = api.get_all_jobs()
all_races = api.get_all_races()
print("\n职业羁绊:")
for job in all_jobs:
job_id = job['jobId']
chess_count = len(api.get_chess_by_job(job_id))
level_info = ' / '.join([f"{level}人:{effect}" for level, effect in job.get('level', {}).items()])
print(f" - {job['name']} ({chess_count}个棋子): {level_info}")
print("\n特质羁绊:")
for race in all_races:
race_id = race['raceId']
chess_count = len(api.get_chess_by_race(race_id))
level_info = ' / '.join([f"{level}人:{effect}" for level, effect in race.get('level', {}).items()])
print(f" - {race['name']} ({chess_count}个棋子): {level_info}")
# 显示棋子费用分布
cost_distribution = api.get_chess_cost_distribution()
print("\n=== 棋子费用分布 ===")
for cost, count in sorted(cost_distribution.items()):
if cost != '0': # 排除费用为0的棋子通常是召唤物
print(f" {cost}费棋子: {count}")
# 互动查询示例
print("\n=== 互动查询 ===")
while True:
print("\n请选择查询类型:")
print("1. 查询羁绊下的所有棋子")
print("2. 查询棋子的所有羁绊")
print("3. 查询羁绊等级信息")
print("4. 退出")
choice = input("请输入选项 (1-4): ")
if choice == '1':
name = input("请输入羁绊名称: ")
synergy = api.get_synergy_by_name(name)
if not synergy:
print(f"未找到名为 '{name}' 的羁绊")
continue
synergy_id = synergy.get('jobId') or synergy.get('raceId')
chess_list = api.get_chess_by_synergy(synergy_id)
print(f"\n{name} 羁绊的棋子 ({len(chess_list)}个):")
for chess in sorted(chess_list, key=lambda x: x.get('price', '0')):
price = chess.get('price', '未知')
print(f" - {chess['displayName']} ({price}费)")
elif choice == '2':
name = input("请输入棋子名称: ")
chess = api.get_chess_by_name(name)
if not chess:
print(f"未找到名为 '{name}' 的棋子")
continue
chess_id = chess['chessId']
synergies = api.get_synergies_of_chess(chess_id)
print(f"\n{name} 的羁绊 ({len(synergies)}个):")
for synergy in synergies:
synergy_type = "职业" if 'jobId' in synergy else "特质"
print(f" - {synergy['name']} ({synergy_type})")
elif choice == '3':
name = input("请输入羁绊名称: ")
synergy = api.get_synergy_by_name(name)
if not synergy:
print(f"未找到名为 '{name}' 的羁绊")
continue
synergy_id = synergy.get('jobId') or synergy.get('raceId')
levels = api.get_synergy_levels(synergy_id)
print(f"\n{name} 的羁绊等级效果:")
for level, effect in sorted(levels.items(), key=lambda x: int(x[0])):
print(f" - {level}人: {effect}")
elif choice == '4':
break
else:
print("无效选项,请重新输入")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,3 @@
"""
接口模块
"""

View File

@ -0,0 +1,3 @@
"""
阵容推荐模块
"""

3
src/scoring/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""
阵容评分模块
"""

3
tests/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""
测试模块
"""

190
tests/test_data_provider.py Normal file
View File

@ -0,0 +1,190 @@
import os
import sys
import unittest
from unittest import mock
# 添加项目根目录到Python路径
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from src.data_provider import DataLoader, DataQueryAPI
class TestDataLoader(unittest.TestCase):
"""测试DataLoader类"""
@mock.patch('requests.get')
def test_load_data_from_online(self, mock_get):
"""测试从网络加载数据"""
# 模拟requests.get返回值
mock_response = mock.Mock()
mock_response.json.return_value = {
"version": "15.7",
"data": [{"id": "test"}]
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
# 创建临时数据目录
os.makedirs('data', exist_ok=True)
# 执行测试
loader = DataLoader(use_local_if_exists=False)
result = loader.load_data('chess')
# 验证结果
self.assertTrue(result)
self.assertEqual(loader.get_data('chess')['version'], '15.7')
self.assertEqual(len(loader.get_data('chess')['data']), 1)
# 清理
if os.path.exists('data/chess.json'):
os.remove('data/chess.json')
def test_get_latest_version(self):
"""测试获取数据版本"""
loader = DataLoader()
# 模拟缓存数据
loader.data_cache = {
'chess': {"version": "15.7", "data": []},
'job': {"version": "15.7", "data": []},
'race': {"version": "15.7", "data": []}
}
# 执行测试
version = loader.get_latest_version()
# 验证结果
self.assertEqual(version, '15.7')
class TestDataQueryAPI(unittest.TestCase):
"""测试DataQueryAPI类"""
def setUp(self):
"""测试前准备"""
# 创建模拟的DataLoader
self.mock_loader = mock.Mock()
self.mock_loader.load_all_data.return_value = True
# 模拟加载的数据
self.job_data = {
"version": "15.7",
"data": [
{
"jobId": "10157",
"name": "重装战士",
"level": {
"2": "16%最大生命值",
"4": "32%最大生命值",
}
}
]
}
self.race_data = {
"version": "15.7",
"data": [
{
"raceId": "10154",
"name": "街头恶魔",
"level": {
"3": "+6% 生命值",
"5": "+10% 生命值",
}
}
]
}
self.chess_data = {
"version": "15.7",
"data": [
{
"chessId": "10275",
"displayName": "布兰德",
"raceIds": "10154",
"jobIds": "10172",
"price": "4"
},
{
"chessId": "10276",
"displayName": "阿利斯塔",
"raceIds": "",
"jobIds": "10157",
"price": "3"
}
]
}
# 设置模拟数据返回
self.mock_loader.get_data.side_effect = lambda data_type: {
'job': self.job_data,
'race': self.race_data,
'chess': self.chess_data
}.get(data_type)
def test_get_chess_by_job(self):
"""测试根据职业获取棋子"""
# 创建API实例
api = DataQueryAPI(data_loader=self.mock_loader)
# 手动设置缓存和映射模拟_init_data方法
api._job_cache = {"10157": self.job_data["data"][0]}
api._chess_cache = {
"10275": self.chess_data["data"][0],
"10276": self.chess_data["data"][1]
}
api._job_chess_map = {"10157": ["10276"]}
# 执行测试
chess_list = api.get_chess_by_job("10157")
# 验证结果
self.assertEqual(len(chess_list), 1)
self.assertEqual(chess_list[0]["displayName"], "阿利斯塔")
def test_get_chess_by_race(self):
"""测试根据特质获取棋子"""
# 创建API实例
api = DataQueryAPI(data_loader=self.mock_loader)
# 手动设置缓存和映射
api._race_cache = {"10154": self.race_data["data"][0]}
api._chess_cache = {
"10275": self.chess_data["data"][0],
"10276": self.chess_data["data"][1]
}
api._race_chess_map = {"10154": ["10275"]}
# 执行测试
chess_list = api.get_chess_by_race("10154")
# 验证结果
self.assertEqual(len(chess_list), 1)
self.assertEqual(chess_list[0]["displayName"], "布兰德")
def test_get_synergy_levels(self):
"""测试获取羁绊等级信息"""
# 创建API实例
api = DataQueryAPI(data_loader=self.mock_loader)
# 手动设置缓存
api._job_cache = {"10157": self.job_data["data"][0]}
api._race_cache = {"10154": self.race_data["data"][0]}
# 执行测试
job_levels = api.get_synergy_levels("10157")
race_levels = api.get_synergy_levels("10154")
# 验证结果
self.assertEqual(len(job_levels), 2)
self.assertEqual(job_levels["2"], "16%最大生命值")
self.assertEqual(job_levels["4"], "32%最大生命值")
self.assertEqual(len(race_levels), 2)
self.assertEqual(race_levels["3"], "+6% 生命值")
self.assertEqual(race_levels["5"], "+10% 生命值")
if __name__ == '__main__':
unittest.main()