feat:优化职业技能关联实现

This commit was merged in pull request #11.
This commit is contained in:
2026-10-13 18:19:20 +08:00
parent 927866d366
commit 66ff3ff63b
175 changed files with 14299 additions and 440 deletions
@@ -0,0 +1,97 @@
"""职业生成与更新保持稳定引用,并保留手写计算规则。"""
import ast
import json
from pathlib import Path
import runpy
import pytest
ROOT = Path(__file__).resolve().parents[3]
MERGER = runpy.run_path(str(ROOT / 'scripts/migrate_generated_characters.py'))
IMPORT = 'from core.character.refs.extra.creator import CreatorSkillRef as Ref\n'
def merge(tmp_path, runtime, generated):
old, new = tmp_path / 'runtime.py', tmp_path / 'generated.py'
old.write_text(runtime)
new.write_text(generated)
return MERGER['merge'](new, old)
def block(name='名称', ref='Ref.CREATESPACE', uuid='uuid1', number=0, extra=''):
return (f'class Skill{number}(PassiveSkill):\n name = {name!r}\n'
+ (f' ref = {ref}\n' if ref else '')
+ (f' uuid = {uuid!r}\n' if uuid else '')
+ ' position = 1\n cellPos = (1, 1)\n' + extra + '\n')
def test_generate_class_uses_shared_member_normalization(monkeypatch):
scripts = ROOT / 'backend/openapi/scripts'
monkeypatch.syspath_prepend(str(scripts))
gen = runpy.run_path(str(scripts / 'generate.py'))
job = {'name': 'gunner_female', 'jobId': 'job'}
profession = {'name': 'launcher_female'}
base = ROOT / 'backend/openapi/static/data/cn/gunner_female/launcher_female'
row = next(x for x in json.loads((base / 'skill_tree.json').read_text()) if x['code'] == 'apg-63')
detail = json.loads((base / 'skillDetail' / f"{row['skillId']}.json").read_text())
skill = gen['convert_data'](detail, row['skillId'], job)
output = gen['generate_class'](skill, 0, row, job, profession)
assert 'ref = Ref.APG_63' in output
ast.parse(output)
with pytest.raises(ValueError, match='code'):
gen['generate_class'](skill, 0, {**row, 'code': None}, job, profession)
def test_renamed_skill_matches_ref_and_keeps_rules_and_alias(tmp_path):
runtime = IMPORT.replace('as Ref', 'as OldRef') + block('旧名称', 'OldRef.CREATESPACE', number=27,
extra=' associate = ["手写规则"]\n level_source = OldRef.THETREEOFSEFIROT\n def custom(self):\n return 42\n')
generated = IMPORT + block('新名称', extra=' data0 = [0, 10]\n')
output, stats = merge(tmp_path, runtime, generated)
assert stats['matched'] == 1
assert 'class Skill27(' in output
assert 'ref = OldRef.CREATESPACE' in output
assert 'level_source = OldRef.THETREEOFSEFIROT' in output
assert 'return 42' in output and '手写规则' in output
assert "name = '旧名称'" in output
assert 'data0 = [0, 10]' in output
ast.parse(output)
def test_old_uuid_matches_after_rename_and_gets_ref_import(tmp_path):
output, stats = merge(tmp_path, block('旧名称', ref=None), IMPORT + block('新名称'))
assert stats['matched'] == 1
assert IMPORT.strip() in output
assert 'ref = Ref.CREATESPACE' in output
output2, _ = merge(tmp_path, output, IMPORT + block('新名称'))
assert output == output2
def test_runtime_only_skills_survive_and_new_skills_stay_commented(tmp_path):
old = IMPORT + block() + block('手写技能', ref='Ref.BACKSTEP', uuid='manual', number=9,
extra=' def effect(self, old, new):\n return 42\n')
new = IMPORT + block() + block('新增技能', ref='Ref.THETREEOFSEFIROT', uuid='new', number=1)
output, stats = merge(tmp_path, old, new)
assert stats['runtime_only'] == stats['commented'] == 1
assert 'class Skill9(' in output and 'return 42' in output
assert '# class Skill1(' in output
assert len([x for x in ast.parse(output).body if isinstance(x, ast.ClassDef)]) == 2
@pytest.mark.parametrize(('old_ref', 'old_uuid', 'new_ref', 'new_uuid'), [
('Ref.CREATESPACE', 'one', 'Ref.THETREEOFSEFIROT', 'one'),
(None, 'one', None, 'two'),
])
def test_identity_conflicts_do_not_fall_back_to_name(tmp_path, old_ref, old_uuid, new_ref, new_uuid):
with pytest.raises(ValueError, match='冲突'):
merge(tmp_path, IMPORT + block(ref=old_ref, uuid=old_uuid), IMPORT + block(ref=new_ref, uuid=new_uuid))
def test_duplicate_refs_fail_before_merge(tmp_path):
with pytest.raises(ValueError, match='duplicate ref_key'):
merge(tmp_path, IMPORT + block() + block('另一个技能', uuid='uuid2', number=1), IMPORT + block())
def test_legacy_name_fallback_without_identity(tmp_path):
output, stats = merge(tmp_path, block(ref=None, uuid=None), block(ref=None, uuid=None))
assert stats['matched'] == 1
ast.parse(output)
@@ -56,5 +56,5 @@ def test_blade_dance_shadow_uses_its_own_passive_ratio(minimal_calc_config):
if isinstance(entry, SkillEffect) and entry.attribute == Power(index)
)
assert association.operation == Operation.ADD
assert '魔刃狂舞' in association.skills
assert char.GetSkillByName('魔刃狂舞').ref in association.skills
assert association.evaluate(passive) == passive.data3[level] / 100
+235
View File
@@ -0,0 +1,235 @@
"""技能引用生成的身份隔离、完整性及失败保护。"""
import importlib
import json
from pathlib import Path
import runpy
import pytest
from core.character.refs.base import SkillRef, SkillReferences
from core.character.refs.common import CommonSkillRefs
from core.character.refs.extra.creator import CreatorSkillRef
from core.character.refs.swordman_male.berserker import BerserkerSkillRef
from core.character.refs.swordman_male.weapon_master import WeaponMasterSkillRef
ROOT = Path(__file__).resolve().parents[3]
GENERATOR = runpy.run_path(str(ROOT / 'scripts/generate_skill_refs.py'))
def write_tree(root, rows, *, include_common=True):
if include_common:
rows = [{'code': code, 'name': name} for code, name in GENERATOR['COMMON_SKILLS'].items()] + [
{'code': code, 'name': labels[0]} for code, labels in GENERATOR['AWAKENING_SKILLS']['1'].items()
] + rows
path = root / 'extra/creator/skill_tree.json'
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(rows, ensure_ascii=False), encoding='utf-8')
def test_refs_preserve_codes_and_profession_scope():
assert CreatorSkillRef.CREATESPACE.code == 'createspace'
assert CreatorSkillRef.CREATESPACE.profession == 'extra.creator'
assert CreatorSkillRef.CREATESPACE.qualified_key == 'extra.creator:createspace'
assert WeaponMasterSkillRef.BLADEMASTERY != BerserkerSkillRef.BLADEMASTERY
assert WeaponMasterSkillRef.BLADEMASTERY != 'blademastery'
assert len({WeaponMasterSkillRef.BLADEMASTERY, BerserkerSkillRef.BLADEMASTERY}) == 2
def test_committed_refs_match_all_source_trees():
source = ROOT / 'backend/openapi/static/data/cn'
output = ROOT / 'backend/core/character/refs'
assert GENERATOR['generate'](source, output, check=True) == []
for tree in source.glob('*/*/skill_tree.json'):
job, profession, _ = tree.relative_to(source).parts
module = importlib.import_module(f'core.character.refs.{job}.{profession}')
class_name = ''.join(part.capitalize() for part in profession.split('_')) + 'SkillRef'
refs = getattr(module, class_name)
assert {ref.code for ref in refs.members()} == {row['code'] for row in json.loads(tree.read_text())}
assert all(ref.profession in (None, f'{job}.{profession}') for ref in refs.members())
assert all(name not in vars(refs) for name in CommonSkillRefs.__members__)
@pytest.mark.parametrize(('code', 'member'), [('apg-63', 'APG_63'), ('1000hands1000eyes', 'SKILL_1000HANDS1000EYES')])
def test_non_identifier_codes_keep_original_values(tmp_path, code, member):
source, output = tmp_path / 'source', tmp_path / 'refs'
write_tree(source, [{'code': code, 'name': '中文名称"与换行\n说明'}])
GENERATOR['generate'](source, output)
refs = runpy.run_path(str(output / 'extra/creator.py'))['CreatorSkillRef']
assert getattr(refs, member).code == code
assert GENERATOR['generate'](source, output, check=True) == []
@pytest.mark.parametrize('rows', [
[{'code': 'a-b', 'name': '甲'}, {'code': 'a_b', 'name': '乙'}],
[{'code': 'same', 'name': '甲'}, {'code': 'same', 'name': '乙'}],
[{'name': '缺少 code'}],
])
def test_invalid_source_does_not_partially_write(tmp_path, rows):
source, output = tmp_path / 'source', tmp_path / 'refs'
write_tree(source, rows)
with pytest.raises(ValueError):
GENERATOR['generate'](source, output)
assert not output.exists()
def test_check_is_read_only_and_manual_files_are_protected(tmp_path):
source, output = tmp_path / 'source', tmp_path / 'refs'
write_tree(source, [{'code': 'createspace', 'name': '创世'}])
assert GENERATOR['generate'](source, output, check=True)
assert not output.exists()
target = output / 'extra/creator.py'
target.parent.mkdir(parents=True)
target.write_text('# 手工扩展\n')
with pytest.raises(ValueError, match='拒绝覆盖'):
GENERATOR['generate'](source, output)
assert target.read_text() == '# 手工扩展\n'
assert not (target.parent / '__init__.py').exists()
def test_common_members_are_inherited_and_queries_include_them():
assert CreatorSkillRef.BACKSTEP is CommonSkillRefs.BACKSTEP
assert CreatorSkillRef.BACKSTEP is WeaponMasterSkillRef.BACKSTEP
assert CreatorSkillRef.BACKSTEP.profession is None
assert CreatorSkillRef.BACKSTEP.qualified_key == 'common:backstep'
assert CreatorSkillRef.from_code('backstep') is CreatorSkillRef.BACKSTEP
assert CreatorSkillRef.from_code('createspace') is CreatorSkillRef.CREATESPACE
assert set(CommonSkillRefs.members()).issubset(CreatorSkillRef.members())
assert len(CommonSkillRefs.members()) == 7
with pytest.raises(KeyError):
CreatorSkillRef.from_code('missing')
def test_refs_and_namespaces_are_read_only():
with pytest.raises(AttributeError):
CreatorSkillRef.BACKSTEP.code = 'changed'
with pytest.raises(AttributeError):
CreatorSkillRef.BACKSTEP = SkillRef('changed')
with pytest.raises(AttributeError):
del CreatorSkillRef.CREATESPACE
with pytest.raises(TypeError):
CreatorSkillRef.__members__['BACKSTEP'] = SkillRef('changed')
with pytest.raises(TypeError):
CreatorSkillRef()
def test_class_definition_rejects_overrides_duplicates_and_wrong_scope():
with pytest.raises(ValueError, match='覆盖继承'):
class Override(CommonSkillRefs):
BACKSTEP = SkillRef('other')
with pytest.raises(ValueError, match='重复技能 code'):
class Alias(CommonSkillRefs):
ANOTHER_BACKSTEP = SkillRef('backstep')
with pytest.raises(ValueError, match='职业范围不一致'):
class WrongProfession(CommonSkillRefs):
__profession__ = 'extra.creator'
OTHER = SkillRef('other', 'extra.dark_knight')
with pytest.raises(ValueError, match='覆盖公共查询接口'):
class OverrideMethod(CommonSkillRefs):
members = None
with pytest.raises(ValueError, match='其他职业'):
class WrongParent(CreatorSkillRef):
__profession__ = 'extra.dark_knight'
def test_missing_or_renamed_common_skill_stops_generation(tmp_path):
rows = [{'code': code, 'name': name} for code, name in GENERATOR['COMMON_SKILLS'].items()]
source, output = tmp_path / 'source', tmp_path / 'refs'
write_tree(source, rows[:-1], include_common=False)
with pytest.raises(ValueError, match='缺少公共技能'):
GENERATOR['generate'](source, output)
rows[0]['name'] = '不同名称'
write_tree(source, rows, include_common=False)
with pytest.raises(ValueError, match='公共技能名称不一致'):
GENERATOR['generate'](source, output)
assert not output.exists()
def test_equal_values_are_stable_hashable_identities():
assert SkillRef('backstep') == CommonSkillRefs.BACKSTEP
assert SkillRef('createspace', 'extra.creator') == CreatorSkillRef.CREATESPACE
assert SkillRef('backstep', 'extra.creator') != CommonSkillRefs.BACKSTEP
assert len({SkillRef('backstep'), CommonSkillRefs.BACKSTEP}) == 1
assert SkillReferences.members() == ()
def test_additional_common_skills_keep_real_codes_and_coverage():
from core.character.refs.common import Awakening1SkillRefs, ComboSkillRefs
from core.character.refs.extra.dark_knight import DarkKnightSkillRef
from core.character.refs.archer.hunter import HunterSkillRef
assert CreatorSkillRef.ARMORMASTERYETC is CommonSkillRefs.ARMORMASTERYETC
assert CreatorSkillRef.CLUEOFAWAKENING1 is Awakening1SkillRefs.CLUEOFAWAKENING1
assert CreatorSkillRef.INSTANTREALIZATION1 is Awakening1SkillRefs.INSTANTREALIZATION1
assert WeaponMasterSkillRef.SKILLPRESETTYPE1 is ComboSkillRefs.SKILLPRESETTYPE1
assert HunterSkillRef.from_code('clueofawakening3').profession is None
for refs in (CreatorSkillRef, DarkKnightSkillRef):
assert not hasattr(refs, 'SKILLPRESETTYPE1')
with pytest.raises(KeyError):
refs.from_code('skillpresettype1')
assert not hasattr(refs, 'CLUEOFAWAKENING5')
for refs in (CreatorSkillRef, WeaponMasterSkillRef, HunterSkillRef):
for member, ref in refs.__members__.items():
if ref.code.startswith(('clueofawakening', 'instantrealization')) or ref.code in ('armormasteryetc', 'skillpresettype1'):
assert member not in vars(refs)
assert ref.profession is None
@pytest.mark.parametrize('extra', [
[{'code': 'skillpresettype1', 'name': '技能连招'}],
[{'code': 'clueofawakening2', 'name': '觉醒之抉择'}],
[{'code': 'instantrealization2', 'name': '卓越之力'}],
])
def test_common_group_mismatch_is_rejected_before_writing(tmp_path, extra):
source, output = tmp_path / 'source', tmp_path / 'refs'
write_tree(source, extra)
with pytest.raises(ValueError):
GENERATOR['generate'](source, output)
assert not output.exists()
def test_bound_refs_survive_display_rename_and_remain_character_local(minimal_calc_config):
from core.basic.character import createCharacter
from core.character.refs.extra.creator import CreatorSkillRef as Ref
first = createCharacter('GF.extra.creator')
second = createCharacter('GF.extra.creator')
first.calc(minimal_calc_config)
second.calc(minimal_calc_config)
source = first.GetSkill(Ref.CREATESPACE)
tree = first.GetSkillByRef(Ref.THETREEOFSEFIROT)
assert source.code == 'createspace'
assert first.GetSkillByCode('createspace') is source
assert first.GetSkill('创世') is source
source.name = '显示名称已修改'
first.skills_dict = {s.name: s for s in first.skills}
assert first.GetSkill('创世') is None
assert first.GetSkill(Ref.CREATESPACE) is source
source.lv = 5
assert tree.lv == source.lv == 5
assert first.GetSkill(Ref.BACKSTEP) is not second.GetSkill(Ref.BACKSTEP)
with pytest.raises(ValueError, match='职业不匹配'):
first.GetSkill(WeaponMasterSkillRef.LIGHTSWORDMASTERY)
def test_bind_rejects_duplicate_codes_and_foreign_refs():
from types import SimpleNamespace
from core.character.refs.base import bind_skill_refs
char = SimpleNamespace(skills=[SimpleNamespace(name='甲', ref=SkillRef('a')), SimpleNamespace(name='乙', ref=SkillRef('a', 'extra.creator'))])
with pytest.raises(ValueError, match='重复技能引用绑定'):
bind_skill_refs(char, 'extra.creator')
char.skills = [SimpleNamespace(name='错误职业', ref=WeaponMasterSkillRef.LIGHTSWORDMASTERY)]
with pytest.raises(ValueError, match='职业不匹配'):
bind_skill_refs(char, 'extra.creator')
def test_names_are_display_metadata_not_identity():
assert SkillRef('a', name='旧名称') == SkillRef('a', name='新名称')
assert hash(SkillRef('a', name='旧名称')) == hash(SkillRef('a', name='新名称'))
assert CreatorSkillRef.ARMORMASTERYETC.name == '防具精通'
@@ -332,3 +332,44 @@ def test_composed_conditions_track_inactive_dependencies_and_skip_only_explicit_
assert Read(Attr.LEVEL, 'gate') in Source.associate[0].dependencies()
char.skills[1].lv = 0
assert char.skills[-1].hit0 == 1
def test_ref_targets_exclusions_and_reads_use_identity_after_rename():
from core.character.refs.base import SkillRef, bind_skill_refs
reader_ref = SkillRef('reader', 'test.job')
gate_ref = SkillRef('gate', 'test.job')
target_ref = SkillRef('target', 'test.job')
excluded_ref = SkillRef('excluded', 'test.job')
class Source(PassiveSkill):
name = 'reader'
ref = reader_ref
maxLv = 1
associate = [SkillEffect(
skills=[target_ref, excluded_ref], except_skills=[excluded_ref],
value=1 + Read(Attr.LEVEL, gate_ref),
)]
class Gate(PassiveSkill):
name = 'gate'
ref = gate_ref
maxLv = 3
class Target(ActiveSkill):
name = 'renamed target'
ref = target_ref
maxLv = 1
class Excluded(ActiveSkill):
name = 'renamed excluded'
ref = excluded_ref
maxLv = 1
char = character(Source, Gate, Target, Excluded)
bind_skill_refs(char, 'test.job')
learn(char, [1, 2, 1, 1])
assert char.skills[2].skillDamage == 3
assert char.skills[3].skillDamage == 1
char.skills[1].lv = 3
assert char.skills[2].skillDamage == 4