feat:优化职业技能关联实现

This commit is contained in:
2026-10-13 15:57:28 +08:00
parent 15c3bff467
commit 7dc5f4cde5
20 changed files with 875 additions and 161 deletions
+7 -1
View File
@@ -1,9 +1,9 @@
from collections.abc import Mapping from collections.abc import Mapping
import sys
from typing import Any, Literal from typing import Any, Literal
from core.abstract.character import CharacterProperty from core.abstract.character import CharacterProperty
from core.basic.skill import Skill from core.basic.skill import Skill
from core.basic.skill_effects import SkillEffectEngine, skill_effect_batch
from core.basic.equipment import EquEffect, OathSuit, OathSuitSkill, Suit from core.basic.equipment import EquEffect, OathSuit, OathSuitSkill, Suit
from ..formula import get_base_attributes, 获取唤醒属性 from ..formula import get_base_attributes, 获取唤醒属性
from ..roleinfo import CharacterEmblemInfo, CharacterEquipInfo, get_key_by_value, CharacterOathInfo from ..roleinfo import CharacterEmblemInfo, CharacterEquipInfo, get_key_by_value, CharacterOathInfo
@@ -178,6 +178,7 @@ class CharacterBase(CharacterProperty):
self.skill_names_cache = {} self.skill_names_cache = {}
self.skills_cache = {} self.skills_cache = {}
self.load_skills() self.load_skills()
self.skill_effects = SkillEffectEngine(self)
# region 角色属性设置 # region 角色属性设置
@@ -188,6 +189,7 @@ class CharacterBase(CharacterProperty):
for index, i in enumerate([get_key_by_value(i) for i in ['力量', '体力', '智力', '精神']]): for index, i in enumerate([get_key_by_value(i) for i in ['力量', '体力', '智力', '精神']]):
setattr(self, i, getattr(self, i) + temp[index] + 唤醒) setattr(self, i, getattr(self, i) + temp[index] + 唤醒)
@skill_effect_batch
def SetStatus( def SetStatus(
self, self,
力量=0, 力量=0,
@@ -323,6 +325,7 @@ class CharacterBase(CharacterProperty):
value = int(value * (1 + self.jade_effect.ElementIncrease)) value = int(value * (1 + self.jade_effect.ElementIncrease))
self.ElementDB[element] += value self.ElementDB[element] += value
@skill_effect_batch
def AddSkillLv(self, min: int, max: int, lv: int, type=-1, exceptSkills: tuple[str, ...] = ()) -> None: def AddSkillLv(self, min: int, max: int, lv: int, type=-1, exceptSkills: tuple[str, ...] = ()) -> None:
""" """
增加技能等级 增加技能等级
@@ -334,18 +337,21 @@ class CharacterBase(CharacterProperty):
if (skillType == 'all' or skill.type == skillType) and skill.lv > 0 and skill.name not in exceptSkills: if (skillType == 'all' or skill.type == skillType) and skill.lv > 0 and skill.name not in exceptSkills:
skill.lv += lv skill.lv += lv
@skill_effect_batch
def SetSkillCD(self, min=1, max=100, cd=0, exclude: tuple[int, ...] = (50, 85, 100)) -> None: def SetSkillCD(self, min=1, max=100, cd=0, exclude: tuple[int, ...] = (50, 85, 100)) -> None:
"""设置技能CD""" """设置技能CD"""
for skill in self.skills: for skill in self.skills:
if min <= skill.learnLv <= max and (skill.type == 'active' or skill.damage) and skill.learnLv not in exclude: if min <= skill.learnLv <= max and (skill.type == 'active' or skill.damage) and skill.learnLv not in exclude:
skill.cdReduce *= 1 - cd skill.cdReduce *= 1 - cd
@skill_effect_batch
def SetSkillCDRecover(self, min=1, max=100, cd=0, exclude: tuple[int, ...] = (50, 85, 100)) -> None: def SetSkillCDRecover(self, min=1, max=100, cd=0, exclude: tuple[int, ...] = (50, 85, 100)) -> None:
"""设置技能CD恢复""" """设置技能CD恢复"""
for skill in self.skills: for skill in self.skills:
if min <= skill.learnLv <= max and (skill.type == 'active' or skill.damage) and skill.learnLv not in exclude: if min <= skill.learnLv <= max and (skill.type == 'active' or skill.damage) and skill.learnLv not in exclude:
skill.cdRecover += cd skill.cdRecover += cd
@skill_effect_batch
def SetSkillRation(self, min=1, max=100, ratio=1, type=-1) -> None: def SetSkillRation(self, min=1, max=100, ratio=1, type=-1) -> None:
"""设置技能倍率 """设置技能倍率
0 修改技能面板 1 不修改技能面板 0 修改技能面板 1 不修改技能面板
+18 -13
View File
@@ -8,6 +8,7 @@ from ..roleinfo import CharacterEmblemInfo, CharacterEquipInfo, CharacterOathInf
from .base import CharacterBase from .base import CharacterBase
from .config_view import CalcConfigView from .config_view import CalcConfigView
from .effect_apply import apply_uppercase_attrs from .effect_apply import apply_uppercase_attrs
from ..skill_effects import skill_effect_batch
@cache @cache
@@ -60,8 +61,9 @@ class CalcEquipMixin(CharacterBase):
self.SetDetail(setInfo) self.SetDetail(setInfo)
self.SetSkills(setInfo.get('skills', {})) self.SetSkills(setInfo.get('skills', {}))
@skill_effect_batch
def SetSkills(self, skills: dict[str, dict]): def SetSkills(self, skills: dict[str, dict]):
"""设置技能等级""" """批量设置基础等级及 VP/UP;等级不含关联加级,共享等级由 level_source 决定。"""
for key in skills: for key in skills:
skill = self.GetSkillByID(key) skill = self.GetSkillByID(key)
if skill is not None: if skill is not None:
@@ -69,7 +71,7 @@ class CalcEquipMixin(CharacterBase):
skill.up = skills[key].get('up', 0) skill.up = skills[key].get('up', 0)
if skill.hasVP: if skill.hasVP:
skill.vp = skills[key].get('vp', 0) skill.vp = skills[key].get('vp', 0)
skill.lv += skills[key].get('lv', 0) self.skill_effects.set_input_level(skill, skills[key].get('lv', 0))
def load_skills(self) -> None: def load_skills(self) -> None:
"""加载技能""" """加载技能"""
@@ -436,17 +438,20 @@ class CalcEquipMixin(CharacterBase):
def calc(self, setInfo: dict[str, dict]): def calc(self, setInfo: dict[str, dict]):
config = CalcConfigView(setInfo).as_legacy_dict() config = CalcConfigView(setInfo).as_legacy_dict()
self.calc_init(config) # 装备等外部加成作为技能关联的基础值;批次结束后按最终技能状态重建关联。
self.SetBaseStatus() # 装备之间仍有先后依赖(如先计算套装积分),不能据此任意交换下面的调用。
self.calc_jade(config.get('jades', [])) with self.skill_effects.batch():
self.calc_avatar(config.get('avatar', {})) self.calc_init(config)
self.calc_basic() self.SetBaseStatus()
self.calc_sundry(config.get('sundry', {})) self.calc_jade(config.get('jades', []))
self.calc_emblems() self.calc_avatar(config.get('avatar', {}))
self.calc_oath_upgrade() self.calc_basic()
suit = self.calc_suits() self.calc_sundry(config.get('sundry', {}))
self.calc_equs() self.calc_emblems()
self.calc_oaths() self.calc_oath_upgrade()
suit = self.calc_suits()
self.calc_equs()
self.calc_oaths()
skillInfos = [] skillInfos = []
if self.buffer: if self.buffer:
skillInfos = self.calc_buffer_skills() skillInfos = self.calc_buffer_skills()
+71 -12
View File
@@ -8,6 +8,7 @@ from .formula import 武器冷却惩罚
from database.connect import get_redis_client from database.connect import get_redis_client
from api.core.Redis import get_redis_info from api.core.Redis import get_redis_info
from api.utils.openapi_data import OPENAPI_CUSTOM_CN_PATH, logical_skill_detail_path from api.utils.openapi_data import OPENAPI_CUSTOM_CN_PATH, logical_skill_detail_path
from .skill_effects import CHARACTER_ATTRIBUTES, INPUT_ATTRIBUTES
if TYPE_CHECKING: if TYPE_CHECKING:
from core.basic.character import Character from core.basic.character import Character
@@ -84,9 +85,21 @@ def get_data(prefix: str, key: int | str, func: Callable[[Any], Any] = _identity
class Skill: class Skill:
def __setattr__(self, name, value):
char = self.__dict__.get('char')
engine = getattr(char, 'skill_effects', None)
if name != 'lv' and name in INPUT_ATTRIBUTES and engine is not None and engine.owns(self) and not engine.resolving:
engine.set_attribute(self, name, value)
else:
object.__setattr__(self, name, value)
name: str = '' name: str = ''
"""技能名称""" """技能名称"""
_lv: int = 0 _lv: int = 0
level_source: str | None = None
"""共享等级的源技能;目标不独立加级,也不使用自身 maxLv 截断。"""
association_source: bool | None = None
"""共享等级技能默认仅复用等级;需要独立提供关联效果时显式开启。"""
id: int = 0 id: int = 0
"""技能ID""" """技能ID"""
icon: str = '$char/$uuid' icon: str = '$char/$uuid'
@@ -131,15 +144,15 @@ class Skill:
cdRecover: float = 1 cdRecover: float = 1
"""技能冷却恢复""" """技能冷却恢复"""
associate: list = [] associate: list = []
"""关联技能 """关联声明;维护示例和扩展边界见 core/basic/skill_effects.py 文件顶部说明。
type: 关联类型 *乘算 +加算 =直接赋值 $角色属性 默认'*skillDamage'
e.g. type 默认 '*skillDamage':*乘算、+加算、=覆盖;$*PAtkP 等作用于角色。
*skillDamage 乘算技能伤害 skills 默认 '*',表示所有伤害技能;显式列表使用技能名称,可指定被动技能。
*cdReduce 乘算技能冷却缩减 exceptSkills 默认 [];weapon 默认职业武器选项;precondition() 控制前置条件。
$*PATK 乘算角色物理攻击 data 按来源等级取值;ratio 是数据除数,默认 100,与技能的 ratio 属性不同。
~直接走type对应的函数名称 传参为old,new,data,skills,exceptSkills +lv 的整数加级通常设置 ratio=1;=覆盖直接使用 data 值,不除以 ratio。
skills: 关联技能 默认 '*' 动态 effect 以 (0, 最终等级) 多次收集,只构造声明并调用 super().effect。
exceptSkills: 例外技能 默认[] 自定义 type 方法仍有兼容入口,但直接修改目标会绕过贡献收集,新增规则勿照搬。
""" """
mode: list[str] = [''] mode: list[str] = ['']
currentMode: str = '' currentMode: str = ''
@@ -159,6 +172,10 @@ class Skill:
def __init__(self, char): def __init__(self, char):
self.char = char self.char = char
# Shared-level display skills override the setter with a no-op. They
# did not apply their own associations in the original calculation.
if self.association_source is None:
self.association_source = self.level_source is None and type(self).lv.fset is Skill.lv.fset
self.id = self._extract_id() self.id = self._extract_id()
self.icon = self._generate_icon() self.icon = self._generate_icon()
if self.line is None: if self.line is None:
@@ -199,11 +216,22 @@ class Skill:
@property @property
def lv(self): def lv(self):
"""技能等级""" """技能等级"""
engine = getattr(self.char, 'skill_effects', None)
if engine is not None and engine.owns(self):
engine.read_level(self)
elif self.level_source is not None:
return self.char.GetSkillByName(self.level_source).lv
return self._lv return self._lv
@lv.setter @lv.setter
def lv(self, value): def lv(self, value):
value = int(min(self.maxLv, value)) if self.level_source is not None:
return
engine = getattr(self.char, 'skill_effects', None)
if engine is not None and engine.owns(self) and not engine.resolving:
engine.set_level(self, max(0, int(value)))
return
value = int(max(0, min(self.maxLv, value)))
if self._lv != value: if self._lv != value:
self.effect(self._lv, value) self.effect(self._lv, value)
self._lv = value self._lv = value
@@ -238,10 +266,20 @@ class Skill:
value = (1 - self.get_data_value(data, new) / ratio) / (1 - self.get_data_value(data, old) / ratio) value = (1 - self.get_data_value(data, new) / ratio) / (1 - self.get_data_value(data, old) / ratio)
else: else:
value = (1 + self.get_data_value(data, new) / ratio) / (1 + self.get_data_value(data, old) / ratio) value = (1 + self.get_data_value(data, new) / ratio) / (1 + self.get_data_value(data, old) / ratio)
self.char.SetStatus(**{type[2:]: value}) engine = getattr(self.char, 'skill_effects', None)
if engine is not None and engine.collecting is self:
if type[2:] in CHARACTER_ATTRIBUTES:
engine.record(self, self.char, type[2:], '*', value)
else:
self.char.SetStatus(**{type[2:]: value})
elif type.startswith('$+'): elif type.startswith('$+'):
value = self.get_data_value(data, new) / ratio - self.get_data_value(data, old) / ratio value = self.get_data_value(data, new) / ratio - self.get_data_value(data, old) / ratio
self.char.SetStatus(**{type[2:]: value}) engine = getattr(self.char, 'skill_effects', None)
if engine is not None and engine.collecting is self:
if type[2:] in CHARACTER_ATTRIBUTES:
engine.record(self, self.char, type[2:], '+', value)
else:
self.char.SetStatus(**{type[2:]: value})
else: else:
exceptSkills = set(exceptSkills) exceptSkills = set(exceptSkills)
if skills == '*': if skills == '*':
@@ -256,6 +294,10 @@ class Skill:
skill = self.char.GetSkillByName(name) skill = self.char.GetSkillByName(name)
if skill is not None: if skill is not None:
self.update_skill_attribute(skill, type, old, new, data, ratio, weapon, weapon_type, weapon_options) self.update_skill_attribute(skill, type, old, new, data, ratio, weapon, weapon_type, weapon_options)
else:
engine = getattr(self.char, 'skill_effects', None)
if engine is not None and engine.collecting is self:
engine.missing_target(self, name)
except Exception as e: except Exception as e:
# print(f"Error applying association {type} for skill {self.name}: {e}") # print(f"Error applying association {type} for skill {self.name}: {e}")
raise e raise e
@@ -265,6 +307,23 @@ class Skill:
return return
old_value = self.get_data_value(data, old) old_value = self.get_data_value(data, old)
new_value = self.get_data_value(data, new) new_value = self.get_data_value(data, new)
engine = getattr(self.char, 'skill_effects', None)
if engine is not None and engine.collecting is self:
if type[1:] == 'lv' and skill.level_source is not None:
return
if type.startswith('*'):
if 'cdReduce' in type or 'cdRatio' in type:
value = (1 - new_value / ratio) / (1 - old_value / ratio)
else:
value = (1 + new_value / ratio) / (1 + old_value / ratio)
elif type.startswith('+'):
if type[1:] == 'lv' and not engine.is_learned(skill):
return
value = (new_value - old_value) / ratio
else:
value = new_value
engine.record(self, skill, type[1:], type[0], value)
return
if type.startswith('*'): if type.startswith('*'):
if 'cdReduce' in type or type == 'cd' or 'cdRatio' in type: if 'cdReduce' in type or type == 'cd' or 'cdRatio' in type:
setattr(skill, type[1:], getattr(skill, type[1:]) * (1 - new_value / ratio) / (1 - old_value / ratio)) setattr(skill, type[1:], getattr(skill, type[1:]) * (1 - new_value / ratio) / (1 - old_value / ratio))
+386
View File
@@ -0,0 +1,386 @@
"""技能关联计算与维护说明
技能关联由 `core/basic/skill_effects.py` 的 `SkillEffectEngine` 统一处理。
`lv`、VP 等输入变化不再通过历史倍率乘除更新目标,而是重新解析最终状态并汇总本次关联贡献。
## 计算阶段
1. `SetSkills` 批量读取基础等级、VP、UP。传入的等级是基础等级,不含关联技能提供的等级;重复传入同一配置不会重复加级。
2. 解析等级和参数依赖。`lv`、`ratio`、`maxLayer` 是状态属性;关联到这些属性的规则以及 `level_source` 组成依赖图,按拓扑顺序求值。关联规则读取其他技能等级时,依赖会自动记录。循环、缺失共享等级源、计算过程中改变状态依赖会报出具体技能。
3. 基于最终状态收集伤害、冷却和角色属性贡献,并在装备提供的基础值之上应用一次。
`calc` 在处理配置、装备、时装、徽章等阶段使用同一个批次。需要读取等级时,可以先解析状态;伤害及角色属性关联在准备阶段结束后统一生效。
## 后续在哪里维护
| 新增或修改的影响 | 维护位置 | 要点 |
| --- | --- | --- |
| 技伤、冷却、攻击段数等普通关联 | 对应 `core/character/GF/职业/角色.py` 的技能类 `associate` | 通常不改引擎;目标属性应有默认值 |
| 随 VP、等级或自身参数变化的关联 | 来源技能的 `effect` | 每次生成完整声明,再调用 `super().effect(old, new)` |
| 技能给其他技能加级 | 来源技能的 `associate`,使用 `+lv` | 整数加级设置 `ratio=1`;只给已学习的目标加级 |
| 生命之树这类共享等级 | 目标技能的 `level_source` | 指向源技能名称,不再写等级 getter/no-op setter |
| 新增会被关联修改、并影响后续关联的参数 | 技能类默认值 + `core/basic/skill_effects.py` 的 `STATE_ATTRIBUTES` | 例如现有 `ratio`、`maxLayer`;加入后自动属于输入属性 |
| 新增外部配置输入,改变它需要重新计算关联 | 技能类、配置赋值入口 + `INPUT_ATTRIBUTES` | 例如现有 `vp`;单纯登记不提供跨技能参数读取依赖 |
| 新增角色属性关联 | `skill.py` 的角色贡献收集分支、`CHARACTER_ATTRIBUTES`,并核对 `character/base.py` 的 `SetStatus` | 当前仅支持对应标量字段;特殊运算需单独实现 |
| 新增运算符、目标选择方式、跨技能非等级依赖 | `skill.py` 的公共关联入口 + `skill_effects.py` | 属于公共能力扩展,需配套单元测试 |
| 装备改变技能等级或倍率 | 原装备规则及 `character/base.py` 的公共修改方法 | 使用已有批处理方法;低层直接写入须在 `batch()` 内 |
| 仅影响单次技能展示/输出的模式、VP、UP 变换 | 原技能 `setMode`、`vp_N`、输出计算方法 | `skillInfo()` 使用临时副本,不要误改为持久关联 |
普通职业规则优先放在职业文件。只有新增公共能力时才修改引擎;不要再用属性 setter 手动撤销旧倍率并乘入新倍率。
## 声明示例和数值约定
以下片段放在对应技能类内,`data0` 为该来源技能的等级数据表:
```python
# 默认百分比:data0[lv] = 110 时,倍率为 1 + 110 / 100。
associate = [{'type': '*skillDamage', 'data': data0, 'skills': ['目标技能名称']}]
# 加级:data0[lv] = 1 时,给已学习目标增加 1 级。
associate = [{'type': '+lv', 'data': data0, 'ratio': 1, 'skills': ['目标技能名称']}]
```
上面是两种独立示例;需要同时生效时放进同一列表。`skills='*'` 仅选择伤害技能,关联被动技能时使用明确的名称列表。名称必须与目标 `name` 完全一致,当前不是按 UUID 匹配。
数据以来源技能等级索引,通常令 `data[0] = 0`。收集仍兼容旧公式:普通乘算为 `(1 + data[lv]/ratio) / (1 + data[0]/ratio)`,`cdReduce`、`cdRatio` 改用减号;加算为 `(data[lv] - data[0])/ratio`;覆盖 `=` 直接取 `data[lv]`。这里字典中的 `ratio` 是除数,区别于手雷精通类上的 `ratio` 参数。
女弹药的动态关联是现成参考:
```python
ratio = 1.0
def effect(self, old, new):
self.associate = [{
'type': '*skillDamage',
'data': [value * self.ratio for value in self.data0],
'skills': ['G-14手雷', 'G-35L感电手雷', 'G-18C冰冻手雷'],
}]
return super().effect(old, new)
```
三觉被动通过 `*ratio` 关联手雷精通。引擎先确定最终 `ratio=1.2`,再收集手雷倍率,所以精通 11 级对应 `1 + 110% × 1.2 = 2.32`。
## 职业规则
静态 `associate` 格式不变。现有动态 `effect(old, new)` 作为兼容入口,在收集模式下以 `(0, 最终等级)` 执行;公共 `apply_association` / `update_skill_attribute` 记录贡献,不修改目标。每次收集都从原始 `associate` 声明开始,取消 VP 时不会保留上一次分支。
动态规则只能生成关联声明,不能在收集过程中直接修改角色或目标技能。需要修改关联产生方式时,应改用声明,而不是另写参数 setter 撤销旧倍率。元素师原先的 `ratio` 补偿 setter 已移除,避免与公共引擎重复应用。
引擎在创建时保存初始声明和状态默认值。动态分支应新建并赋值 `self.associate`,不要对共享的类级列表执行 `append` 或原地改写;也不要把运行后修改 `associate` 当作注册新规则。一次重算可能多次调用 `effect`,因此不能在其中累计计数、直接调用角色加成方法或依赖随机结果。
依赖图目前自动识别三类依赖:共享等级源、收集时跨技能 `.lv` 读取、指向状态属性的关联。
直接读取另一个技能的 `.ratio` 等非等级参数不会自动建立依赖。
新增这类规则时需要先扩展公共依赖机制并验证顺序;单独加入 `INPUT_ATTRIBUTES` 只会触发重算,不能解决求值先后问题。
初次建图后新出现的等级读取或状态目标会报错;不要靠最终状态临时增加依赖边。
每条贡献包含来源技能、条目索引、目标、属性、运算及数值,可从 `character.skill_effects.contributions` 检查。角色属性支持原 `SetStatus` 中相应的标量加算/乘算字段。
同一属性的汇总顺序为:基础值(或覆盖值)→ 加算贡献 → 乘算贡献。不同来源给出不同覆盖值会报冲突;不会依赖遍历顺序选择最后一个。赋零可以直接从基础值重建,不需要除以旧倍率。
## 共享等级
例如生命之树:
```python
level_source = '创世'
```
生命之树跟随创世最终等级,包含创世获得的装备及技能等级加成。目标自身的 `maxLv = 1` 不会截断共享等级。目标不接受独立等级或独立加级;需要提升等级时应提升源技能。
共享等级技能默认不独立提供关联效果,保留原 getter/no-op setter 的行为。如职业规则确实要求共享等级技能独立提供关联,可显式设置 `association_source = True`。
现有 11 个共享等级技能已迁移为 `level_source` 声明。
## 装备和外部修改
`SetStatus`、`AddSkillLv`、`SetSkillRation`、`SetSkillCD`、`SetSkillCDRecover` 已自动批处理。完整 `calc` 中的装备代码也在批次内,因此原有装备的直接属性写入会保留为关联计算的基础值。
新增低层直接修改必须放进批次,不能修改已叠加关联贡献的结果后再要求引擎推断基础值:
```python
with character.skill_effects.batch():
skill.skillDamage *= 1.15
```
`resolve()` 在没有变化时不重复应用;批次重建会先恢复原来的基础值。`skillInfo()` 的临时拷贝不属于引擎管理对象,其模式、VP、UP 变换仍在临时拷贝上执行。
这不表示装备自身也已经与顺序无关:装备仍按原流程执行,例如先由 `calc_suits()` 生成套装积分,再让装备读取积分。当前批次解决的是装备修改与技能关联贡献之间的基线问题,没有为装备建立逐来源贡献账本和依赖图,也不保证对同一实例反复执行整套装备流程不会累计。
定位引擎内部问题时,`_levels` 保存基础及外部等级,`_state_base` 保存状态参数基线,`_output_base` 保存应用技能输出贡献之前的属性值。它们是内部状态,不应由职业或装备规则直接修改;外部输入走公共修改方法或批次入口。
## 诊断和验证
找不到目标的旧关联仍按原逻辑跳过,同时记录在 `unresolved_targets`,供职业规则审查。例如刺客现存的“终结追击”名称失配;本次未推测其数值意图并修改规则。
测试覆盖女弹药 2.32 场景及后续升降级、旅人条件、大枪加级、VP 切换、装备基线、角色属性、共享等级及链式共享、零覆盖、赋值冲突和循环依赖。全职业测试使用全部学习等级,在无 VP/VP1/VP2 下交换输入顺序,并另测搭配职业武器的情况。
重构前后 72 个模块的默认满技能配置对比:伤害/冷却的实质变化集中在女弹药、旅人、男女大枪已确认的问题;元素师统一了 `skillRation`/`skillDamage` 的分配,总伤害保持一致。该验证针对本地计算规则,不等同于游戏实测或线上验证。
新增规则时,在 `tests/unit/test_skill_effects.py` 补充对应场景,重点验证正反设置顺序、等级升降或禁用、条件开关、外部加成后再次重建。公共依赖机制变更还需验证循环及冲突报错。
从仓库根目录执行:
```sh
backend/.venv/bin/pytest backend/tests/unit/test_skill_effects.py -q
backend/.venv/bin/pytest backend/tests/smoke/test_calc_availability.py -k configuration_order -q
```
第二项验证技能配置顺序(包括搭配武器的场景),不验证装备执行顺序。修改公共引擎后再运行完整 `backend/tests` 回归。
"""
from collections import defaultdict
from contextlib import contextmanager
from dataclasses import dataclass
from functools import wraps
from graphlib import CycleError, TopologicalSorter
from math import prod
# 会被关联修改、且参与后续关联求值的属性在此登记,并在技能类上提供默认值。
STATE_ATTRIBUTES = frozenset({'lv', 'ratio', 'maxLayer'})
# 外部赋值触发重算的输入;登记在此不等于自动跟踪跨技能的该属性读取。
INPUT_ATTRIBUTES = STATE_ATTRIBUTES | {'vp', 'up', 'comboCol', 'stage', 'currentMode'}
# These are the scalar keys actually supported by CharacterBase.SetStatus.
# 扩展前核对 SetStatus 的实际运算语义;特殊字段不能仅加入白名单。
CHARACTER_ATTRIBUTES = frozenset({
'STR', 'INT', 'Spirit', 'Vitality', 'AtkP', 'AtkM', 'AtkI',
'PAtkP', 'PAtkM', 'PAtkI', 'PSTR', 'PINT',
})
_MISSING = object()
@dataclass(frozen=True)
class SkillContribution:
source: object
index: int
target: object
attribute: str
operation: str
value: float
def skill_effect_batch(method):
"""Keep external skill/stat changes outside the association baseline."""
@wraps(method)
def wrapped(self, *args, **kwargs):
engine = getattr(self, 'skill_effects', None)
if engine is None:
return method(self, *args, **kwargs)
with engine.batch():
return method(self, *args, **kwargs)
return wrapped
class SkillEffectEngine:
def __init__(self, character):
self.character = character
self.skills = tuple(sorted(character.skills, key=lambda skill: skill.id))
self._members = {id(skill) for skill in self.skills}
self._levels = {skill: skill._lv for skill in self.skills}
self._state_base = {
(skill, attr): getattr(skill, attr)
for skill in self.skills for attr in STATE_ATTRIBUTES - {'lv'}
if hasattr(skill, attr)
}
self._associations = {skill: skill.associate for skill in self.skills}
self._output_base = {}
self._depth = 0
self.resolving = False
self.collecting = None
self._pending = []
self._reads = set()
self._dirty = True
self._state_dirty = True
self.contributions = ()
self.unresolved_targets = set()
def owns(self, skill):
# skillInfo uses shallow copies; these are local render contexts.
return id(skill) in self._members
def changed(self):
self._dirty = self._state_dirty = True
if not self._depth:
self.resolve()
def set_level(self, skill, value):
if skill.level_source is not None:
return
# += equipment bonuses must change the input, not copy already-derived
# mastery levels back into it. Input configuration uses set_input_level.
self._levels[skill] = min(skill.maxLv, max(0, self._levels[skill] + int(value) - skill.lv))
self.changed()
def set_input_level(self, skill, value):
if skill.level_source is not None:
return
self._levels[skill] = min(skill.maxLv, max(0, int(value)))
self._dirty = self._state_dirty = True
def set_attribute(self, skill, name, value):
if name in STATE_ATTRIBUTES:
self._state_base[skill, name] = value
object.__setattr__(skill, name, value)
self.changed()
def is_learned(self, skill):
if skill.level_source is not None:
return skill.lv > 0
return self._levels[skill] > 0
def read_level(self, skill):
if self.collecting is not None and self.collecting is not skill:
self._reads.add(skill)
elif self._state_dirty and not self.resolving:
self.resolve_states()
def record(self, source, target, attribute, operation, value):
self._pending.append(SkillContribution(
source, len(self._pending), target, attribute, operation, value,
))
def missing_target(self, source, name):
self.unresolved_targets.add((source.name, name))
def _collect(self, skill):
# 一个重算周期会多次调用 effect;职业实现只能构造声明并调用公共收集入口。
self._pending = []
self._reads = set()
if not skill.association_source or skill.lv <= 0:
return (), set()
# VP branches often only assign associate when enabled. Start from the
# declaration every time so disabling VP also removes the old branch.
skill.associate = self._associations[skill]
self.collecting = skill
try:
skill.effect(0, skill.lv)
return tuple(self._pending), self._reads.copy()
finally:
self.collecting = None
@staticmethod
def _combine(base, contributions):
contributions = sorted(contributions, key=lambda item: (item.source.id, item.index))
overrides = {item.value for item in contributions if item.operation == '='}
if len(overrides) > 1:
sources = ', '.join(item.source.name for item in contributions if item.operation == '=')
raise ValueError(f'关联赋值冲突: {sources}')
if overrides:
base = overrides.pop()
return (base + sum(item.value for item in contributions if item.operation == '+')) * prod(
item.value for item in contributions if item.operation == '*'
)
def _restore_outputs(self):
for (target, attr), value in self._output_base.items():
if value is _MISSING:
object.__delattr__(target, attr)
else:
object.__setattr__(target, attr, value)
self._output_base.clear()
def resolve_states(self):
# 依赖来源:level_source、跨技能 lv 读取、指向 STATE_ATTRIBUTES 的关联。
# 其他跨技能属性读取不会自动建边;新增此类规则须先扩展依赖机制。
if self.resolving or not self._state_dirty:
return
self.resolving = True
try:
self._restore_outputs()
self.unresolved_targets.clear()
for skill, lv in self._levels.items():
skill._lv = min(skill.maxLv, max(0, lv))
for (skill, attr), value in self._state_base.items():
object.__setattr__(skill, attr, value)
graph = {skill: set() for skill in self.skills}
for skill in self.skills:
if skill.level_source is not None:
source = self.character.GetSkillByName(skill.level_source)
if source is None:
raise ValueError(f'共享等级源技能不存在: {skill.name} -> {skill.level_source}')
graph[skill].add(source)
contributions, reads = self._collect(skill)
graph[skill].update(reads)
for item in contributions:
if item.target is not self.character and item.attribute in STATE_ATTRIBUTES:
graph[item.target].add(skill)
try:
order = tuple(TopologicalSorter(graph).static_order())
except CycleError as exc:
names = ' -> '.join(skill.name for skill in exc.args[1])
raise ValueError(f'技能关联状态存在循环依赖: {names}') from exc
states = defaultdict(list)
for skill in order:
# All upstream level/parameter contributions are ready now.
if skill.level_source is not None:
skill._lv = self.character.GetSkillByName(skill.level_source).lv
contributions, reads = self._collect(skill)
if not reads.issubset(graph[skill]):
raise ValueError(f'技能关联状态依赖随计算改变: {skill.name}')
for item in contributions:
if item.target is self.character or item.attribute not in STATE_ATTRIBUTES:
continue
if skill not in graph[item.target]:
raise ValueError(f'技能关联状态目标随计算改变: {skill.name} -> {item.target.name}')
key = item.target, item.attribute
states[key].append(item)
if item.attribute == 'lv':
base = self._levels[item.target]
value = self._combine(base, states[key])
item.target._lv = min(item.target.maxLv, max(0, int(value)))
else:
base = self._state_base[key]
object.__setattr__(item.target, item.attribute, self._combine(base, states[key]))
self._state_dirty = False
finally:
self.resolving = False
def resolve(self):
if self.resolving or not self._dirty:
return
self.resolve_states()
self.resolving = True
try:
self._restore_outputs()
contributions = []
grouped = defaultdict(list)
for skill in self.skills:
items, _ = self._collect(skill)
contributions.extend(items)
for item in items:
if item.target is not self.character and item.attribute in STATE_ATTRIBUTES:
continue
grouped[item.target, item.attribute].append(item)
for (target, attr), items in grouped.items():
base = getattr(target, attr, _MISSING)
self._output_base[target, attr] = base
if base is _MISSING and not any(item.operation == '=' for item in items):
raise AttributeError(f'技能关联属性不存在: {items[0].source.name} -> {attr}')
object.__setattr__(target, attr, self._combine(0 if base is _MISSING else base, items))
self.contributions = tuple(contributions)
self._dirty = False
finally:
self.resolving = False
@contextmanager
def batch(self):
# 最外层先撤销技能输出贡献,让装备等外部写入落在基础值上,再统一重建。
# 这里只隔离技能贡献,不解决装备规则彼此之间的执行顺序依赖。
outermost = not self._depth
if outermost:
self.resolving = True
try:
self._restore_outputs()
finally:
self.resolving = False
self._dirty = self._state_dirty = True
self._depth += 1
completed = False
try:
yield
completed = True
finally:
self._depth -= 1
if completed and not self._depth:
self.resolve()
+1 -8
View File
@@ -1439,14 +1439,7 @@ class Skill38(CreatorActiveSkill):
mode = ['秒炸', '常规'] mode = ['秒炸', '常规']
@property level_source = '创世'
def lv(self):
"""技能等级"""
return self.char.GetSkillByName("创世").lv
@lv.setter
def lv(self, value):
...
def setMode(self, mode): def setMode(self, mode):
if mode == '秒炸': if mode == '秒炸':
@@ -2210,14 +2210,7 @@ class Skill48(ActiveSkill):
hit2 = 8 hit2 = 8
dataType2 = EnumDamageType.ENCHANTPOISON dataType2 = EnumDamageType.ENCHANTPOISON
@property level_source = '万毒噬心诀'
def lv(self):
"""技能等级"""
return self.char.GetSkillByName("万毒噬心诀").lv
@lv.setter
def lv(self, value):
...
# 身化万毒 # 身化万毒
# fighter_female/brawler_female/78b86e64fbb74c1db1b71c50a5ac21cd # fighter_female/brawler_female/78b86e64fbb74c1db1b71c50a5ac21cd
@@ -1361,14 +1361,7 @@ class Skill40(ActiveSkill):
data0 = get_data(f'{prefix}/{uuid}', 0) data0 = get_data(f'{prefix}/{uuid}', 0)
hit0 = 1 hit0 = 1
@property level_source = '烈焰焚步'
def lv(self):
"""技能等级"""
return self.char.GetSkillByName("烈焰焚步").lv
@lv.setter
def lv(self, value):
...
# 飞燕旋风 # 飞燕旋风
# fighter_male/striker_male/8e358ecf99ac9df31a6132aeafe378a9 # fighter_male/striker_male/8e358ecf99ac9df31a6132aeafe378a9
@@ -682,7 +682,7 @@ class Skill22(PassiveSkill):
def effect(self, old, new): def effect(self, old, new):
skills = [] skills = []
for i in self.char.skills: for i in self.char.skills:
if i.type == "active" and i.learnLv not in [50,85,100] and i.damage and i.lv > 0: if i.type == "active" and i.learnLv not in [50,85,100] and i.damage and self.char.skill_effects.is_learned(i):
skills.append(i.name) skills.append(i.name)
self.associate = [{"type":"+lv","data":[0] + [1]*self.maxLv,"ratio":1,"skills":skills}] self.associate = [{"type":"+lv","data":[0] + [1]*self.maxLv,"ratio":1,"skills":skills}]
super().effect(old, new) super().effect(old, new)
@@ -672,7 +672,7 @@ class Skill22(PassiveSkill):
def effect(self, old, new): def effect(self, old, new):
skills = [] skills = []
for i in self.char.skills: for i in self.char.skills:
if i.type == "active" and i.learnLv not in [50,85,100] and i.damage and i.lv > 0: if i.type == "active" and i.learnLv not in [50,85,100] and i.damage and self.char.skill_effects.is_learned(i):
skills.append(i.name) skills.append(i.name)
self.associate = [{"type":"+lv","data":[0] + [1]*self.maxLv,"ratio":1,"skills":skills}] self.associate = [{"type":"+lv","data":[0] + [1]*self.maxLv,"ratio":1,"skills":skills}]
super().effect(old, new) super().effect(old, new)
@@ -912,25 +912,10 @@ class Skill25(ActiveSkill):
# 蓄气时间上限减少率 : {value3}% # 蓄气时间上限减少率 : {value3}%
data3 = get_data(f'{prefix}/{uuid}', 3) data3 = get_data(f'{prefix}/{uuid}', 3)
_ratio = 1 ratio = 1.0
associate = [{"type":"*cdRatio","data":data1,"skills":['冰墙', '元素之门', '元素之幕', '元素震荡', '圣灵水晶', '烈焰冲击','天雷', '雷旋', '杰克降临', '湮灭黑洞', '极冰盛宴', '虚无之球', '光与暗的交响']}] associate = [{"type":"*cdRatio","data":data1,"skills":['冰墙', '元素之门', '元素之幕', '元素震荡', '圣灵水晶', '烈焰冲击','天雷', '雷旋', '杰克降临', '湮灭黑洞', '极冰盛宴', '虚无之球', '光与暗的交响']}]
@property
def ratio(self):
return self._ratio
@ratio.setter
def ratio(self, value):
if self._ratio != value:
data = self.associate[0]
ratio = (1 - data['data'][self.lv] / 100 * value) / (1 - data['data'][self.lv] / 100 * self._ratio)
for item in data['skills']:
skill = self.char.GetSkillByName(item)
if skill is not None:
skill.cdRatio *= ratio
self._ratio = value
# 技能等级变化的时候,系数对应的数值变化 # 技能等级变化的时候,系数对应的数值变化
def update_skill_attribute(self, skill, type, old, new, data, ratio=100, weapon=(), weapon_type=None, weapon_options=()): def update_skill_attribute(self, skill, type, old, new, data, ratio=100, weapon=(), weapon_type=None, weapon_options=()):
super().update_skill_attribute(skill, type, old, new, data, ratio / self.ratio, weapon, weapon_type, weapon_options) super().update_skill_attribute(skill, type, old, new, data, ratio / self.ratio, weapon, weapon_type, weapon_options)
@@ -1139,25 +1124,10 @@ class Skill31(PassiveSkill):
# 所有属性抗性增加值 : {value1} # 所有属性抗性增加值 : {value1}
data1 = get_data(f'{prefix}/{uuid}', 1) data1 = get_data(f'{prefix}/{uuid}', 1)
_ratio = 1 ratio = 1.0
associate = [{"type":"*skillDamage","data":data0}] associate = [{"type":"*skillDamage","data":data0}]
@property
def ratio(self):
return self._ratio
@ratio.setter
def ratio(self, value):
if self._ratio != value:
data = self.associate[0]
ratio = (1 + data['data'][self.lv] / 100 * value) / (1 + data['data'][self.lv] / 100 * self._ratio)
for item in self.char.GetSkillNames('all', True):
skill = self.char.GetSkillByName(item)
if skill is not None:
skill.skillRation *= ratio
self._ratio = value
# 技能等级变化的时候,系数对应的数值变化 # 技能等级变化的时候,系数对应的数值变化
def update_skill_attribute(self, skill, type, old, new, data, ratio=100, weapon=(), weapon_type=None, weapon_options=()): def update_skill_attribute(self, skill, type, old, new, data, ratio=100, weapon=(), weapon_type=None, weapon_options=()):
super().update_skill_attribute(skill, type, old, new, data, ratio / self.ratio, weapon, weapon_type, weapon_options) super().update_skill_attribute(skill, type, old, new, data, ratio / self.ratio, weapon, weapon_type, weapon_options)
@@ -2924,14 +2924,7 @@ class Skill60(ActiveSkill):
# 自身强化 # 自身强化
skillDamage = 1.15 skillDamage = 1.15
@property level_source = '传说召唤 : 逆月者拉莫斯'
def lv(self):
"""技能等级"""
return self.char.GetSkillByName("传说召唤 : 逆月者拉莫斯").lv
@lv.setter
def lv(self, value):
...
# 逆月 # 逆月
# mage_female/summoner/10834437b54f67c4c27f92eaabd59bb1 # mage_female/summoner/10834437b54f67c4c27f92eaabd59bb1
@@ -2505,13 +2505,7 @@ class Skill49(ActiveSkill):
associate = [{'data': data3, 'skills': ['变异苍蝇拍', '超级苍蝇拍', '改良舒露露', '改良魔法星弹', '暗影斗篷', '技艺融合', '超级棒棒糖', '光电兔', '雪人刨冰', '乌洛波洛斯之环', '糖果大作战精怪乐园', '糖果大作战捣蛋杰克']}, {'data': data4, 'type': '+powerFail', 'skills': ['熔岩药瓶', '暴炎加热炉', '冰霜钻孔车']}] associate = [{'data': data3, 'skills': ['变异苍蝇拍', '超级苍蝇拍', '改良舒露露', '改良魔法星弹', '暗影斗篷', '技艺融合', '超级棒棒糖', '光电兔', '雪人刨冰', '乌洛波洛斯之环', '糖果大作战精怪乐园', '糖果大作战捣蛋杰克']}, {'data': data4, 'type': '+powerFail', 'skills': ['熔岩药瓶', '暴炎加热炉', '冰霜钻孔车']}]
@property level_source = '贤者之石'
def lv(self):
"""技能等级"""
return self.char.GetSkillByName('贤者之石').lv
@lv.setter
def lv(self, value): ...
def getSkillData(self, lv = 0): def getSkillData(self, lv = 0):
pre = self.char.GetSkillByName('超级苍蝇拍') pre = self.char.GetSkillByName('超级苍蝇拍')
@@ -1383,14 +1383,7 @@ class Skill33(ActiveSkill):
associate = [{"data": [0] + [10]*maxLv,"skills":[name]}] associate = [{"data": [0] + [10]*maxLv,"skills":[name]}]
@property level_source = '魔化 : 末日守卫者'
def lv(self):
"""技能等级"""
return self.char.GetSkillByName("魔化 : 末日守卫者").lv
@lv.setter
def lv(self, value):
...
def skillInfo(self, mode = None): def skillInfo(self, mode = None):
pre = self.char.GetSkillByName("魔化 : 末日守卫者") pre = self.char.GetSkillByName("魔化 : 末日守卫者")
@@ -1725,13 +1725,7 @@ class Skill47(ActiveSkill):
hasVP = False hasVP = False
hasUP = False hasUP = False
@property level_source = '神圣洗礼 : 信仰之翼'
def lv(self):
"""技能等级"""
return self.char.GetSkillByName('神圣洗礼 : 信仰之翼').lv
@lv.setter
def lv(self, value): ...
# 爆炸攻击力 : {value0} # 爆炸攻击力 : {value0}
data0 = get_data(f'{prefix}/{uuid}', 0) data0 = get_data(f'{prefix}/{uuid}', 0)
@@ -652,13 +652,7 @@ class Skill21(ActiveSkill):
# 范围比率 : {value3}% # 范围比率 : {value3}%
data3 = get_data(f'{prefix}/{uuid}', 3) data3 = get_data(f'{prefix}/{uuid}', 3)
@property level_source = '三才回轮剑'
def lv(self):
"""技能等级"""
return self.char.GetSkillByName("三才回轮剑").lv
@lv.setter
def lv(self, value): ...
# 轮回剑 # 轮回剑
# swordman_female/vegabond/852f8ad797db4dca1405cb3e77198401 # swordman_female/vegabond/852f8ad797db4dca1405cb3e77198401
@@ -695,13 +689,7 @@ class Skill22(ActiveSkill):
# 范围比率 : {value3}% # 范围比率 : {value3}%
data3 = get_data(f'{prefix}/{uuid}', 3) data3 = get_data(f'{prefix}/{uuid}', 3)
@property level_source = '三才回轮剑'
def lv(self):
"""技能等级"""
return self.char.GetSkillByName("三才回轮剑").lv
@lv.setter
def lv(self, value): ...
# 三才回轮剑 # 三才回轮剑
# swordman_female/vegabond/ab6fc3303df03b58911967dfca2b5d07 # swordman_female/vegabond/ab6fc3303df03b58911967dfca2b5d07
@@ -1052,14 +1052,7 @@ class Skill31(ActiveSkill):
data0 = get_data(f'{prefix}/{uuid}', 0) data0 = get_data(f'{prefix}/{uuid}', 0)
hit0 = 3 hit0 = 3
@property level_source = '鬼连击'
def lv(self):
"""技能等级"""
return self.char.GetSkillByName("鬼连击").lv
@lv.setter
def lv(self, value):
...
def getSkillCD(self,mode=None): def getSkillCD(self,mode=None):
return self.char.GetSkillByName("鬼连击").getSkillCD(mode) return self.char.GetSkillByName("鬼连击").getSkillCD(mode)
@@ -1746,13 +1746,7 @@ class Skill42(ActiveSkill):
# [冥炎剑]最后一击范围比率 : {value4}% # [冥炎剑]最后一击范围比率 : {value4}%
data4 = get_data(f'{prefix}/{uuid}', 4) data4 = get_data(f'{prefix}/{uuid}', 4)
@property level_source = '冥炎之卡洛'
def lv(self):
"""技能等级"""
return self.char.GetSkillByName('冥炎之卡洛').lv
@lv.setter
def lv(self, value): ...
def vp_1(self): def vp_1(self):
""" """
+2 -18
View File
@@ -742,26 +742,10 @@ class Skill20(ActiveSkill):
# 7点 : {value7}% # 7点 : {value7}%
data7 = get_data(f'{prefix}/{uuid}', 7) data7 = get_data(f'{prefix}/{uuid}', 7)
_maxLayer = 6 maxLayer = 6
associate = [{'type': '*endpower', 'data': data0}, {'type': '*endpower'}] associate = [{'type': '*endpower', 'data': data0}, {'type': '*endpower'}]
@property
def maxLayer(self):
return self._maxLayer
@maxLayer.setter
def maxLayer(self, value):
if self._maxLayer != value:
skills = self.char.GetSkillNames('all', True)
if self.lv > 0:
raito = (getattr(self, f'data{int(value)}')[self.lv] - 100) / (getattr(self, f'data{int(self.maxLayer)}')[self.lv] - 100)
for name in skills:
skill = self.char.GetSkillByName(name)
if skill.type == 'active' and skill.damage:
skill.endpower *= raito
self._maxLayer = value
def update_skill_attribute(self, skill, type, old, new, data, ratio=100, weapon=(), weapon_type=None, weapon_options=()): def update_skill_attribute(self, skill, type, old, new, data, ratio=100, weapon=(), weapon_type=None, weapon_options=()):
if len(data) == 0: if len(data) == 0:
data = getattr(self, f'data{int(self.maxLayer)}') data = getattr(self, f'data{int(self.maxLayer)}')
@@ -1080,7 +1064,7 @@ class Skill27(PassiveSkill):
associate = [ associate = [
{'type': '$*PAtkP', 'data': data0}, {'type': '$*PAtkP', 'data': data0},
{'type': '*cdReduce', 'data': data1, 'exceptSkills': []}, {'type': '*cdReduce', 'data': data1, 'exceptSkills': []},
{'type': '+maxLayer', 'data': data2, 'ratio': 1, 'skills': ['终结追击']}, {'type': '+maxLayer', 'data': data2, 'ratio': 1, 'skills': ['终结之击']},
] ]
# 旋舞斩 # 旋舞斩
@@ -102,3 +102,50 @@ def test_all_equipment_can_calculate_when_equipped_once(equ, part, minimal_calc_
assert character is not None assert character is not None
_assert_calc_result_shape(character.calc(config)) _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]
+329
View File
@@ -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)