feat:完善加点代码导入
This commit is contained in:
@@ -6,6 +6,20 @@ from api.utils.openapi_data import openapi_locale_path
|
|||||||
|
|
||||||
SKILL_TREE_WITH_DESC_SPECIAL_VERSION = 'skillTreeWithDescSpecial:v1'
|
SKILL_TREE_WITH_DESC_SPECIAL_VERSION = 'skillTreeWithDescSpecial:v1'
|
||||||
SKILL_TREE_NAME_MAP_VERSION = 'skillTreeNameMap:v1'
|
SKILL_TREE_NAME_MAP_VERSION = 'skillTreeNameMap:v1'
|
||||||
|
SKILL_TREE_MAP_VERSION = 'skillTreeMap:v1'
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def load_skill_tree_map(job_id: str, job_grow_id: str) -> dict[str, dict]:
|
||||||
|
"""按 OpenAPI 数字 ID 索引技能树,保留 autoLevel 等元数据。调用方只读。"""
|
||||||
|
skill_tree_path = openapi_locale_path('cn', job_id, job_grow_id, 'skill_tree.json')
|
||||||
|
with skill_tree_path.open(encoding='utf-8') as file:
|
||||||
|
skills_data = json.load(file)
|
||||||
|
return {
|
||||||
|
str(skill['id']): skill
|
||||||
|
for skill in skills_data
|
||||||
|
if skill.get('id') is not None and skill.get('name') is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@cache
|
@cache
|
||||||
|
|||||||
@@ -244,6 +244,32 @@ if len(data) % 2:
|
|||||||
|
|
||||||
其中最后的 `00` 是当前解析实现补入的对齐字节,不是解压后原始数据的一部分。该行为为兼容现有返回格式而保留。
|
其中最后的 `00` 是当前解析实现补入的对齐字节,不是解压后原始数据的一部分。该行为为兼容现有返回格式而保留。
|
||||||
|
|
||||||
|
### 5.1 普通职业技能连招
|
||||||
|
|
||||||
|
版本 5 的非黑暗武士编码新增 `skill_combo`,由 `_parse_skill_combo()` 读取:
|
||||||
|
|
||||||
|
```python
|
||||||
|
'skill_combo': {
|
||||||
|
'skills': [309, 305, 312, 311],
|
||||||
|
'interval_ms': 5000,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `remaining[92:100]`:固定四个技能索引,每项两字节;沿用快捷栏的高位标志规则。
|
||||||
|
- 技能为 OpenAPI 数字 ID,空槽返回 `None`,保留四个槽位及顺序。
|
||||||
|
- `remaining[100:102]`:小端 uint16 初始化间隔,单位毫秒。
|
||||||
|
- `remaining[102]`:尾部 `F3`。长度不足或标记不匹配时 `skill_combo` 返回 `None`。
|
||||||
|
- `remaining[91]` 尚未确认,不解释为开关或数量。
|
||||||
|
|
||||||
|
女蓝拳样本的四个技能为拳震八方、巨力炮拳、致命冲拳、怒气贯地击,
|
||||||
|
顺序已由用户确认。仅把间隔从 0 秒改为 5 秒后,唯一变化是
|
||||||
|
`remaining[100:102]` 从 `00 00` 变为 `88 13`(小端整数 5000)。
|
||||||
|
剑豪样本也能对应三个技能和一个空槽。
|
||||||
|
|
||||||
|
该字段当前仅在 `decode_skill_hash()` 输出;计算器接口及黑暗武士的
|
||||||
|
`arrangements` / `combos` 不变。黑暗武士和其他编码版本不套用此偏移。
|
||||||
|
原始 `section2_remaining` 始终完整保留。
|
||||||
|
|
||||||
## 6. 黑暗武士连续技排列
|
## 6. 黑暗武士连续技排列
|
||||||
|
|
||||||
只有满足以下条件时,顶层结果才增加 `arrangements`:
|
只有满足以下条件时,顶层结果才增加 `arrangements`:
|
||||||
@@ -330,6 +356,7 @@ index = index_byte + (0x100 if flag else 0)
|
|||||||
{
|
{
|
||||||
'header': dict,
|
'header': dict,
|
||||||
'skills': list,
|
'skills': list,
|
||||||
|
'auto_skills': list,
|
||||||
'evolutions': list,
|
'evolutions': list,
|
||||||
'enhancements': list,
|
'enhancements': list,
|
||||||
'keybinds': list,
|
'keybinds': list,
|
||||||
@@ -350,21 +377,32 @@ index = index_byte + (0x100 if flag else 0)
|
|||||||
|
|
||||||
## 8. 技能索引到名称的解析
|
## 8. 技能索引到名称的解析
|
||||||
|
|
||||||
`decode_skill_hash()` 只负责二进制结构和技能索引,不直接写入名称。
|
`decode_skill_hash()` 保留代码中的原始 `skills`,并根据 Header 职业读取本地
|
||||||
|
`backend/openapi/static/data/cn/{job_name}/{job_grow_name}/skill_tree.json`,新增:
|
||||||
|
|
||||||
`get_skill_tree_info()` 根据职业加载:
|
```python
|
||||||
|
'auto_skills': [
|
||||||
```text
|
{'skill_index': 174, 'level': 115},
|
||||||
backend/openapi/static/data/cn/{job_name}/{job_grow_name}/skill_tree.json
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
并通过 `load_skill_tree_name_map()` 建立:
|
仅补充具有 `autoLevel` 且未出现在原始 `skills` 中的技能,等级直接使用
|
||||||
|
`autoLevel`,不覆盖代码中的显式等级。没有补充项时返回空列表。
|
||||||
|
这些条目来自技能树,没有二进制 `skill_flag` / `level_flag`。
|
||||||
|
解码不创建角色;通过 `_get_skill_tree_context()` 优先读取 Redis,缓存键为
|
||||||
|
`openapi:skillTreeMap:v1:{job_name}:{job_grow_name}:map`,与旧名称映射隔离。
|
||||||
|
未命中或 Redis 读写失败时,按现有 `get_redis_info()` 规则回退本地加载;
|
||||||
|
DEBUG 模式跳过 Redis。文件加载另有进程内缓存,更新数据需清理对应 Redis
|
||||||
|
缓存并重启进程(或升级缓存版本)。
|
||||||
|
|
||||||
```text
|
解析和计算器适配分为三步:
|
||||||
OpenAPI 技能索引 -> 技能名称
|
|
||||||
```
|
|
||||||
|
|
||||||
随后再按技能名称连接到 `character.skills` 中的计算对象。
|
1. `decode_skill_hash(encoded)`:二进制解析及 `auto_skills` 补充。
|
||||||
|
2. `resolve_skill_tree(decoded, skill_tree)`:合并两类技能,映射名称、进化、强化、快捷栏和排列。
|
||||||
|
`get_openapi_skill_tree_info(encoded)` 封装前两步,以 OpenAPI ID 返回结果,不调用 `createCharacter`。
|
||||||
|
3. `get_skill_tree_info(encoded)`:调用上述入口,再按名称匹配计算器技能,保持现有接口字段和计算器 ID。
|
||||||
|
|
||||||
|
尚未新增 HTTP 路由;后续对外接口可直接调用 `get_openapi_skill_tree_info()`。
|
||||||
|
|
||||||
需要区分:
|
需要区分:
|
||||||
|
|
||||||
@@ -391,7 +429,7 @@ OpenAPI 技能索引 -> 技能名称
|
|||||||
- Header 中未命名的常量或状态字节;
|
- Header 中未命名的常量或状态字节;
|
||||||
- Section2 首字节 marker 的业务含义;
|
- Section2 首字节 marker 的业务含义;
|
||||||
- 黑暗武士排列前缀之后的 remaining 数据;
|
- 黑暗武士排列前缀之后的 remaining 数据;
|
||||||
- 普通职业 remaining 中非零字段的业务含义;
|
- 普通职业 remaining 中除上述技能连招以外的字段含义;
|
||||||
- 尾部 `0xF3` 之前其他状态值的业务含义。
|
- 尾部 `0xF3` 之前其他状态值的业务含义。
|
||||||
|
|
||||||
在获得“只修改一个游戏设置后重新导出的对照代码”之前,不应为这些字节添加业务字段。
|
在获得“只修改一个游戏设置后重新导出的对照代码”之前,不应为这些字节添加业务字段。
|
||||||
|
|||||||
+198
-129
@@ -3,8 +3,7 @@ import zlib
|
|||||||
import struct
|
import struct
|
||||||
|
|
||||||
from api.core.Redis import get_redis_info
|
from api.core.Redis import get_redis_info
|
||||||
from api.utils.skill_tree import SKILL_TREE_NAME_MAP_VERSION, load_skill_tree_name_map
|
from api.utils.skill_tree import SKILL_TREE_MAP_VERSION, load_skill_tree_map
|
||||||
from core.basic.character import createCharacter
|
|
||||||
from core.character.adventure import get_adv_info
|
from core.character.adventure import get_adv_info
|
||||||
from database.connect import get_redis_client
|
from database.connect import get_redis_client
|
||||||
|
|
||||||
@@ -50,17 +49,47 @@ role_map = {
|
|||||||
|
|
||||||
def decode_skill_hash(encoded: str) -> dict:
|
def decode_skill_hash(encoded: str) -> dict:
|
||||||
"""
|
"""
|
||||||
解码 DNF 技能加点编码。
|
解码 DNF 技能加点编码,并根据职业技能树补充自动学习技能。
|
||||||
|
|
||||||
编码流程: base64 -> zlib decompress -> 二进制
|
输入 encoded 为游戏导出的 Base64 加点字符串。
|
||||||
|
解码流程: base64 -> zlib decompress -> 二进制
|
||||||
二进制结构:
|
二进制结构:
|
||||||
[0xF3] [Header 14B] [UTF-16LE 角色名]
|
[Header 14B,首字节 0xF3] [UTF-16LE 角色名]
|
||||||
[Section1: N个(技能索引,等级)对]
|
[Section1: N个(技能索引,等级)对]
|
||||||
[Section2: 进化 | 强化 | 键位 | 其他]
|
[Section2: 进化 | 强化 | 键位 | 职业排列及其他尾部数据]
|
||||||
[零填充] [0xF3]
|
|
||||||
|
|
||||||
Section1 中每个 uint16 的高字节=数据值,低字节=标志位。
|
Section1 中每个 uint16 的高字节=数据值,低字节=标志位。
|
||||||
Section2 按字节读取,包含三部分定长子结构。
|
技能索引在 level_flag 非零时加 0x100。
|
||||||
|
Section2 按字节读取,各子结构由数量字段和对应数量的定长条目组成;
|
||||||
|
其中技能索引为低字节加 0x100(第二字节标志非零时)。
|
||||||
|
|
||||||
|
所有职业均返回:
|
||||||
|
header: 角色名、等级、职业字节、编码版本等头信息。
|
||||||
|
skills: 原始加点列表,每项包含 skill_index、level、skill_flag、
|
||||||
|
level_flag;不将自动学习技能混入此列表。
|
||||||
|
auto_skills: 技能树中具有 autoLevel 且未出现在 skills 中的技能,
|
||||||
|
每项为 {skill_index, level},等级直接取 autoLevel。
|
||||||
|
不覆盖显式加点,无补充项时为 []。
|
||||||
|
evolutions: 进化列表,每项为 {skill_index, flag, type},type 从 1 开始。
|
||||||
|
enhancements: 强化列表,字段与 evolutions 相同。
|
||||||
|
keybinds: 快捷栏列表,每项为 {slot, skill_index, flag};
|
||||||
|
slot 从 0 开始,空槽的 skill_index 为 None。
|
||||||
|
section2_remaining: 快捷栏之后的完整原始字节,不因扩展解析而截断。
|
||||||
|
若角色名之后的数据长度为奇数,末尾含对齐补入的 0x00。
|
||||||
|
raw_size: zlib 解压后的原始字节数,不包含解析器补入的对齐字节。
|
||||||
|
|
||||||
|
按职业和版本额外返回:
|
||||||
|
arrangements: 仅黑暗武士返回,格式为 [{slot_index, skills}],
|
||||||
|
skills 保留该排列内技能索引的顺序。
|
||||||
|
skill_combo: 仅版本 5 的非黑暗武士返回,格式为
|
||||||
|
{skills: [309, 305, 312, 311], interval_ms: 5000}。
|
||||||
|
skills 为四个有序槽位,空槽为 None;interval_ms 为
|
||||||
|
初始化间隔(毫秒)。读取 remaining[92:100] 的技能索引
|
||||||
|
和 remaining[100:102] 的小端 uint16 间隔;长度不足或
|
||||||
|
remaining[102] 不是 0xF3 时返回 None。
|
||||||
|
remaining[91] 含义未确认,不解释为数量或开关。
|
||||||
|
|
||||||
|
未确认的尾部字段仅保留原始字节;黑暗武士不套用普通职业连招偏移。
|
||||||
"""
|
"""
|
||||||
raw = zlib.decompress(base64.b64decode(encoded))
|
raw = zlib.decompress(base64.b64decode(encoded))
|
||||||
header = _parse_header(raw)
|
header = _parse_header(raw)
|
||||||
@@ -103,6 +132,16 @@ def decode_skill_hash(encoded: str) -> dict:
|
|||||||
# 黑暗武士才有技能排列;其他职业保持原有返回结构不变。
|
# 黑暗武士才有技能排列;其他职业保持原有返回结构不变。
|
||||||
if header['job_bytes'][0] == 9:
|
if header['job_bytes'][0] == 9:
|
||||||
result['arrangements'] = _parse_dark_knight_arrangements(remaining)
|
result['arrangements'] = _parse_dark_knight_arrangements(remaining)
|
||||||
|
elif header['version'] == 5:
|
||||||
|
# 普通职业的技能连招与黑暗武士排列是两种结构,不能共用偏移。
|
||||||
|
result['skill_combo'] = _parse_skill_combo(remaining)
|
||||||
|
_, skill_tree = _get_skill_tree_context(header)
|
||||||
|
encoded_ids = {str(skill['skill_index']) for skill in skills}
|
||||||
|
result['auto_skills'] = [
|
||||||
|
{'skill_index': int(skill_id), 'level': metadata['autoLevel']}
|
||||||
|
for skill_id, metadata in skill_tree.items()
|
||||||
|
if 'autoLevel' in metadata and skill_id not in encoded_ids
|
||||||
|
]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -129,7 +168,21 @@ def _parse_section2(sec2: bytes):
|
|||||||
byte[2+3E+1..2+3E+3H]: H 个强化条目,每条 3 字节
|
byte[2+3E+1..2+3E+3H]: H 个强化条目,每条 3 字节
|
||||||
byte[2+3E+3H+1]: 键位数量 K (固定 0x0E=14)
|
byte[2+3E+3H+1]: 键位数量 K (固定 0x0E=14)
|
||||||
byte[..+2K]: K 个键位条目,每条 2 字节 (技能索引, 标志)
|
byte[..+2K]: K 个键位条目,每条 2 字节 (技能索引, 标志)
|
||||||
剩余: 其他数据
|
剩余: remaining = sec2[off:],包含职业排列、技能连招及未解析数据。
|
||||||
|
|
||||||
|
技能连招位于快捷栏之后的 remaining 中,不属于上面的 K 个键位条目。
|
||||||
|
版本 5 普通职业(不含黑暗武士)的已确认布局如下,偏移从 remaining
|
||||||
|
开头计算,不是从 sec2 或整个解压数据开头计算:
|
||||||
|
remaining[91]: 含义未确认,不解释为数量或开关。
|
||||||
|
remaining[92:100]: 四个有序连招技能,每项 2 字节;索引为低字节
|
||||||
|
加 0x100(第二字节非零时),0 表示空槽。
|
||||||
|
remaining[100:102]: 初始化间隔,小端 uint16,单位毫秒。
|
||||||
|
remaining[102]: 尾部标记 0xF3,其后可能有解析器补入的对齐 0x00。
|
||||||
|
|
||||||
|
本函数仅返回完整 remaining,不直接解析连招;decode_skill_hash()
|
||||||
|
根据职业和版本调用 _parse_skill_combo(),输出 skill_combo。
|
||||||
|
黑暗武士的 remaining 前缀由 _parse_dark_knight_arrangements() 解析,
|
||||||
|
不套用上述普通职业偏移。任何扩展解析均不截断原始 remaining。
|
||||||
"""
|
"""
|
||||||
if len(sec2) < 2:
|
if len(sec2) < 2:
|
||||||
return [], [], [], sec2
|
return [], [], [], sec2
|
||||||
@@ -194,6 +247,30 @@ def _parse_section2(sec2: bytes):
|
|||||||
return evolutions, enhancements, keybinds, remaining
|
return evolutions, enhancements, keybinds, remaining
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_skill_combo(remaining: bytes) -> dict | None:
|
||||||
|
"""读取版本 5 普通职业尾部的技能连招,不修改 remaining。
|
||||||
|
|
||||||
|
已通过女蓝拳顺序确认及仅将间隔从 0 秒改为 5 秒的代码验证:
|
||||||
|
remaining[92:100]:四个技能索引,每个 2 字节,0 表示空槽;
|
||||||
|
remaining[100:102]:小端 uint16 初始化间隔,单位毫秒;
|
||||||
|
remaining[102]:尾部 0xF3,解析器可能在它后面补齐一个 0x00。
|
||||||
|
remaining[91] 的含义尚未确认,不将其解释为数量或开关。
|
||||||
|
数据不足或尾部标记不匹配时返回 None,避免把未知布局当成空连招。
|
||||||
|
"""
|
||||||
|
if len(remaining) < 103 or remaining[102] != 0xF3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
skills = []
|
||||||
|
for offset in range(92, 100, 2):
|
||||||
|
# 与快捷栏一致:低字节为索引,第二字节非零表示加 0x100。
|
||||||
|
index = remaining[offset] + (0x100 if remaining[offset + 1] else 0)
|
||||||
|
skills.append(index if index else None)
|
||||||
|
return {
|
||||||
|
'skills': skills,
|
||||||
|
'interval_ms': struct.unpack_from('<H', remaining, 100)[0],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_dark_knight_arrangements(remaining: bytes) -> list[dict]:
|
def _parse_dark_knight_arrangements(remaining: bytes) -> list[dict]:
|
||||||
"""从原始 remaining 前缀中读取黑暗武士技能排列。
|
"""从原始 remaining 前缀中读取黑暗武士技能排列。
|
||||||
|
|
||||||
@@ -236,24 +313,52 @@ def _parse_dark_knight_arrangements(remaining: bytes) -> list[dict]:
|
|||||||
return arrangements
|
return arrangements
|
||||||
|
|
||||||
|
|
||||||
def _resolve_openapi_skill_id(skill_index: int, skill_name_map: dict[str, str]) -> int | None:
|
def resolve_skill_tree(decoded: dict, skill_tree: dict[str, dict]) -> dict:
|
||||||
if str(skill_index) in skill_name_map:
|
"""将二进制结果映射到 OpenAPI 技能 ID;不依赖角色计算对象。
|
||||||
return skill_index
|
|
||||||
return None
|
autoLevel 仅补充代码中缺失的技能,不覆盖显式等级。
|
||||||
|
"""
|
||||||
|
skills = {}
|
||||||
|
for item in decoded['skills'] + decoded['auto_skills']:
|
||||||
|
skill_id = str(item['skill_index'])
|
||||||
|
metadata = skill_tree.get(skill_id)
|
||||||
|
if metadata is None:
|
||||||
|
continue
|
||||||
|
if skill_id not in skills or item['level'] > skills[skill_id]['lv']:
|
||||||
|
skills[skill_id] = {
|
||||||
|
'lv': item['level'], 'up': 0, 'vp': 0, 'name': metadata['name'],
|
||||||
|
}
|
||||||
|
for section, field in [('evolutions', 'vp'), ('enhancements', 'up')]:
|
||||||
|
for item in decoded[section]:
|
||||||
|
skill = skills.get(str(item['skill_index']))
|
||||||
|
if skill is not None:
|
||||||
|
skill[field] = item['type']
|
||||||
|
|
||||||
|
def reference(skill_index):
|
||||||
|
metadata = skill_tree.get(str(skill_index))
|
||||||
|
return {
|
||||||
|
'skill_id': skill_index if metadata is not None else None,
|
||||||
|
'skill_name': metadata['name'] if metadata is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'skills': skills,
|
||||||
|
'shortcuts': [
|
||||||
|
{'slot': item['slot'], **reference(item['skill_index'])}
|
||||||
|
for item in decoded['keybinds']
|
||||||
|
],
|
||||||
|
'combos': [
|
||||||
|
{
|
||||||
|
'slot_index': item['slot_index'],
|
||||||
|
'skills': [reference(index) for index in item['skills']],
|
||||||
|
}
|
||||||
|
for item in decoded.get('arrangements', [])
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# def _resolve_section_skill_id(skill_index: int, skill_name_map: dict[str, str]) -> int | None:
|
def _get_skill_tree_context(header: dict) -> tuple[dict, dict[str, dict]]:
|
||||||
# high_skill_id = skill_index + 0x100
|
"""根据编码职业读取 Redis 技能树缓存,未命中时回退本地文件。"""
|
||||||
# if str(high_skill_id) in skill_name_map:
|
|
||||||
# return high_skill_id
|
|
||||||
# if str(skill_index) in skill_name_map:
|
|
||||||
# return skill_index
|
|
||||||
# return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_skill_tree_info(encoded: str) -> dict:
|
|
||||||
r = decode_skill_hash(encoded)
|
|
||||||
header = r['header']
|
|
||||||
role = role_map.get(str(header['job_bytes'][0]), None)
|
role = role_map.get(str(header['job_bytes'][0]), None)
|
||||||
job_id = int(header['job_bytes'][1]) - 1
|
job_id = int(header['job_bytes'][1]) - 1
|
||||||
# 黑暗武士
|
# 黑暗武士
|
||||||
@@ -269,119 +374,83 @@ def get_skill_tree_info(encoded: str) -> dict:
|
|||||||
job_name = class_parts[1]
|
job_name = class_parts[1]
|
||||||
job_grow_name = class_parts[2].replace('_carry', '')
|
job_grow_name = class_parts[2].replace('_carry', '')
|
||||||
|
|
||||||
def get_skill_name_map():
|
|
||||||
return load_skill_tree_name_map(job_name, job_grow_name)
|
|
||||||
|
|
||||||
with get_redis_client() as redis:
|
with get_redis_client() as redis:
|
||||||
skill_name_map = get_redis_info(
|
skill_tree = get_redis_info(
|
||||||
redis,
|
redis,
|
||||||
f'openapi:{SKILL_TREE_NAME_MAP_VERSION}:{job_name}:{job_grow_name}:nameMap',
|
f'openapi:{SKILL_TREE_MAP_VERSION}:{job_name}:{job_grow_name}:map',
|
||||||
get_skill_name_map,
|
lambda: load_skill_tree_map(job_name, job_grow_name),
|
||||||
)
|
)
|
||||||
|
return advInfo, skill_tree
|
||||||
|
|
||||||
character = createCharacter(className)
|
|
||||||
skillMap = {}
|
|
||||||
for index, i in enumerate(r['skills']):
|
|
||||||
skillIndex = i['skill_index']
|
|
||||||
actualSkillId = _resolve_openapi_skill_id(skillIndex, skill_name_map)
|
|
||||||
skillName = skill_name_map.get(str(actualSkillId)) if actualSkillId is not None else None
|
|
||||||
skills = [skill for skill in character.skills if skill.name == skillName] if skillName else []
|
|
||||||
i['actual_skill_id'] = actualSkillId
|
|
||||||
i['skill_name'] = skillName
|
|
||||||
i['candidates'] = skills[0] if skills else None
|
|
||||||
if actualSkillId is not None:
|
|
||||||
skillMap[actualSkillId] = skills
|
|
||||||
if len(skills) == 0:
|
|
||||||
r['skills'][index] = None
|
|
||||||
for k, v in skillMap.items():
|
|
||||||
if len(v) > 1:
|
|
||||||
skillMap[k] = [s for s in v if s.learnLv not in [50, 85]]
|
|
||||||
skillLearn = {}
|
|
||||||
# 处理技能等级
|
|
||||||
for skill in r['skills']:
|
|
||||||
if skill is None:
|
|
||||||
continue
|
|
||||||
candidates = skill.get('candidates', [])
|
|
||||||
skillName = skill.get('skill_name')
|
|
||||||
chosen = None
|
|
||||||
if candidates:
|
|
||||||
chosen = candidates
|
|
||||||
if chosen and skillLearn.get(str(chosen.id), {'lv': 0})['lv'] < skill['level']:
|
|
||||||
skillLearn[str(chosen.id)] = {
|
|
||||||
'lv': skill['level'],
|
|
||||||
'up': 0,
|
|
||||||
'vp': 0,
|
|
||||||
'name': skillName or chosen.name,
|
|
||||||
}
|
|
||||||
# 处理进化
|
|
||||||
for evo in r['evolutions']:
|
|
||||||
actualSkillId = _resolve_openapi_skill_id(evo['skill_index'], skill_name_map)
|
|
||||||
for s in skillMap.get(actualSkillId, []):
|
|
||||||
if getattr(s, 'hasVP', False) and str(s.id) in skillLearn:
|
|
||||||
skillLearn[str(s.id)]['vp'] = evo['type']
|
|
||||||
|
|
||||||
# 处理强化
|
def get_openapi_skill_tree_info(encoded: str) -> dict:
|
||||||
for enh in r['enhancements']:
|
"""解析并补全技能树,返回 OpenAPI 技能 ID,不创建计算角色。"""
|
||||||
actualSkillId = _resolve_openapi_skill_id(enh['skill_index'], skill_name_map)
|
r = decode_skill_hash(encoded)
|
||||||
for s in skillMap.get(actualSkillId, []):
|
header = r['header']
|
||||||
if getattr(s, 'hasUP', False) and str(s.id) in skillLearn:
|
advInfo, skill_tree = _get_skill_tree_context(header)
|
||||||
skillLearn[str(s.id)]['up'] = enh['type']
|
|
||||||
for i in character.skills:
|
|
||||||
if str(i.id) not in skillLearn:
|
|
||||||
skillLearn[str(i.id)] = {
|
|
||||||
'lv': i.calculate_lv() if (i.learnLv in [50,85] or i.name == '基础精通') and str(i.id) else 0,
|
|
||||||
'up': 0,
|
|
||||||
'vp': 0,
|
|
||||||
'name': i.name,
|
|
||||||
}
|
|
||||||
shortcuts = []
|
|
||||||
for k in r['keybinds']:
|
|
||||||
actualSkillId = _resolve_openapi_skill_id(k['skill_index'], skill_name_map)
|
|
||||||
if actualSkillId is None:
|
|
||||||
shortcuts.append({
|
|
||||||
'slot': k['slot'],
|
|
||||||
'skill_id': None,
|
|
||||||
'skill_name': None,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
for s in skillMap.get(actualSkillId, []):
|
|
||||||
if str(s.id) in skillLearn:
|
|
||||||
shortcuts.append({
|
|
||||||
'slot': k['slot'],
|
|
||||||
'skill_id': s.id,
|
|
||||||
'skill_name': s.name,
|
|
||||||
})
|
|
||||||
if s is None:
|
|
||||||
shortcuts.append({
|
|
||||||
'slot': k['slot'],
|
|
||||||
'skill_id': None,
|
|
||||||
'skill_name': None,
|
|
||||||
})
|
|
||||||
combos = []
|
|
||||||
for a in r.get('arrangements', []):
|
|
||||||
combo = {
|
|
||||||
'slot_index': a['slot_index'],
|
|
||||||
'skills': [],
|
|
||||||
}
|
|
||||||
for skillIndex in a['skills']:
|
|
||||||
actualSkillId = _resolve_openapi_skill_id(skillIndex, skill_name_map)
|
|
||||||
for s in skillMap.get(actualSkillId, []):
|
|
||||||
if str(s.id) in skillLearn:
|
|
||||||
combo['skills'].append({
|
|
||||||
'skill_id': s.id,
|
|
||||||
'skill_name': s.name,
|
|
||||||
})
|
|
||||||
combos.append(combo)
|
|
||||||
return {
|
return {
|
||||||
"name": header['name'],
|
'name': header['name'],
|
||||||
"level": header['level'],
|
'level': header['level'],
|
||||||
"job":advInfo,
|
'job': advInfo,
|
||||||
"skills": skillLearn,
|
**resolve_skill_tree(r, skill_tree),
|
||||||
"shortcuts":shortcuts,
|
|
||||||
"combos":combos,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_skill_tree_info(encoded: str) -> dict:
|
||||||
|
"""将完整 OpenAPI 加点映射为计算器技能 ID,保持现有接口格式。"""
|
||||||
|
from core.basic.character import createCharacter
|
||||||
|
|
||||||
|
info = get_openapi_skill_tree_info(encoded)
|
||||||
|
character = createCharacter(info['job']['class'])
|
||||||
|
skills_by_name = {}
|
||||||
|
for skill in character.skills:
|
||||||
|
skills_by_name.setdefault(skill.name, []).append(skill)
|
||||||
|
|
||||||
|
skill_learn = {}
|
||||||
|
for item in info['skills'].values():
|
||||||
|
candidates = skills_by_name.get(item['name'], [])
|
||||||
|
if not candidates:
|
||||||
|
continue
|
||||||
|
# 保持原有同名技能等级选首项、进化/强化排除觉醒分身的规则。
|
||||||
|
chosen = candidates[0]
|
||||||
|
chosen_id = str(chosen.id)
|
||||||
|
if chosen_id not in skill_learn or skill_learn[chosen_id]['lv'] < item['lv']:
|
||||||
|
skill_learn[chosen_id] = {**item, 'up': 0, 'vp': 0}
|
||||||
|
targets = [s for s in candidates if s.learnLv not in [50, 85]] if len(candidates) > 1 else candidates
|
||||||
|
for skill in targets:
|
||||||
|
learned = skill_learn.get(str(skill.id))
|
||||||
|
if learned is not None:
|
||||||
|
if getattr(skill, 'hasVP', False):
|
||||||
|
learned['vp'] = item['vp']
|
||||||
|
if getattr(skill, 'hasUP', False):
|
||||||
|
learned['up'] = item['up']
|
||||||
|
|
||||||
|
for skill in character.skills:
|
||||||
|
skill_learn.setdefault(str(skill.id), {
|
||||||
|
'lv': skill.calculate_lv() if skill.name == '基础精通' else 0,
|
||||||
|
'up': 0, 'vp': 0, 'name': skill.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
def match_reference(item):
|
||||||
|
candidates = skills_by_name.get(item['skill_name'], [])
|
||||||
|
if len(candidates) > 1:
|
||||||
|
candidates = [s for s in candidates if s.learnLv not in [50, 85]]
|
||||||
|
return [{'skill_id': s.id, 'skill_name': s.name} for s in candidates]
|
||||||
|
|
||||||
|
shortcuts = []
|
||||||
|
for item in info['shortcuts']:
|
||||||
|
matches = match_reference(item) or [{'skill_id': None, 'skill_name': None}]
|
||||||
|
shortcuts.extend({'slot': item['slot'], **match} for match in matches)
|
||||||
|
combos = [
|
||||||
|
{
|
||||||
|
'slot_index': combo['slot_index'],
|
||||||
|
'skills': [match for item in combo['skills'] for match in match_reference(item)],
|
||||||
|
}
|
||||||
|
for combo in info['combos']
|
||||||
|
]
|
||||||
|
return {**info, 'skills': skill_learn, 'shortcuts': shortcuts, 'combos': combos}
|
||||||
|
|
||||||
|
|
||||||
# def print_decoded(encoded: str):
|
# def print_decoded(encoded: str):
|
||||||
# """解码并打印技能编码的全部信息。"""
|
# """解码并打印技能编码的全部信息。"""
|
||||||
# r = decode_skill_hash(encoded)
|
# r = decode_skill_hash(encoded)
|
||||||
|
|||||||
+1
-1
Submodule backend/openapi updated: a831e4a141...80468f2cbd
@@ -0,0 +1,132 @@
|
|||||||
|
import base64
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
from contextlib import nullcontext
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.open import skillTree
|
||||||
|
|
||||||
|
from api.utils.skill_tree import load_skill_tree_map
|
||||||
|
from core.open.skillTree import decode_skill_hash, get_openapi_skill_tree_info, get_skill_tree_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def no_live_redis(monkeypatch):
|
||||||
|
monkeypatch.setattr(skillTree, 'get_redis_client', lambda: nullcontext(None))
|
||||||
|
|
||||||
|
|
||||||
|
def encode(skills=(), job=(0, 3), remaining=b''):
|
||||||
|
header = bytearray(14)
|
||||||
|
header[0] = 0xF3
|
||||||
|
header[2:4] = bytes(job)
|
||||||
|
header[5] = 115
|
||||||
|
header[8] = ord('K')
|
||||||
|
data = struct.pack('<H', len(skills) << 8)
|
||||||
|
for index, level in skills:
|
||||||
|
data += struct.pack('<HH', (index & 255) << 8, (level << 8) | (index >> 8))
|
||||||
|
# 无进化/强化,两个快捷栏:鬼斩、空槽。
|
||||||
|
data += bytes([0, 0, 0, 2, 5, 0, 0, 0]) + remaining
|
||||||
|
return base64.b64encode(zlib.compress(header + data)).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_adds_missing_auto_skills_without_overwriting_encoded_levels():
|
||||||
|
result = decode_skill_hash(encode([(5, 7)]))
|
||||||
|
assert result['skills'][0]['level'] == 7
|
||||||
|
automatic = {item['skill_index']: item['level'] for item in result['auto_skills']}
|
||||||
|
tree = load_skill_tree_map('swordman_male', 'berserker')
|
||||||
|
assert automatic == {
|
||||||
|
int(key): item['autoLevel'] for key, item in tree.items()
|
||||||
|
if 'autoLevel' in item and key != '5'
|
||||||
|
}
|
||||||
|
assert automatic[174] == 115
|
||||||
|
assert 'arrangements' not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_result_does_not_create_character(monkeypatch):
|
||||||
|
import core.basic.character as character
|
||||||
|
|
||||||
|
def fail(*args, **kwargs):
|
||||||
|
raise AssertionError('must not create a character')
|
||||||
|
|
||||||
|
monkeypatch.setattr(character, 'createCharacter', fail)
|
||||||
|
result = get_openapi_skill_tree_info(encode())
|
||||||
|
assert result['skills']['5']['lv'] == 1
|
||||||
|
assert result['shortcuts'] == [
|
||||||
|
{'slot': 0, 'skill_id': 5, 'skill_name': '鬼斩'},
|
||||||
|
{'slot': 1, 'skill_id': None, 'skill_name': None},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculator_receives_automatic_levels_and_shortcuts():
|
||||||
|
result = get_skill_tree_info(encode())
|
||||||
|
skills = {item['name']: item for item in result['skills'].values()}
|
||||||
|
assert skills['鬼斩']['lv'] == 1
|
||||||
|
assert skills['基础精通']['lv'] == 115
|
||||||
|
assert result['shortcuts'][0]['skill_name'] == '鬼斩'
|
||||||
|
|
||||||
|
|
||||||
|
def test_dark_knight_arrangements_and_remaining_are_preserved():
|
||||||
|
remaining = bytes([1, 118, 0, 1, 60, 0])
|
||||||
|
result = decode_skill_hash(encode(job=(9, 0), remaining=remaining))
|
||||||
|
assert result['arrangements'] == [{'slot_index': 118, 'skills': [60]}]
|
||||||
|
assert result['section2_remaining'] == remaining
|
||||||
|
assert result['auto_skills']
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_uses_separate_redis_map_cache(monkeypatch):
|
||||||
|
from config.main import config
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, 'DEBUG_MODE', False)
|
||||||
|
|
||||||
|
class FakeRedis:
|
||||||
|
value = None
|
||||||
|
keys = []
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
self.keys.append(key)
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def set(self, key, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
redis = FakeRedis()
|
||||||
|
monkeypatch.setattr(skillTree, 'get_redis_client', lambda: nullcontext(redis))
|
||||||
|
header = {'job_bytes': (0, 3)}
|
||||||
|
_, original = skillTree._get_skill_tree_context(header)
|
||||||
|
|
||||||
|
def fail(*args):
|
||||||
|
raise AssertionError('cache hit must not load local data')
|
||||||
|
|
||||||
|
monkeypatch.setattr(skillTree, 'load_skill_tree_map', fail)
|
||||||
|
_, cached = skillTree._get_skill_tree_context(header)
|
||||||
|
assert cached == original
|
||||||
|
assert cached['174']['autoLevel'] == 115
|
||||||
|
assert redis.keys == ['openapi:skillTreeMap:v1:swordman_male:berserker:map'] * 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(('code', 'interval_ms'), [
|
||||||
|
('eJytjb9OwnAABu9CmpQG0MUYjBYHaCRi8ytQW/7sDiQ46OxLuMArsTCwM/lGvICBZ+Cbbrjcd4o6UeOXzQqk1Qo/26/P+Jt1O3ykLI1c2HZuk5mxtXdW3vvuLaUPTtWJN47tUpgZTM199c3c0YUSX0wYGnxSMnWg9JVHE/bIDumpqXpEDsREuRZaayWlNEZSSS2dc3ro1MyZE/8srA0uXJ7VIzugLzw7cM/1Znl5qoTTP4KaGC0=', 0),
|
||||||
|
('eJytjT9OwnAARt8LaVIb/iXGEI1WB2gkQvOrUFuE3cFEBp25hIvewLOwMLgzcSMuQOAMfNMbXt63j9pR45ufd5BmM6x+P5fxFx+t8JayMHJuy1cvmBlbe2Vlzxe7lN44VSd2fPaawsxgau6TY3NHJ0p8NGFo8E7J1IHSV25N2CBr5F5N1S3yT0yUa6G1VlJKYySV1NI+podOzZw5cWdhbXDu4qhuWQN94cGBG843y9NT5d/l/gCEWBjI', 5000),
|
||||||
|
])
|
||||||
|
def test_confirmed_skill_combo_samples(code, interval_ms):
|
||||||
|
result = decode_skill_hash(code)
|
||||||
|
assert result['skill_combo'] == {
|
||||||
|
'skills': [309, 305, 312, 311], 'interval_ms': interval_ms,
|
||||||
|
}
|
||||||
|
raw = zlib.decompress(base64.b64decode(code))
|
||||||
|
assert result['section2_remaining'] == raw[-103:] + b'\x00'
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_combo_preserves_empty_slots_and_rejects_truncated_layout():
|
||||||
|
remaining = bytearray(103)
|
||||||
|
remaining[92:100] = struct.pack('<4H', 98, 114, 93, 0)
|
||||||
|
remaining[102] = 0xF3
|
||||||
|
original = bytes(remaining)
|
||||||
|
assert skillTree._parse_skill_combo(original) == {
|
||||||
|
'skills': [98, 114, 93, None], 'interval_ms': 0,
|
||||||
|
}
|
||||||
|
assert bytes(remaining) == original
|
||||||
|
assert skillTree._parse_skill_combo(original[:102]) is None
|
||||||
|
assert skillTree._parse_skill_combo(bytes(103)) is None
|
||||||
|
assert 'skill_combo' not in decode_skill_hash(encode(job=(9, 0)))
|
||||||
Reference in New Issue
Block a user