feat:优化职业技能关联实现
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user