feat:优化职业技能关联实现
This commit is contained in:
@@ -102,3 +102,50 @@ def test_all_equipment_can_calculate_when_equipped_once(equ, part, minimal_calc_
|
||||
|
||||
assert character is not None
|
||||
_assert_calc_result_shape(character.calc(config))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('vp', [0, 1, 2])
|
||||
@pytest.mark.parametrize('alter', _character_params())
|
||||
def test_all_gf_learned_skill_results_ignore_configuration_order(alter, vp, minimal_calc_config):
|
||||
"""Exercise real profession rules, including every VP-dependent effect."""
|
||||
from copy import deepcopy
|
||||
from random import Random
|
||||
|
||||
probe = createCharacter(alter, '0')
|
||||
values = [
|
||||
(str(skill.id), {'lv': skill.calculate_lv(), 'vp': vp, 'up': 0})
|
||||
for skill in sorted(probe.skills, key=lambda item: item.id)
|
||||
]
|
||||
shuffled = values.copy()
|
||||
Random(42).shuffle(shuffled)
|
||||
results = []
|
||||
for ordered in (values, values[::-1], shuffled):
|
||||
config = deepcopy(minimal_calc_config)
|
||||
config['skills'] = dict(ordered)
|
||||
config['skillShortcuts'] = {'combos': [[] for _ in range(6)]}
|
||||
result = createCharacter(alter, '0').calc(config)
|
||||
result.pop('uuid')
|
||||
results.append(result)
|
||||
assert results[0] == results[1] == results[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('alter', _character_params())
|
||||
def test_learned_skills_with_weapon_ignore_configuration_order(alter, minimal_calc_config):
|
||||
from copy import deepcopy
|
||||
|
||||
probe = createCharacter(alter, '0')
|
||||
weapon = next(
|
||||
equ for equ in get_equipment('0').equs
|
||||
if equ.itemType == '武器' and equ.itemDetailType in probe.武器选项
|
||||
)
|
||||
values = [(str(skill.id), {'lv': skill.calculate_lv(), 'vp': 2, 'up': 0}) for skill in probe.skills]
|
||||
results = []
|
||||
for ordered in (values, values[::-1]):
|
||||
config = deepcopy(minimal_calc_config)
|
||||
config['equips']['武器']['id'] = weapon.id
|
||||
config['skills'] = dict(ordered)
|
||||
config['skillShortcuts'] = {'combos': [[] for _ in range(6)]}
|
||||
result = createCharacter(alter, '0').calc(config)
|
||||
result.pop('uuid')
|
||||
results.append(result)
|
||||
assert results[0] == results[1]
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user