"""从 skill_tree.json 生成职业技能引用及中文悬停说明。 默认读取仓库内中文技能树;--source-root 可指向 dnfSkillData/openapi。 只生成标准引用,不改技能计算代码,也不为未知技能猜测映射。 使用 --check 校验已提交产物是否与输入一致;校验模式不写文件。 """ from __future__ import annotations import argparse import json from pathlib import Path import re import sys ROOT = Path(__file__).resolve().parents[1] SOURCE_ROOT = ROOT / 'backend/openapi/static/data/cn' OUTPUT_ROOT = ROOT / 'backend/core/character/refs' MARKER = '# 此文件由 scripts/generate_skill_refs.py 自动生成,请勿手工修改。' # 显式公共技能清单;各职业输入必须包含这些 code,且名称一致。 COMMON_SKILLS = { 'physicalcriticalhitup': '暴击', 'backstep': '后跳', 'basicattackup': '基础精通', 'escapebackstep': '强化 - 后跳', 'jumppowerup': '跃翔', 'quickstanding': '受身蹲伏', 'armormasteryetc': '防具精通', } # 两项觉醒技能按原始 code 后缀分组,所有定义集中在 common.py。 # 只允许明确确认的名称差异,不用模糊名称推测 code。 AWAKENING_SKILLS = { str(index): { f'clueofawakening{index}': ('觉醒之抉择', '觉醒之心') if index == 1 else ('觉醒之抉择',), f'instantrealization{index}': ('卓越之力',), } for index in range(1, 6) } COMBO_SKILLS = {'skillpresettype1': '技能连招'} COMBO_EXCLUDED_PROFESSIONS = {'extra.creator', 'extra.dark_knight'} def render_common() -> str: """集中生成基础、觉醒变体及连招引用,职业按实际技能树继承适用分组。""" lines = [MARKER, '"""公共技能引用;保留觉醒 code 变体,按职业技能树选择继承分组。"""', 'from core.character.refs.base import SkillRef, SkillReferences', '', '', 'class CommonSkillRefs(SkillReferences):', ' """全部职业共享的七项基础引用,仅共享身份,不合并技能计算实现。"""'] def append_members(skills): for code, label in sorted(skills.items()): lines.extend(['', f' {member_name(code)} = SkillRef(name={label!r}, code={code!r})', ' ' + json.dumps(label, ensure_ascii=False)]) append_members(COMMON_SKILLS) for suffix, skills in AWAKENING_SKILLS.items(): lines.extend(['', '', f'class Awakening{suffix}SkillRefs(SkillReferences):', f' """觉醒公共技能第 {suffix} 组;后缀沿用职业技能树原始 code。"""']) append_members({code: ' / '.join(labels) for code, labels in skills.items()}) lines.extend(['', '', 'class ComboSkillRefs(SkillReferences):', ' """拥有技能连招的职业共享的引用;创造者、黑暗武士不继承。"""']) append_members(COMBO_SKILLS) return '\n'.join(lines) + '\n' def common_groups(entries: dict, profession: str) -> tuple[list[str], set[str]]: """校验公共技能覆盖和名称,返回适用继承分组及本职业的公共 code。""" codes = {code: label for code, label in entries.values()} for code, label in COMMON_SKILLS.items(): if code not in codes: raise ValueError(f'{profession}: 缺少公共技能 code: {code}') if codes[code] != label: raise ValueError(f'{profession}: 公共技能名称不一致: {code}: {codes[code]!r} != {label!r}') groups = ['CommonSkillRefs'] shared = set(COMMON_SKILLS) variants = set().union(*(set(skills) for skills in AWAKENING_SKILLS.values())) selected = variants.intersection(codes) matching = [suffix for suffix, skills in AWAKENING_SKILLS.items() if set(skills) == selected] if len(matching) != 1: raise ValueError(f'{profession}: 觉醒公共技能必须为一组完整且唯一的 code: {sorted(selected)}') suffix = matching[0] for code, labels in AWAKENING_SKILLS[suffix].items(): if codes[code] not in labels: raise ValueError(f'{profession}: 觉醒公共技能名称不一致: {code}: {codes[code]!r}') groups.append(f'Awakening{suffix}SkillRefs') shared.update(selected) for code, label in COMBO_SKILLS.items(): expected = profession not in COMBO_EXCLUDED_PROFESSIONS if (code in codes) != expected: raise ValueError(f'{profession}: 技能连招覆盖与公共清单不一致: {code}') if expected: if codes[code] != label: raise ValueError(f'{profession}: 公共技能名称不一致: {code}') groups.append('ComboSkillRefs') shared.add(code) return groups, shared def member_name(code: str) -> str: """规范化 Python 成员名,原始 code 值保持不变;数字开头时添加 SKILL_。""" if not isinstance(code, str) or not re.fullmatch(r'[A-Za-z0-9_\-]+', code): raise ValueError(f'无效技能 code: {code!r}') name = code.replace('-', '_').upper() if not name or name.startswith('_'): raise ValueError(f'无效技能成员名: {code!r}') return f'SKILL_{name}' if name[0].isdigit() else name def render_tree(path: Path, relative: Path) -> str: """校验单职业技能树并渲染引用文件,重复 code 或成员名均报错。""" job, profession, _ = relative.parts if any(not re.fullmatch(r'[a-z][a-z0-9_]*', part) for part in (job, profession)): raise ValueError(f'无效职业目录: {relative}') rows = json.loads(path.read_text(encoding='utf-8')) if not isinstance(rows, list) or not rows: raise ValueError(f'{relative}: 技能树必须是非空数组') entries = {} codes = set() for row in rows: if not isinstance(row, dict): raise ValueError(f'{relative}: 技能记录必须是对象') code, label = row.get('code'), row.get('name') if not isinstance(label, str) or not label.strip(): raise ValueError(f'{relative}: 技能缺少中文名称: {code!r}') member = member_name(code) if code in codes: raise ValueError(f'{relative}: 重复 code: {code}') if member in entries: raise ValueError(f'{relative}: 成员名冲突: {entries[member][0]} / {code} -> {member}') codes.add(code) entries[member] = (code, label) groups, shared = common_groups(entries, f'{job}.{profession}') common_members = {member_name(code) for code in shared} class_name = ''.join(part.capitalize() for part in profession.split('_')) + 'SkillRef' lines = [ MARKER, f'"""技能引用来源:{relative.as_posix()};公共技能继承自 common.py 中的适用分组。"""', 'from core.character.refs.base import SkillRef', 'from core.character.refs.common import ' + ', '.join(groups), '', '', f'class {class_name}({", ".join(groups)}):', f' """{job}.{profession} 的技能引用;包含继承的公共技能。"""', f" __profession__ = '{job}.{profession}'", ] for member, (code, label) in sorted(entries.items()): if code in shared: continue if member in common_members: raise ValueError(f'{relative}: 职业成员覆盖公共引用: {member}') lines += ['', f' {member} = SkillRef(name={label!r}, code={code!r}, profession=__profession__)', ' ' + json.dumps(label, ensure_ascii=False)] return '\n'.join(lines) + '\n' def expected_files(source_root: Path) -> dict[Path, str]: """先校验全部输入,再返回期望产物,避免输入错误造成部分生成。""" trees = sorted(source_root.glob('*/*/skill_tree.json')) if not trees: raise ValueError(f'未找到职业技能树: {source_root}') files = {Path('common.py'): render_common()} for tree in trees: relative = tree.relative_to(source_root) job, profession, _ = relative.parts files[Path(job) / f'{profession}.py'] = render_tree(tree, relative) files[Path(job) / '__init__.py'] = MARKER + '\n' + '"""职业技能引用分组;按需导入具体职业。"""\n' return files def generate(source_root: Path, output_root: Path, *, check: bool = False) -> list[str]: """生成或只读检查引用;返回差异路径,不覆盖手工文件或自动删除旧职业。""" expected = expected_files(source_root) stale = [ path.relative_to(output_root) for path in sorted(output_root.rglob('*.py')) if path.read_text(encoding='utf-8').startswith(MARKER) and path.relative_to(output_root) not in expected ] if stale: raise ValueError('存在来源已移除的生成文件,请核对后清理: ' + ', '.join(map(str, stale))) changes = [] for relative, content in expected.items(): path = output_root / relative if path.exists(): existing = path.read_text(encoding='utf-8') if not existing.startswith(MARKER): raise ValueError(f'拒绝覆盖手工文件: {path}') if existing == content: continue changes.append(str(relative)) if not check: for relative in changes: path = output_root / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text(expected[Path(relative)], encoding='utf-8') return changes def main() -> int: """命令行入口;--check 发现差异或输入校验失败时返回非零退出码。""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--source-root', type=Path, default=SOURCE_ROOT, help='包含职业技能树的目录') parser.add_argument('--output-root', type=Path, default=OUTPUT_ROOT, help='生成引用文件的目录') parser.add_argument('--check', action='store_true', help='只检查差异,不写文件') args = parser.parse_args() try: changes = generate(args.source_root, args.output_root, check=args.check) except (ValueError, OSError) as exc: print(f'生成失败: {exc}', file=sys.stderr) return 1 if args.check and changes: print('引用文件需要更新:\n' + '\n'.join(changes)) return 1 print(f'{"检查通过" if args.check else "生成完成"},差异文件数: {len(changes)}') return 0 if __name__ == '__main__': raise SystemExit(main())