218 lines
10 KiB
Python
218 lines
10 KiB
Python
"""从 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())
|