Files
dcalc/backend/tests/unit/test_skill_effects.py

330 lines
12 KiB
Python

"""Behavioral regressions for the shared skill association resolver."""
from types import SimpleNamespace
import pytest
from core.basic.character import createCharacter
from core.basic.skill import ActiveSkill, PassiveSkill
from core.basic.skill_effects import SkillEffectEngine
@pytest.fixture
def character_factory(minimal_calc_config):
def create(alter):
char = createCharacter('GF.' + alter)
char.setInfo = minimal_calc_config
char.SetDetail(minimal_calc_config)
return char
return create
def grenade_factor(char):
awake = char.GetSkillByName('单兵推进器-02X')
return char.GetSkillByName('G-14手雷').skillDamage / (1 + awake.data0[awake.lv] / 100)
@pytest.mark.parametrize('reverse', [False, True])
def test_grenade_mastery_uses_final_awakening_parameter(character_factory, reverse):
char = character_factory('gunner_female.spitfire_female')
mastery = char.GetSkillByName('手雷精通')
awake = char.GetSkillByName('单兵推进器-02X')
operations = [(mastery, 11), (awake, 10)]
for skill, lv in operations[::-1] if reverse else operations:
skill.lv = lv
assert mastery.ratio == pytest.approx(1.2)
assert grenade_factor(char) == pytest.approx(2.32)
mastery.lv = 12
assert grenade_factor(char) == pytest.approx(2.44)
awake.lv = 0
assert grenade_factor(char) == pytest.approx(2.2)
mastery.lv = 0
assert grenade_factor(char) == pytest.approx(1)
def test_equipment_baseline_and_character_stats_survive_refresh(character_factory):
char = character_factory('gunner_female.spitfire_female')
mastery = char.GetSkillByName('手雷精通')
awake = char.GetSkillByName('单兵推进器-02X')
mastery.lv = 11
char.SetSkillRation(1, 100, .15)
char.SetStatus(Spirit=40)
spirit = char.Spirit
awake.lv = 10
assert grenade_factor(char) == pytest.approx(2.32 * 1.15)
assert char.Spirit == spirit
before = [(s.lv, s.skillDamage) for s in char.skills]
for _ in range(3):
char.skill_effects.resolve()
assert [(s.lv, s.skillDamage) for s in char.skills] == before
awake.lv = mastery.lv = 0
assert char.GetSkillByName('G-14手雷').skillDamage == pytest.approx(1.15)
@pytest.mark.parametrize('auto_first', [False, True])
def test_traveler_condition_reads_final_levels(character_factory, auto_first):
char = character_factory('archer.traveler')
crest = char.GetSkillByName('流浪之星的纹章')
auto = char.GetSkillByName('自动操作模式')
meteor = char.GetSkillByName('神雾兵仗·流星')
values = [(crest, 1), (auto, 1)]
char.SetSkills({str(s.id): {'lv': lv} for s, lv in (values[::-1] if auto_first else values)})
assert meteor.cdReduce == 1
auto.lv = 0
assert meteor.cdReduce == pytest.approx(.9)
auto.lv = 1
assert meteor.cdReduce == 1
@pytest.mark.parametrize('sex', ['male', 'female'])
@pytest.mark.parametrize('mastery_first', [False, True])
def test_launcher_level_bonus_ignores_request_order(character_factory, sex, mastery_first):
char = character_factory(f'gunner_{sex}.launcher_{sex}')
mastery = char.GetSkillByName('重火器奥义')
laser = char.GetSkillByName('激光炮')
values = [(mastery, 1), (laser, 10)]
config = {str(s.id): {'lv': lv} for s, lv in (values if mastery_first else values[::-1])}
char.SetSkills(config)
assert laser.lv == 11
char.SetSkills(config)
assert laser.lv == 11
char.AddSkillLv(1, 100, 1)
assert laser.lv == 12
mastery.lv = 0
assert laser.lv == 11
def test_vp_switch_replaces_and_removes_associations(character_factory):
char = character_factory('gunner_female.launcher_female')
source = char.GetSkillByName('X-2太阳神光炮')
target = char.GetSkillByName('激光炮')
source.lv = 1
source.vp = 1
assert target.skillRation == pytest.approx(.875)
source.vp = 0
assert target.skillRation == 1
source.vp = 1
assert target.skillRation == pytest.approx(.875)
source.lv = 0
assert target.skillRation == 1
@pytest.mark.parametrize('reverse', [False, True])
def test_elementalist_uses_consistent_damage_and_cooldown_fields(character_factory, reverse):
char = character_factory('mage_female.elementalist')
mastery = char.GetSkillByName('属性精通')
show = char.GetSkillByName('魔法秀')
rune = char.GetSkillByName('圣灵符文')
wall = char.GetSkillByName('冰墙')
values = [(mastery, 5), (show, 10), (rune, 1)]
for s, lv in values[::-1] if reverse else values:
s.lv = lv
assert wall.skillDamage == pytest.approx(1 + mastery.data0[5] * mastery.ratio / 100)
assert wall.skillRation == 1
assert wall.cdRatio == pytest.approx(1 - show.data1[10] * show.ratio / 100)
rune.lv = 0
assert wall.skillDamage == pytest.approx(1 + mastery.data0[5] / 100)
def synthetic_character(classes):
char = SimpleNamespace(role='test', name='test', 武器选项=['test'])
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 test_state_dependencies_resolve_before_damage_and_clamp_after_aggregation():
class Skill1(PassiveSkill):
name = 'upstream'
maxLv = 10
associate = [{'type': '+lv', 'data': [0, 200], 'skills': ['middle']}]
class Skill2(PassiveSkill):
name = 'middle'
maxLv = 3
ratio = 1.0
associate = [{'type': '*ratio', 'data': [0, 10, 20, 30], 'skills': ['mastery']}]
class Skill3(PassiveSkill):
name = 'mastery'
maxLv = 10
ratio = 1.0
def effect(self, old, new):
self.associate = [{'data': [0, 100 * self.ratio], 'skills': ['target']}]
super().effect(old, new)
class Skill4(ActiveSkill):
name = 'target'
maxLv = 10
char = synthetic_character([Skill1, Skill2, Skill3, Skill4])
with char.skill_effects.batch():
for s in char.skills:
char.skill_effects.set_input_level(s, 1)
upstream, middle, mastery, target = char.skills
assert middle.lv == 3
assert mastery.ratio == pytest.approx(1.3)
assert target.skillDamage == pytest.approx(2.3)
upstream.lv = 0
assert middle.lv == 1
assert mastery.ratio == pytest.approx(1.1)
assert target.skillDamage == pytest.approx(2.1)
def test_zero_assignment_can_be_removed_without_division():
class Skill1(PassiveSkill):
name = 'disable'
maxLv = 1
associate = [{'type': '=skillDamage', 'data': [0, 0], 'skills': ['target']}]
class Skill2(ActiveSkill):
name = 'target'
maxLv = 1
char = synthetic_character([Skill1, Skill2])
source, target = char.skills
with char.skill_effects.batch():
target.skillDamage *= 1.15
char.skill_effects.set_input_level(source, 1)
assert target.skillDamage == 0
source.lv = 0
assert target.skillDamage == pytest.approx(1.15)
def test_state_cycles_report_the_involved_skills():
class Skill1(PassiveSkill):
name = 'first'
maxLv = 10
associate = [{'type': '+lv', 'data': [0, 100], 'skills': ['second']}]
class Skill2(PassiveSkill):
name = 'second'
maxLv = 10
associate = [{'type': '+lv', 'data': [0, 100], 'skills': ['first']}]
char = synthetic_character([Skill1, Skill2])
with pytest.raises(ValueError, match='循环依赖:.*first.*second|循环依赖:.*second.*first'):
with char.skill_effects.batch():
for s in char.skills:
char.skill_effects.set_input_level(s, 1)
def test_missing_target_is_exposed_for_rule_audits():
class Skill1(PassiveSkill):
name = 'source'
maxLv = 1
associate = [{'data': [0, 10], 'skills': ['missing']}]
char = synthetic_character([Skill1])
char.skills[0].lv = 1
assert char.skill_effects.unresolved_targets == {('source', 'missing')}
@pytest.mark.parametrize('reverse', [False, True])
def test_creator_tree_shares_final_creation_level_without_target_cap(character_factory, reverse):
char = character_factory('extra.creator')
source = char.GetSkillByName('创世')
tree = char.GetSkillByName('生命之树')
values = [(source, 10), (tree, 1)]
char.SetSkills({str(s.id): {'lv': lv} for s, lv in (values[::-1] if reverse else values)})
assert tree.maxLv == 1
assert source.lv == tree.lv == 10
tree.lv = 1
assert tree.lv == 10
char.AddSkillLv(85, 85, 1)
assert source.lv == tree.lv == 11
source.lv = 0
assert tree.lv == 0
def test_level_links_chain_and_detect_cycles():
class Skill1(PassiveSkill):
name = 'source'
maxLv = 20
class Skill2(PassiveSkill):
name = 'middle'
maxLv = 1
level_source = 'source'
class Skill3(ActiveSkill):
name = 'target'
maxLv = 1
level_source = 'middle'
char = synthetic_character([Skill3, Skill2, Skill1])
source = char.GetSkillByName('source')
source.lv = 10
assert [s.lv for s in char.skills] == [10, 10, 10]
source.level_source = 'target'
with pytest.raises(ValueError, match='循环依赖'):
char.skill_effects.changed()
def test_exception_inside_batch_is_not_replaced_by_resolution_error():
class Skill1(PassiveSkill):
name = 'source'
maxLv = 20
level_source = 'missing'
char = synthetic_character([Skill1])
with pytest.raises(RuntimeError, match='original error'):
with char.skill_effects.batch():
raise RuntimeError('original error')
def test_character_passive_stats_preserve_external_additions(character_factory):
char = character_factory('priest_male.crusader_male')
source = char.GetSkillByName('守护恩赐')
base = char.Spirit
source.lv = 10
passive = source.data1[10]
assert char.Spirit == pytest.approx(base + passive)
char.SetStatus(Spirit=40)
source.lv = 11
assert char.Spirit == pytest.approx(base + 40 + source.data1[11])
source.lv = 0
assert char.Spirit == pytest.approx(base + 40)
def test_conflicting_assignments_are_reported():
class Skill1(PassiveSkill):
name = 'first'
maxLv = 1
associate = [{'type': '=cd', 'data': [0, 20], 'skills': ['target']}]
class Skill2(PassiveSkill):
name = 'second'
maxLv = 1
associate = [{'type': '=cd', 'data': [0, 30], 'skills': ['target']}]
class Skill3(ActiveSkill):
name = 'target'
maxLv = 1
char = synthetic_character([Skill1, Skill2, Skill3])
with pytest.raises(ValueError, match='关联赋值冲突'):
with char.skill_effects.batch():
for s in char.skills:
char.skill_effects.set_input_level(s, 1)
def test_random_grenade_level_updates_match_final_configuration(character_factory):
from random import Random
char = character_factory('gunner_female.spitfire_female')
mastery = char.GetSkillByName('手雷精通')
awake = char.GetSkillByName('单兵推进器-02X')
rng = Random(42)
for _ in range(100):
if rng.randrange(2):
mastery.lv = rng.randrange(21)
else:
awake.lv = rng.randrange(11)
expected = (1 + mastery.data0[mastery.lv] * (1 + awake.data1[awake.lv] / 100) / 100) * (1 + awake.data0[awake.lv] / 100)
for name in ('G-14手雷', 'G-35L感电手雷', 'G-18C冰冻手雷'):
assert char.GetSkillByName(name).skillDamage == pytest.approx(expected)