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

This commit is contained in:
2026-10-13 17:10:08 +08:00
parent 7dc5f4cde5
commit 927866d366
78 changed files with 5242 additions and 1011 deletions
@@ -41,26 +41,20 @@ def test_blade_dance_shadow_uses_final_hit_damage_not_hit_count():
assert shadow_final_hit.value.id == "data2"
def test_blade_dance_shadow_uses_its_own_passive_ratio():
passive = _skill_class("唤魔 : 弑神剑")
associations = next(
item.value
for item in passive.body
if isinstance(item, ast.Assign)
and any(isinstance(target, ast.Name) and target.id == "associate" for target in item.targets)
)
shadow_associations = [
dict(zip((key.value for key in entry.keys), entry.values))
for entry in associations.elts
if isinstance(entry, ast.Dict)
]
def test_blade_dance_shadow_uses_its_own_passive_ratio(minimal_calc_config):
from core.basic.character import createCharacter
from core.basic.skill_effect_types import Operation, Power, SkillEffect
for power in ("+power4", "+power5"):
association = next(
entry
for entry in shadow_associations
if isinstance(entry.get("type"), ast.Constant)
and entry["type"].value == power
)
assert isinstance(association["data"], ast.Name)
assert association["data"].id == "data3"
char = createCharacter('GF.swordman_female.demon_slayer')
char.calc(minimal_calc_config)
passive = char.GetSkillByName('唤魔 : 弑神剑')
for level in (1, 10, passive.maxLv):
passive.lv = level
for index in (4, 5):
association = next(
entry for entry in passive.associate
if isinstance(entry, SkillEffect) and entry.attribute == Power(index)
)
assert association.operation == Operation.ADD
assert '魔刃狂舞' in association.skills
assert association.evaluate(passive) == passive.data3[level] / 100
@@ -0,0 +1,334 @@
"""Typed declarations must preserve baselines and expose all state dependencies."""
import pytest
from core.basic.skill import ActiveSkill, PassiveSkill
from core.basic.skill_effects import SkillEffectEngine
from core.basic.skill_effect_types import (
CharacterAttribute, CharacterEffect, Equals, ExtraData, LevelTable, Operation,
Percent, Power, Read, Select, SkillAttribute as Attr, SkillEffect, WeaponCategoryContains,
)
def character(*classes):
class Character:
role = name = 'test'
武器选项 = ['test']
Spirit = 100
char = Character()
char.GetWeaponType = lambda: ('test', '')
char.skills = [cls(char) for cls in classes]
char.GetSkillByName = lambda name: next((s for s in char.skills if s.name == name), None)
char.GetSkills = lambda *args: [s for s in char.skills if s.damage]
char.skill_effects = SkillEffectEngine(char)
return char
def learn(char, values=None):
with char.skill_effects.batch():
for skill, value in zip(char.skills, values or [1] * len(char.skills)):
char.skill_effects.set_input_level(skill, value)
@pytest.mark.parametrize('reverse', [False, True])
def test_typed_cross_parameter_and_condition_use_final_state(reverse):
class Skill1(PassiveSkill):
name = 'reader'
maxLv = 1
associate = [SkillEffect(
skills=['mastery'], attribute=Attr.RATIO,
value=Read(Attr.RATIO, 'parameter'), when=Equals(Read(Attr.LEVEL, 'parameter'), 2),
)]
class Skill2(PassiveSkill):
name = 'upstream'
maxLv = 1
associate = [
{'type': '+lv', 'data': [0, 100], 'skills': ['parameter']},
{'type': '*ratio', 'data': [0, 50], 'skills': ['parameter']},
]
class Skill3(PassiveSkill):
name = 'parameter'
maxLv = 3
ratio = 1.0
class Skill4(PassiveSkill):
name = 'mastery'
maxLv = 1
ratio = 1.0
associate = [SkillEffect(skills=['target'], value=1 + Read(Attr.RATIO))]
class Skill5(ActiveSkill):
name = 'target'
maxLv = 1
char = character(Skill1, Skill2, Skill3, Skill4, Skill5)
with char.skill_effects.batch():
for skill in char.skills[::-1] if reverse else char.skills:
char.skill_effects.set_input_level(skill, 1)
assert char.skills[-1].skillDamage == pytest.approx(2.5)
char.skills[1].lv = 0
assert char.skills[-1].skillDamage == pytest.approx(2)
char.skills[1].lv = 1
assert char.skills[-1].skillDamage == pytest.approx(2.5)
def test_discovery_does_not_evaluate_provisional_divisor_or_inactive_branch():
class Skill1(PassiveSkill):
name = 'reader'
maxLv = 1
vp = 0
associate = [SkillEffect(skills=['target'], value=Select(
Equals(Read(Attr.VP), 0), 1 / Read(Attr.RATIO, 'parameter'), 1 / Read(Attr.VP),
))]
class Skill2(PassiveSkill):
name = 'upstream'
maxLv = 1
associate = [SkillEffect(skills=['parameter'], attribute=Attr.RATIO, operation=Operation.ASSIGN, value=2)]
class Skill3(PassiveSkill):
name = 'parameter'
maxLv = 1
ratio = 0
class Skill4(ActiveSkill):
name = 'target'
maxLv = 1
char = character(Skill1, Skill2, Skill3, Skill4)
learn(char)
assert char.skills[-1].skillDamage == pytest.approx(.5)
char.skills[0].vp = 1
assert char.skills[-1].skillDamage == 1
def test_typed_level_assignment_can_activate_a_previously_unlearned_source():
class Skill1(PassiveSkill):
name = 'new source'
maxLv = 1
associate = [SkillEffect(skills=['target'], value=1 + Percent(LevelTable([0, 30])))]
class Skill2(PassiveSkill):
name = 'activator'
maxLv = 1
associate = [SkillEffect(skills=['new source'], attribute=Attr.LEVEL, operation=Operation.ASSIGN, value=1)]
class Skill3(ActiveSkill):
name = 'target'
maxLv = 1
char = character(Skill1, Skill2, Skill3)
learn(char, [0, 1, 1])
assert char.skills[0].lv == 1
assert char.skills[-1].skillDamage == pytest.approx(1.3)
char.skills[1].lv = 0
assert char.skills[0].lv == 0
assert char.skills[-1].skillDamage == 1
def test_typed_and_legacy_contributions_preserve_character_and_component_baselines():
class Skill1(PassiveSkill):
name = 'source'
maxLv = 1
associate = [
CharacterEffect(attribute=CharacterAttribute.SPIRIT, operation=Operation.ADD, value=10),
{'type': '$*Spirit', 'data': [0, 10]},
SkillEffect(skills=['target'], attribute=ExtraData(0), operation=Operation.ADD, value=20),
SkillEffect(skills=['target'], attribute=Power(0), operation=Operation.ASSIGN, value=0),
{'data': [50, 100], 'skills': ['target']},
SkillEffect(skills=['target'], value=1.5),
]
class Skill2(ActiveSkill):
name = 'target'
maxLv = 1
dataplus0 = 5
char = character(Skill1, Skill2)
learn(char)
source, target = char.skills
assert char.Spirit == pytest.approx(121)
assert target.dataplus0 == 25
assert target.power0 == 0
assert target.skillDamage == pytest.approx(2)
with char.skill_effects.batch():
char.Spirit += 30
target.dataplus0 += 3
assert char.Spirit == pytest.approx(154)
assert target.dataplus0 == 28
source.lv = 0
assert char.Spirit == 130
assert target.dataplus0 == 8
assert not hasattr(target, 'power0')
assert target.skillDamage == 1
def test_name_arrays_and_level_tables_are_snapshotted_and_filters_apply():
names, data = ['first'], [0, 50]
rule = SkillEffect(skills=names, value=1 + Percent(LevelTable(data)))
names.append('second')
data[1] = 100
class Skill1(PassiveSkill):
name = 'source'
maxLv = 1
vp = 1
associate = [
rule,
SkillEffect(value=2, except_skills=['second'], weapon=['test'], when=Equals(Read(Attr.VP), 1)),
SkillEffect(value=100, weapon=['other']),
]
class Skill2(ActiveSkill):
name = 'first'
maxLv = 1
class Skill3(ActiveSkill):
name = 'second'
maxLv = 1
char = character(Skill1, Skill2, Skill3)
learn(char)
assert char.skills[1].skillDamage == 3
assert char.skills[2].skillDamage == 1
char.skills[0].vp = 0
assert char.skills[1].skillDamage == 1.5
@pytest.mark.parametrize('factory', [
lambda: SkillEffect(value=1, operation='*'),
lambda: SkillEffect(value=1, attribute='skillDamage'),
lambda: SkillEffect(value=1, attribute=Attr.VP),
lambda: SkillEffect(value=1, skills='target'),
lambda: CharacterEffect(value=1, attribute=Attr.RATIO),
lambda: Read(Attr.DAMAGE),
lambda: LevelTable([0, float('inf')]),
lambda: SkillEffect(value=lambda: 1),
lambda: Power(-1),
])
def test_invalid_declarations_fail_at_construction(factory):
with pytest.raises((TypeError, ValueError)):
factory()
@pytest.mark.parametrize('rule, message', [
(SkillEffect(skills=['missing'], value=1), '目标不存在'),
(SkillEffect(value=Read(Attr.LEVEL, 'missing')), '引用技能不存在'),
(SkillEffect(value=Read(Attr.RATIO)), '引用属性不存在'),
(SkillEffect(value=1 / Read(Attr.VP)), '除数为零'),
])
def test_invalid_reference_and_arithmetic_report_errors(rule, message):
class Skill1(ActiveSkill):
name = 'source'
maxLv = 1
associate = [rule]
with pytest.raises(ValueError, match=message):
learn(character(Skill1))
def test_typed_state_cycles_are_rejected():
class Skill1(PassiveSkill):
name = 'first'
maxLv = 1
ratio = 1
associate = [SkillEffect(skills=['second'], attribute=Attr.RATIO, value=1.2)]
class Skill2(PassiveSkill):
name = 'second'
maxLv = 1
ratio = 1
associate = [SkillEffect(skills=['first'], attribute=Attr.RATIO, value=1.2)]
with pytest.raises(ValueError, match='循环依赖'):
learn(character(Skill1, Skill2))
@pytest.mark.parametrize('category, expected', [('制式武器', 1.1), ('传世武器', 1.3), ('狄瑞吉传世武器', 1.3)])
def test_weapon_category_selects_mastery_table(category, expected):
class Skill1(PassiveSkill):
name = 'mastery'
maxLv = 1
associate = [CharacterEffect(
attribute=CharacterAttribute.P_ATK_P,
value=1 + Percent(Select(WeaponCategoryContains('传世武器'), LevelTable([0, 30]), LevelTable([0, 10]))),
)]
char = character(Skill1)
char.PAtkP = 1
char.GetWeaponType = lambda: ('test', category)
learn(char)
assert char.PAtkP == pytest.approx(expected)
def test_profession_attributes_are_local_typed_outputs():
from core.basic.skill_effect_types import ProfessionAttribute
class LocalAttribute(ProfessionAttribute):
BONUS = 'localBonus'
"""测试职业的独立加成。"""
class OtherAttribute(ProfessionAttribute):
BONUS = 'localBonus'
class Source(PassiveSkill):
name = 'source'
maxLv = 1
associate = [SkillEffect(attribute=LocalAttribute.BONUS, operation=Operation.ADD, value=2)]
class Target(ActiveSkill):
name = 'target'
maxLv = 1
localBonus = 3
char = character(Source, Target)
learn(char)
assert char.skills[1].localBonus == 5
char.skills[0].lv = 0
assert char.skills[1].localBonus == 3
assert SkillEffect(attribute=OtherAttribute.BONUS, value=2).attribute is OtherAttribute.BONUS
with pytest.raises(ValueError, match='Read'):
Read(LocalAttribute.BONUS)
@pytest.mark.parametrize('name', ['lv', 'skillDamage', 'power2', 'hit0', 'not-valid'])
def test_profession_attribute_rejects_shared_or_invalid_names(name):
from core.basic.skill_effect_types import ProfessionAttribute
local = ProfessionAttribute('LocalAttribute', {'VALUE': name})
with pytest.raises(ValueError):
SkillEffect(attribute=local.VALUE, value=1)
def test_composed_conditions_track_inactive_dependencies_and_skip_only_explicit_missing_targets():
from core.basic.skill_effect_types import All, GreaterThan, Hit, MissingTargetPolicy, Not
class Source(PassiveSkill):
name = 'source'
maxLv = 1
associate = [SkillEffect(
attribute=Hit(0), operation=Operation.ADD, value=2,
skills=['old missing name', 'target'], missing_target=MissingTargetPolicy.SKIP,
when=All([GreaterThan(Read(Attr.LEVEL, 'gate'), 0), Not(Equals(Read(Attr.LEVEL), 0))]),
)]
class Gate(PassiveSkill):
name = 'gate'
maxLv = 1
class Target(ActiveSkill):
name = 'target'
maxLv = 1
hit0 = 1
char = character(Source, Gate, Target)
learn(char, [1, 0, 1])
assert char.skills[-1].hit0 == 1
char.skills[1].lv = 1
assert char.skills[-1].hit0 == 3
assert ('source', 'old missing name') in char.skill_effects.unresolved_targets
assert Read(Attr.LEVEL, 'gate') in Source.associate[0].dependencies()
char.skills[1].lv = 0
assert char.skills[-1].hit0 == 1