330 lines
12 KiB
Python
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)
|