"""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)