feat:完善加点代码导入

This commit is contained in:
2026-09-14 14:53:05 +08:00
parent e65dfef083
commit 9453106dc1
5 changed files with 394 additions and 141 deletions
+14
View File
@@ -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
+49 -11
View File
@@ -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
View File
@@ -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)
+132
View File
@@ -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)))