From 9453106dc1d268371ae4595caea3eab66276ba8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E7=93=9C=C2=B0?= Date: Mon, 14 Sep 2026 14:53:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=AE=8C=E5=96=84=E5=8A=A0=E7=82=B9?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/utils/skill_tree.py | 14 ++ backend/core/open/skillTree.md | 60 ++++- backend/core/open/skillTree.py | 327 ++++++++++++++++---------- backend/openapi | 2 +- backend/tests/unit/test_skill_tree.py | 132 +++++++++++ 5 files changed, 394 insertions(+), 141 deletions(-) create mode 100644 backend/tests/unit/test_skill_tree.py diff --git a/backend/api/utils/skill_tree.py b/backend/api/utils/skill_tree.py index 7b79ac16..f9424810 100644 --- a/backend/api/utils/skill_tree.py +++ b/backend/api/utils/skill_tree.py @@ -6,6 +6,20 @@ from api.utils.openapi_data import openapi_locale_path SKILL_TREE_WITH_DESC_SPECIAL_VERSION = 'skillTreeWithDescSpecial: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 diff --git a/backend/core/open/skillTree.md b/backend/core/open/skillTree.md index d1113a5f..0ed754a6 100644 --- a/backend/core/open/skillTree.md +++ b/backend/core/open/skillTree.md @@ -244,6 +244,32 @@ if len(data) % 2: 其中最后的 `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. 黑暗武士连续技排列 只有满足以下条件时,顶层结果才增加 `arrangements`: @@ -330,6 +356,7 @@ index = index_byte + (0x100 if flag else 0) { 'header': dict, 'skills': list, + 'auto_skills': list, 'evolutions': list, 'enhancements': list, 'keybinds': list, @@ -350,21 +377,32 @@ index = index_byte + (0x100 if flag else 0) ## 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()` 根据职业加载: - -```text -backend/openapi/static/data/cn/{job_name}/{job_grow_name}/skill_tree.json +```python +'auto_skills': [ + {'skill_index': 174, 'level': 115}, +] ``` -并通过 `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 中未命名的常量或状态字节; - Section2 首字节 marker 的业务含义; - 黑暗武士排列前缀之后的 remaining 数据; -- 普通职业 remaining 中非零字段的业务含义; +- 普通职业 remaining 中除上述技能连招以外的字段含义; - 尾部 `0xF3` 之前其他状态值的业务含义。 在获得“只修改一个游戏设置后重新导出的对照代码”之前,不应为这些字节添加业务字段。 diff --git a/backend/core/open/skillTree.py b/backend/core/open/skillTree.py index 2d9a9f6b..777cc6d7 100644 --- a/backend/core/open/skillTree.py +++ b/backend/core/open/skillTree.py @@ -3,8 +3,7 @@ import zlib import struct 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 core.basic.character import createCharacter +from api.utils.skill_tree import SKILL_TREE_MAP_VERSION, load_skill_tree_map from core.character.adventure import get_adv_info from database.connect import get_redis_client @@ -50,17 +49,47 @@ role_map = { 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个(技能索引,等级)对] - [Section2: 进化 | 强化 | 键位 | 其他] - [零填充] [0xF3] + [Section2: 进化 | 强化 | 键位 | 职业排列及其他尾部数据] 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)) header = _parse_header(raw) @@ -103,6 +132,16 @@ def decode_skill_hash(encoded: str) -> dict: # 黑暗武士才有技能排列;其他职业保持原有返回结构不变。 if header['job_bytes'][0] == 9: 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 @@ -129,7 +168,21 @@ def _parse_section2(sec2: bytes): byte[2+3E+1..2+3E+3H]: H 个强化条目,每条 3 字节 byte[2+3E+3H+1]: 键位数量 K (固定 0x0E=14) 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: return [], [], [], sec2 @@ -194,6 +247,30 @@ def _parse_section2(sec2: bytes): 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(' list[dict]: """从原始 remaining 前缀中读取黑暗武士技能排列。 @@ -236,24 +313,52 @@ def _parse_dark_knight_arrangements(remaining: bytes) -> list[dict]: return arrangements -def _resolve_openapi_skill_id(skill_index: int, skill_name_map: dict[str, str]) -> int | None: - if str(skill_index) in skill_name_map: - return skill_index - return None +def resolve_skill_tree(decoded: dict, skill_tree: dict[str, dict]) -> dict: + """将二进制结果映射到 OpenAPI 技能 ID;不依赖角色计算对象。 + + 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: -# high_skill_id = skill_index + 0x100 -# 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'] +def _get_skill_tree_context(header: dict) -> tuple[dict, dict[str, dict]]: + """根据编码职业读取 Redis 技能树缓存,未命中时回退本地文件。""" role = role_map.get(str(header['job_bytes'][0]), None) 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_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: - skill_name_map = get_redis_info( + skill_tree = get_redis_info( redis, - f'openapi:{SKILL_TREE_NAME_MAP_VERSION}:{job_name}:{job_grow_name}:nameMap', - get_skill_name_map, + f'openapi:{SKILL_TREE_MAP_VERSION}:{job_name}:{job_grow_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'] - # 处理强化 - for enh in r['enhancements']: - actualSkillId = _resolve_openapi_skill_id(enh['skill_index'], skill_name_map) - for s in skillMap.get(actualSkillId, []): - if getattr(s, 'hasUP', False) and str(s.id) in skillLearn: - 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) +def get_openapi_skill_tree_info(encoded: str) -> dict: + """解析并补全技能树,返回 OpenAPI 技能 ID,不创建计算角色。""" + r = decode_skill_hash(encoded) + header = r['header'] + advInfo, skill_tree = _get_skill_tree_context(header) return { - "name": header['name'], - "level": header['level'], - "job":advInfo, - "skills": skillLearn, - "shortcuts":shortcuts, - "combos":combos, + 'name': header['name'], + 'level': header['level'], + 'job': advInfo, + **resolve_skill_tree(r, skill_tree), } +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): # """解码并打印技能编码的全部信息。""" # r = decode_skill_hash(encoded) diff --git a/backend/openapi b/backend/openapi index a831e4a1..80468f2c 160000 --- a/backend/openapi +++ b/backend/openapi @@ -1 +1 @@ -Subproject commit a831e4a14135bf915e492644930c29e5f6bb803e +Subproject commit 80468f2cbd2aecf952a185b9ae05878089745f84 diff --git a/backend/tests/unit/test_skill_tree.py b/backend/tests/unit/test_skill_tree.py new file mode 100644 index 00000000..cee23701 --- /dev/null +++ b/backend/tests/unit/test_skill_tree.py @@ -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('> 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)))