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

This commit is contained in:
2026-10-13 17:10:08 +08:00
parent 7dc5f4cde5
commit 927866d366
78 changed files with 5242 additions and 1011 deletions
+5 -1
View File
@@ -9,6 +9,7 @@ from .base import CharacterBase
from .config_view import CalcConfigView
from .effect_apply import apply_uppercase_attrs
from ..skill_effects import skill_effect_batch
from ..skill import Skill
@cache
@@ -17,7 +18,10 @@ def get_skill_classes(module_name: str) -> tuple[type, ...]:
return tuple(
memberClass
for name in dir(module)
if isinstance((memberClass := getattr(module, name)), type) and memberClass.__name__.startswith('Skill')
if isinstance((memberClass := getattr(module, name)), type)
and memberClass is not Skill
and issubclass(memberClass, Skill)
and memberClass.__name__.startswith('Skill')
)
+10 -1
View File
@@ -9,6 +9,7 @@ from database.connect import get_redis_client
from api.core.Redis import get_redis_info
from api.utils.openapi_data import OPENAPI_CUSTOM_CN_PATH, logical_skill_detail_path
from .skill_effects import CHARACTER_ATTRIBUTES, INPUT_ATTRIBUTES
from .skill_effect_types import CharacterEffect, EffectDeclaration, SkillEffect
if TYPE_CHECKING:
from core.basic.character import Character
@@ -143,9 +144,11 @@ class Skill:
"""技能冷却倍率,直接体现在面板上,不被上限影响"""
cdRecover: float = 1
"""技能冷却恢复"""
associate: list = []
associate: list[dict[str, Any] | EffectDeclaration] = []
"""关联声明;维护示例和扩展边界见 core/basic/skill_effects.py 文件顶部说明。
新规则可用 SkillEffect/CharacterEffect,与旧字典混用;目标仍使用技能名称数组。
新声明 value 是实际倍率/增量/覆盖值,以下 type、data、ratio 约定仅适用于旧字典。
type 默认 '*skillDamage':*乘算、+加算、=覆盖;$*PAtkP 等作用于角色。
skills 默认 '*',表示所有伤害技能;显式列表使用技能名称,可指定被动技能。
exceptSkills 默认 [];weapon 默认职业武器选项;precondition() 控制前置条件。
@@ -240,6 +243,12 @@ class Skill:
weapon_type = self.char.GetWeaponType()[0]
weapon_options = self.char.武器选项
for assoc in self.associate:
if isinstance(assoc, (SkillEffect, CharacterEffect)):
engine = getattr(self.char, 'skill_effects', None)
if engine is None:
raise RuntimeError('类型化效果需要 SkillEffectEngine')
engine.collect_effect(self, assoc)
continue
type = assoc.get('type', '*skillDamage')
skills = assoc.get('skills', '*')
data = assoc.get('data', [0] * self.maxLv)
+549
View File
@@ -0,0 +1,549 @@
"""有类型的技能关联声明;完整维护说明见 skill_effects.py 文件顶部。
表达式不可变、无副作用,并在求值前声明依赖。关联目标仍使用技能名称数组。
公开类型、枚举成员和字段的中文文档用于编辑器补全与悬停提示。
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from enum import Enum
from math import isfinite
from typing import TYPE_CHECKING, Literal, cast
if TYPE_CHECKING:
from .skill import Skill
class Operation(str, Enum):
"""效果汇总运算:先覆盖基础值,再加算,最后乘算。"""
ADD = '+'
"""加算:向目标属性增加 value;等级 +1 应传 1。"""
MULTIPLY = '*'
"""乘算:目标属性乘以 value;提高 20% 应传 1.2。"""
ASSIGN = '='
"""覆盖:用 value 替换基础值;不同来源的不同覆盖值会报冲突。"""
class SkillAttribute(str, Enum):
"""技能公共属性。等级、参数先求值;伤害、冷却随后汇总。"""
LEVEL = 'lv'
"""技能等级:加级仅作用于已学习技能,共享等级应修改源技能。"""
RATIO = 'ratio'
"""关联参数倍率:例如手雷精通的倍率参数,不是百分比除数。"""
MAX_LAYER = 'maxLayer'
"""最大层数:可影响后续关联求值的状态参数。"""
VP = 'vp'
"""技能进化选项:0 未选择、1/2 对应 VP 分支;只读外部输入。"""
UP = 'up'
"""技能强化选项:只读外部输入,不接受关联效果写入。"""
DAMAGE = 'skillDamage'
"""技能技攻倍率:影响最终伤害,不改变技能面板数据。"""
PANEL_RATIO = 'skillRation'
"""技能面板倍率:改变返回的技能面板数据。"""
CD = 'cd'
"""基础冷却时间,单位为秒。"""
CD_CUT = 'cdCut'
"""冷却时间直接减少量,单位为秒。"""
CD_REDUCE = 'cdReduce'
"""技能冷却缩减倍率:缩减 10% 应乘以 0.9。"""
CD_RATIO = 'cdRatio'
"""面板冷却倍率:直接作用于面板冷却,不受冷却缩减上限影响。"""
CD_RECOVER = 'cdRecover'
"""冷却恢复倍率:提高恢复速度应按实际倍率或增量声明。"""
class ProfessionAttribute(str, Enum):
"""职业专用输出属性的枚举基类;在职业文件中继承并为成员补充中文文档。
例如 HunterAttribute.MARK_RATIO = 'markRation'。仅用于效果写入,不可通过
Read 读取。需要参与依赖求值的状态属性,应在公共状态注册处显式维护。
属性名必须对应目标技能已有的数值字段;不接受公共属性或编号分段属性的重定义。
"""
class CharacterAttribute(str, Enum):
"""当前支持关联的角色标量属性;特殊属性不能只添加枚举便直接套用。"""
STR = 'STR'
"""角色力量。"""
INT = 'INT'
"""角色智力。"""
SPIRIT = 'Spirit'
"""角色精神。"""
VITALITY = 'Vitality'
"""角色体力。"""
ATK_P = 'AtkP'
"""物理攻击力数值。"""
ATK_M = 'AtkM'
"""魔法攻击力数值。"""
ATK_I = 'AtkI'
"""独立攻击力数值。"""
P_ATK_P = 'PAtkP'
"""物理攻击力倍率,例如武器精通的物攻加成。"""
P_ATK_M = 'PAtkM'
"""魔法攻击力倍率。"""
P_ATK_I = 'PAtkI'
"""独立攻击力倍率。"""
P_STR = 'PSTR'
"""力量倍率。"""
P_INT = 'PINT'
"""智力倍率。"""
class ComponentKind(str, Enum):
"""技能分段属性类型,结合非负整数索引定位具体分段。"""
POWER = 'power'
"""普通伤害分段倍率,对应 powerN。"""
EXTRA_DATA = 'dataplus'
"""额外伤害数据,对应 dataplusN。"""
EXTRA_POWER = 'powerplus'
"""额外伤害分段倍率,对应 powerplusN。"""
HIT = 'hit'
"""分段攻击次数,对应 hitN。"""
@dataclass(frozen=True)
class ComponentAttribute:
"""有编号的技能分段属性,例如 power13、dataplus0。"""
kind: ComponentKind
"""分段属性类型。"""
index: int
"""从 0 开始的分段索引。"""
def __post_init__(self):
if not isinstance(self.kind, ComponentKind) or type(self.index) is not int or self.index < 0:
raise ValueError('技能分段属性需要 ComponentKind 和非负整数索引')
@property
def value(self) -> str:
"""底层技能属性名称,由分段类型与索引组成。"""
return f'{self.kind.value}{self.index}'
def Power(index: int) -> ComponentAttribute:
"""普通分段倍率。index 为从 0 开始的分段编号;Power(13) 对应 power13。"""
return ComponentAttribute(ComponentKind.POWER, index)
def ExtraData(index: int) -> ComponentAttribute:
"""额外伤害数据。index 为分段编号;ExtraData(0) 对应 dataplus0。"""
return ComponentAttribute(ComponentKind.EXTRA_DATA, index)
def ExtraPower(index: int) -> ComponentAttribute:
"""额外分段倍率。index 为分段编号;ExtraPower(0) 对应 powerplus0。"""
return ComponentAttribute(ComponentKind.EXTRA_POWER, index)
def Hit(index: int) -> ComponentAttribute:
"""分段攻击次数。index 为分段编号;Hit(0) 对应 hit0。"""
return ComponentAttribute(ComponentKind.HIT, index)
STATE_ATTRIBUTES = frozenset({SkillAttribute.LEVEL.value, SkillAttribute.RATIO.value, SkillAttribute.MAX_LAYER.value})
READ_ATTRIBUTES = STATE_ATTRIBUTES | {SkillAttribute.VP.value, SkillAttribute.UP.value}
# VP/UP are configuration inputs, not writable association outputs.
WRITE_ATTRIBUTES = frozenset(attr.value for attr in SkillAttribute) - {'vp', 'up'}
def _number(value: float) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value):
raise TypeError(f'效果表达式需要有限数值,实际为 {value!r}')
return value
class Expression:
"""数值表达式基类,支持四则运算;新增节点必须同时实现求值和依赖提取。"""
def evaluate(self, source: Skill) -> float:
"""使用来源技能的最终状态求值,不得修改技能或角色。"""
raise NotImplementedError
def dependencies(self) -> frozenset[Read]:
"""返回全部潜在读取,包括当前未启用的条件分支。"""
raise NotImplementedError
def __add__(self, other: Expression | float) -> Expression:
return Binary(self, '+', expression(other))
def __radd__(self, other: Expression | float) -> Expression:
return expression(other) + self
def __sub__(self, other: Expression | float) -> Expression:
return Binary(self, '-', expression(other))
def __rsub__(self, other: Expression | float) -> Expression:
return expression(other) - self
def __mul__(self, other: Expression | float) -> Expression:
return Binary(self, '*', expression(other))
def __rmul__(self, other: Expression | float) -> Expression:
return expression(other) * self
def __truediv__(self, other: Expression | float) -> Expression:
return Binary(self, '/', expression(other))
def __rtruediv__(self, other: Expression | float) -> Expression:
return expression(other) / self
def expression(value: Expression | float) -> Expression:
"""将数值包装为常量表达式,已有表达式原样返回。"""
return value if isinstance(value, Expression) else Constant(value)
@dataclass(frozen=True)
class Constant(Expression):
"""有限数值常量;声明中直接传入数字时会自动包装。"""
value: float
"""常量数值,不接受布尔值、无穷大或 NaN。"""
def __post_init__(self):
_number(self.value)
def evaluate(self, source: Skill) -> float:
return self.value
def dependencies(self) -> frozenset[Read]:
return frozenset()
@dataclass(frozen=True)
class Read(Expression):
"""读取技能状态或输入;可分析跨技能依赖,不支持读取最终伤害/冷却输出。
Args:
attribute: 要读取的等级、ratio、最大层数、VP 或 UP。
skill: 被读取技能的名称,省略时读取自身。
"""
attribute: SkillAttribute
"""要读取的等级、ratio、最大层数、VP 或 UP。"""
skill: str | None = None
"""源技能名称;省略时读取声明所属技能自身。"""
def __post_init__(self):
if not isinstance(self.attribute, SkillAttribute) or self.attribute.value not in READ_ATTRIBUTES:
raise ValueError('Read 仅支持等级、ratio、maxLayer、VP、UP;不能读取尚未汇总的输出属性')
if self.skill is not None and (not isinstance(self.skill, str) or not self.skill):
raise TypeError('Read 的技能引用必须是非空技能名称,省略时读取自身')
def target(self, source: Skill) -> Skill:
"""按名称解析被读取的技能,并检查目标属性是否存在。"""
target = source if self.skill is None else source.char.GetSkillByName(self.skill)
if target is None:
raise ValueError(f'效果表达式引用技能不存在: {source.name} -> {self.skill}')
if not hasattr(target, self.attribute.value):
raise ValueError(f'效果表达式引用属性不存在: {source.name} -> {target.name}.{self.attribute.value}')
return target
def evaluate(self, source: Skill) -> float:
return _number(getattr(self.target(source), self.attribute.value))
def dependencies(self) -> frozenset[Read]:
return frozenset({self})
@dataclass(frozen=True)
class Binary(Expression):
"""四则运算节点,通常通过表达式之间的 +、-、*、/ 自动构造。"""
left: Expression
"""左侧数值表达式。"""
operation: Literal['+', '-', '*', '/']
"""四则运算符;与效果汇总运算 Operation 不同。"""
right: Expression
"""右侧数值表达式;除法求值时不能为零。"""
def __post_init__(self):
if self.operation not in ('+', '-', '*', '/'):
raise ValueError(f'未知表达式运算: {self.operation}')
object.__setattr__(self, 'left', expression(self.left))
object.__setattr__(self, 'right', expression(self.right))
def evaluate(self, source: Skill) -> float:
left, right = self.left.evaluate(source), self.right.evaluate(source)
if self.operation == '+':
return _number(left + right)
if self.operation == '-':
return _number(left - right)
if self.operation == '*':
return _number(left * right)
if right == 0:
raise ValueError(f'效果表达式除数为零: {source.name}')
return _number(left / right)
def dependencies(self) -> frozenset[Read]:
return self.left.dependencies() | self.right.dependencies()
@dataclass(frozen=True)
class LevelTable(Expression):
"""按等级查表;默认使用来源技能最终等级,越界返回 0,沿用旧表行为。
Args:
values: 等级数据数组,包含索引 0 对应的零级值。
level: 等级索引表达式,默认读取来源技能最终等级。
"""
values: Sequence[float]
"""等级数据数组,索引 0 表示零级;构造时复制为不可变元组。"""
level: Expression = field(default_factory=lambda: Read(SkillAttribute.LEVEL))
"""等级索引表达式,可传 Read(SkillAttribute.LEVEL, '其他技能名称')。"""
def __post_init__(self):
object.__setattr__(self, 'values', tuple(_number(value) for value in self.values))
object.__setattr__(self, 'level', expression(self.level))
def evaluate(self, source: Skill) -> float:
level = self.level.evaluate(source)
if int(level) != level:
raise ValueError(f'等级表索引必须为整数: {source.name} -> {level}')
return self.values[int(level)] if 0 <= level < len(self.values) else 0
def dependencies(self) -> frozenset[Read]:
return self.level.dependencies()
def Percent(value: Expression | float) -> Expression:
"""百分数转小数:110 → 1.10。提高 110% 的倍率应写 1 + Percent(110)。"""
return expression(value) / 100
class Condition:
"""效果条件基类;条件的状态读取也必须声明为依赖。"""
def evaluate(self, source: Skill) -> bool:
"""判断最终状态是否满足条件。"""
raise NotImplementedError
def dependencies(self) -> frozenset[Read]:
"""返回条件判断需要读取的全部技能状态。"""
raise NotImplementedError
@dataclass(frozen=True)
class Equals(Condition):
"""相等条件,例如 Equals(Read(SkillAttribute.VP), 1) 表示选择 VP1。"""
left: Expression | float
"""左侧数值或表达式。"""
right: Expression | float
"""右侧数值或表达式。"""
def __post_init__(self):
object.__setattr__(self, 'left', expression(self.left))
object.__setattr__(self, 'right', expression(self.right))
def evaluate(self, source: Skill) -> bool:
return cast(Expression, self.left).evaluate(source) == cast(Expression, self.right).evaluate(source)
def dependencies(self) -> frozenset[Read]:
return cast(Expression, self.left).dependencies() | cast(Expression, self.right).dependencies()
@dataclass(frozen=True)
class GreaterThan(Equals):
"""大于条件,例如 GreaterThan(Read(SkillAttribute.LEVEL, '技能名称'), 0)。"""
def evaluate(self, source: Skill) -> bool:
return cast(Expression, self.left).evaluate(source) > cast(Expression, self.right).evaluate(source)
@dataclass(frozen=True)
class Not(Condition):
"""条件取反,仍保留原条件的全部依赖。"""
condition: Condition
"""需要取反的条件。"""
def __post_init__(self):
if not isinstance(self.condition, Condition):
raise TypeError('Not 需要 Condition 声明')
def evaluate(self, source: Skill) -> bool:
return not self.condition.evaluate(source)
def dependencies(self) -> frozenset[Read]:
return self.condition.dependencies()
@dataclass(frozen=True)
class All(Condition):
"""全部条件成立;建图时收集全部条件依赖,不因短路判断遗漏依赖。"""
conditions: Sequence[Condition]
"""条件数组,构造时复制为不可变元组。"""
def __post_init__(self):
if not all(isinstance(condition, Condition) for condition in self.conditions):
raise TypeError('All 需要 Condition 数组')
object.__setattr__(self, 'conditions', tuple(self.conditions))
def evaluate(self, source: Skill) -> bool:
return all(condition.evaluate(source) for condition in self.conditions)
def dependencies(self) -> frozenset[Read]:
return frozenset().union(*(condition.dependencies() for condition in self.conditions))
@dataclass(frozen=True)
class OutputTypeStartsWith(Condition):
"""角色输出类型前缀条件,例如物理/魔法;读取计算准备阶段确定的角色输入。"""
prefix: str
"""输出类型前缀,例如 '物理'。"""
def __post_init__(self):
if not isinstance(self.prefix, str) or not self.prefix:
raise ValueError('输出类型条件需要非空前缀')
def evaluate(self, source: Skill) -> bool:
return source.char.输出类型.startswith(self.prefix)
def dependencies(self) -> frozenset[Read]:
return frozenset()
@dataclass(frozen=True)
class WeaponCategoryContains(Condition):
"""武器分类包含指定文本;匹配现有分类字段,如“传世武器”。"""
category: str
"""武器分类中的匹配文本,支持“传世武器升级”等包含关系。"""
def __post_init__(self):
if not isinstance(self.category, str) or not self.category:
raise ValueError('武器分类条件需要非空名称')
def evaluate(self, source: Skill) -> bool:
return self.category in (source.char.GetWeaponType()[1] or '')
def dependencies(self) -> frozenset[Read]:
# Weapon facts are prepared by SetDetail before skill state evaluation.
return frozenset()
@dataclass(frozen=True)
class Select(Expression):
"""条件选值:只求值实际分支,但建图时收集条件及两个分支的全部依赖。"""
condition: Condition
"""选择分支的条件。"""
yes: Expression | float
"""条件成立时使用的数值表达式。"""
no: Expression | float
"""条件不成立时使用的数值表达式。"""
def __post_init__(self):
if not isinstance(self.condition, Condition):
raise TypeError('Select 需要 Condition 声明')
object.__setattr__(self, 'yes', expression(self.yes))
object.__setattr__(self, 'no', expression(self.no))
def evaluate(self, source: Skill) -> float:
branch = self.yes if self.condition.evaluate(source) else self.no
return cast(Expression, branch).evaluate(source)
def dependencies(self) -> frozenset[Read]:
return self.condition.dependencies() | cast(Expression, self.yes).dependencies() | cast(Expression, self.no).dependencies()
def _names(values: Sequence[str], label: str) -> tuple[str, ...]:
if isinstance(values, str) or not all(isinstance(name, str) and name for name in values):
raise TypeError(f'{label} 必须是非空名称组成的数组')
return tuple(values)
@dataclass(frozen=True, kw_only=True)
class _Effect:
"""效果声明公共字段;职业代码使用 SkillEffect 或 CharacterEffect。"""
value: Expression | float
"""实际贡献值:乘算填倍率,加算填增量,覆盖填目标值,不隐式除以 100。"""
operation: Operation = Operation.MULTIPLY
"""汇总运算,默认乘算。"""
when: Condition | None = None
"""生效条件;省略表示不附加条件,来源技能仍须已学习。"""
weapon: Sequence[str] | None = None
"""允许的武器类型数组,例如 ['光剑'];省略表示不限制武器类型。"""
def __post_init__(self):
object.__setattr__(self, 'value', expression(self.value))
if not isinstance(self.operation, Operation):
raise TypeError('效果运算必须使用 Operation')
if self.when is not None and not isinstance(self.when, Condition):
raise TypeError('效果条件必须使用 Condition 声明')
if self.weapon is not None:
object.__setattr__(self, 'weapon', _names(self.weapon, 'weapon'))
def dependencies(self) -> frozenset[Read]:
"""贡献值与生效条件的全部状态依赖。"""
return cast(Expression, self.value).dependencies() | (self.when.dependencies() if self.when is not None else frozenset())
def evaluate(self, source: Skill) -> float:
"""基于来源技能求出实际贡献值,并验证结果为有限数值。"""
return _number(cast(Expression, self.value).evaluate(source))
class MissingTargetPolicy(str, Enum):
"""缺失关联目标的处理方式;只对已确认的旧名称失配显式使用 SKIP。"""
ERROR = 'error'
"""默认报错,避免新增规则静默失效。"""
SKIP = 'skip'
"""跳过并记录 unresolved_targets,兼容旧规则中已存在的无效名称。"""
@dataclass(frozen=True, kw_only=True)
class SkillEffect(_Effect):
"""作用于技能的效果声明;skills 继续接收技能名称数组,默认所有伤害技能。
Args:
value: 实际贡献值或表达式;乘算使用倍率,加算使用增量。
operation: 汇总运算,默认乘算。
attribute: 目标属性,默认技能技攻;也支持编号分段和职业专用输出属性。
skills: 目标技能名称数组,默认 '*' 表示所有伤害技能。
except_skills: 排除的技能名称数组。
when: 生效条件,默认无额外条件。
weapon: 允许的武器类型数组,默认不限制。
missing_target: 缺失目标时默认报错;迁移旧名称失配时可显式跳过并记录。
"""
attribute: SkillAttribute | ProfessionAttribute | ComponentAttribute = SkillAttribute.DAMAGE
"""目标属性,默认技能技攻;分段属性使用 Power/Hit/ExtraData/ExtraPower,职业属性使用 ProfessionAttribute 子类。"""
skills: Sequence[str] | Literal['*'] = '*'
"""目标技能名称数组;'*' 仅选择伤害技能,指定名称时可关联被动技能。"""
except_skills: Sequence[str] = ()
"""从目标中排除的技能名称数组。"""
missing_target: MissingTargetPolicy = MissingTargetPolicy.ERROR
"""缺失目标处理策略;SKIP 仅用于保留已确认的旧名称失配行为。"""
def __post_init__(self):
super().__post_init__()
if isinstance(self.attribute, ProfessionAttribute):
name = self.attribute.value
if not isinstance(name, str) or not name.isidentifier() or name in {item.value for item in SkillAttribute}:
raise ValueError('职业属性必须使用独立的有效属性名,不能重定义公共属性')
if any(name.startswith(kind.value) and name[len(kind.value):].isdigit() for kind in ComponentKind):
raise ValueError('编号分段属性请使用 Power、Hit、ExtraData 或 ExtraPower')
elif not isinstance(self.attribute, ComponentAttribute) and (
not isinstance(self.attribute, SkillAttribute) or self.attribute.value not in WRITE_ATTRIBUTES
):
raise ValueError('效果目标必须是可写的 SkillAttribute;VP/UP 只能作为外部输入')
if self.skills != '*':
object.__setattr__(self, 'skills', _names(self.skills, 'skills'))
object.__setattr__(self, 'except_skills', _names(self.except_skills, 'except_skills'))
if not isinstance(self.missing_target, MissingTargetPolicy):
raise TypeError('缺失目标策略必须使用 MissingTargetPolicy')
@dataclass(frozen=True, kw_only=True)
class CharacterEffect(_Effect):
"""作用于角色标量属性的效果声明,沿用同一来源贡献汇总机制。
Args:
value: 实际贡献值或表达式,不隐式按百分比缩放。
attribute: 角色目标属性,例如 P_ATK_P 表示物理攻击力倍率。
operation: 汇总运算,默认乘算。
when: 生效条件,默认无额外条件。
weapon: 允许的武器类型数组,默认不限制。
"""
attribute: CharacterAttribute
"""角色目标属性,如 P_ATK_P(物理攻击力倍率)、SPIRIT(精神)。"""
def __post_init__(self):
super().__post_init__()
if not isinstance(self.attribute, CharacterAttribute):
raise TypeError('角色效果属性必须使用 CharacterAttribute')
EffectDeclaration = SkillEffect | CharacterEffect
"""类型化关联声明:技能效果或角色效果,均可放入 associate 数组。"""
+151 -18
View File
@@ -16,12 +16,13 @@
| 新增或修改的影响 | 维护位置 | 要点 |
| --- | --- | --- |
| 技伤、冷却、攻击段数等普通关联 | 对应 `core/character/GF/职业/角色.py` 的技能类 `associate` | 通常不改引擎;目标属性应有默认值 |
| 随 VP、等级或自身参数变化的关联 | 来源技能的 `effect` | 每次生成完整声明,再调用 `super().effect(old, new)` |
| 技能给其他技能加级 | 来源技能的 `associate`,使用 `+lv` | 整数加级设置 `ratio=1`;只给已学习的目标加级 |
| 随 VP、等级或自身参数变化的关联 | `associate` 中的 `Read`、`LevelTable`、`Select`、`when` | 旧动态 `effect` 仍兼容,新规则优先用表达式 |
| 技能给其他技能加级 | `SkillEffect(attribute=SkillAttribute.LEVEL, operation=Operation.ADD, ...)` | value 直接填整数增量;只给已学习的目标加级 |
| 生命之树这类共享等级 | 目标技能的 `level_source` | 指向源技能名称,不再写等级 getter/no-op setter |
| 新增会被关联修改、并影响后续关联的参数 | 技能类默认值 + `core/basic/skill_effects.py` 的 `STATE_ATTRIBUTES` | 例如现有 `ratio`、`maxLayer`;加入后自动属于输入属性 |
| 新增会被关联修改、并影响后续关联的参数 | 技能类默认值 + `skill_effect_types.py` 的 `SkillAttribute`、`STATE_ATTRIBUTES` | 新参数同时检查读写范围;状态属性自动属于输入属性 |
| 新增外部配置输入,改变它需要重新计算关联 | 技能类、配置赋值入口 + `INPUT_ATTRIBUTES` | 例如现有 `vp`;单纯登记不提供跨技能参数读取依赖 |
| 新增角色属性关联 | `skill.py` 的角色贡献收集分支、`CHARACTER_ATTRIBUTES`,并核对 `character/base.py` 的 `SetStatus` | 当前仅支持对应标量字段;特殊运算需单独实现 |
| 新增职业专用输出属性 | 职业文件内继承 `ProfessionAttribute` 定义枚举,并在目标技能设置数值默认值 | 成员紧跟中文文档字符串,IDE 可显示含义;不可通过 `Read` 读取 |
| 新增角色属性关联 | `skill_effect_types.py` 的 `CharacterAttribute`,并核对 `character/base.py` 的 `SetStatus` | 当前仅支持对应标量字段;特殊运算需单独实现 |
| 新增运算符、目标选择方式、跨技能非等级依赖 | `skill.py` 的公共关联入口 + `skill_effects.py` | 属于公共能力扩展,需配套单元测试 |
| 装备改变技能等级或倍率 | 原装备规则及 `character/base.py` 的公共修改方法 | 使用已有批处理方法;低层直接写入须在 `batch()` 内 |
| 仅影响单次技能展示/输出的模式、VP、UP 变换 | 原技能 `setMode`、`vp_N`、输出计算方法 | `skillInfo()` 使用临时副本,不要误改为持久关联 |
@@ -30,6 +31,61 @@
## 声明示例和数值约定
新代码优先使用 `skill_effect_types.py` 的不可变声明,`associate` 继续是数组,目标仍然使用技能名称数组:
```python
associate = [
SkillEffect(skills=['目标技能名称'], value=1 + Percent(LevelTable(data0))),
SkillEffect(attribute=SkillAttribute.LEVEL, operation=Operation.ADD,
skills=['目标精通名称'], value=Read(SkillAttribute.LEVEL) + 1),
SkillEffect(attribute=Power(13), operation=Operation.ADD,
skills=['破军升龙击'], value=Percent(LevelTable(data2))),
]
```
`SkillEffect` 作用于技能,`CharacterEffect` 作用于角色。Operation 明确区分加算、乘算、覆盖;
新声明的 value 是实际贡献值,不再自动按百分比缩放,也不根据属性名称推测冷却公式。
例如增加 20% 技伤使用 `1 + Percent(20)`,缩减 10% 冷却使用 `1 - Percent(10)`。
旧 data[0] 不为零的规则迁移时必须保留零级基准的除法/减法,不能直接省略。
`Read(SkillAttribute.RATIO, '技能名称')` 可分析跨技能状态依赖;省略名称表示读取自身。
`LevelTable` 按来源最终等级取表,`Select` 按条件选值,`when=Equals(Read(SkillAttribute.VP), 1)` 控制是否生效。
条件和未选分支也声明全部依赖,建图阶段不计算实际数值,避免在临时状态下发生除零或遗漏依赖。
当前 Read 支持等级、ratio、maxLayer、VP、UP,不能读取尚未汇总的伤害/冷却输出。
武器类型用 `weapon=['光剑']`,武器分类用 `WeaponCategoryContains('传世武器')`;武器信息来自提前完成的 SetDetail。
编号属性通过 `Power(n)`、`Hit(n)`、`ExtraData(n)`、`ExtraPower(n)` 构造,分别对应 powerN、hitN、dataplusN、powerplusN。
名称数组和等级表在构造时复制为元组,外部修改原数组不会影响已注册声明。
新类型、枚举成员、字段和构造函数的中文文档可供编辑器悬停和补全查看。
GF 职业的关联现已使用类型化声明,原 associate、动态 effect 和专用更新函数紧邻新实现注释保留。
男/女大枪的等级目标、黑暗武士的连招目标仍在 effect 内动态组装技能名称数组;贡献值和条件使用类型化声明。
模式、VP、UP 对临时技能副本的变换保留原实现,不属于持久关联。
旧名称失配显式使用 `MissingTargetPolicy.SKIP`,继续记录 unresolved_targets;新规则默认报错,不要为了消除报错批量设置 SKIP。
原先未生效的错误角色字段及特殊字典键保持原有效计算行为,迁移不同时修正规则。
### 职业专用属性
公共 `SkillAttribute` 仅维护通用字段。专用输出字段就近放在职业模块中,例如:
```python
class HunterAttribute(ProfessionAttribute):
'''猎人专用输出属性。'''
MARK_RATIO = 'markRation'
'''每层标记对应的技能伤害增量。'''
# 目标技能先定义 markRation = 0,然后声明:
associate = [SkillEffect(attribute=HunterAttribute.MARK_RATIO,
operation=Operation.ADD, value=Percent(LevelTable(data0)))]
```
不同职业可各自定义同名字段。枚举值必须对应目标技能的实际数值属性;不能重定义公共字段或编号分段字段。
专用枚举只用于最终输出,不自动加入状态依赖。将来某参数需要被后续关联读取时,应先登记公共状态/读写范围,
再使用 Read 表达式,不能直接读取尚未汇总的输出。条件可组合 `All`、`Not`、`GreaterThan`;
角色输出类型用 `OutputTypeStartsWith` 判断,所有条件均在建图时提供完整依赖。
### 旧字典兼容
以下片段放在对应技能类内,`data0` 为该来源技能的等级数据表:
```python
@@ -62,15 +118,16 @@ def effect(self, old, new):
## 职业规则
静态 `associate` 格式不变。现有动态 `effect(old, new)` 作为兼容入口,在收集模式下以 `(0, 最终等级)` 执行;公共 `apply_association` / `update_skill_attribute` 记录贡献,不修改目标。每次收集都从原始 `associate` 声明开始,取消 VP 时不会保留上一次分支。
`associate` 可以混用新声明和旧字典。现有动态 `effect(old, new)` 作为兼容入口,在收集模式下以 `(0, 最终等级)` 执行;公共入口记录贡献,不修改目标。每次收集都从原始 `associate` 声明开始,取消 VP 时不会保留上一次分支。
动态规则只能生成关联声明,不能在收集过程中直接修改角色或目标技能。需要修改关联产生方式时,应改用声明,而不是另写参数 setter 撤销旧倍率。元素师原先的 `ratio` 补偿 setter 已移除,避免与公共引擎重复应用。
引擎在创建时保存初始声明和状态默认值。动态分支应新建并赋值 `self.associate`,不要对共享的类级列表执行 `append` 或原地改写;也不要把运行后修改 `associate` 当作注册新规则。一次重算可能多次调用 `effect`,因此不能在其中累计计数、直接调用角色加成方法或依赖随机结果。
依赖图目前自动识别三类依赖:共享等级源、收集时跨技能 `.lv` 读取、指向状态属性的关联。
直接读取另一个技能的 `.ratio` 等非等级参数不会自动建立依赖。
新增这类规则时需要先扩展公共依赖机制并验证顺序;单独加入 `INPUT_ATTRIBUTES` 只会触发重算,不能解决求值先后问题。
类型化表达式的 Read 额外提供跨技能状态参数依赖;普通 Python 代码直接读取其他技能的 `.ratio` 仍不会自动建立依赖。
新规则请使用 Read;单独加入 INPUT_ATTRIBUTES 只会触发重算,不能解决求值先后问题。
本阶段仍使用技能级拓扑图,因此同一技能的状态回写、相互引用等可能被保守判定为循环;尚未升级为属性级依赖图。
初次建图后新出现的等级读取或状态目标会报错;不要靠最终状态临时增加依赖边。
每条贡献包含来源技能、条目索引、目标、属性、运算及数值,可从 `character.skill_effects.contributions` 检查。角色属性支持原 `SetStatus` 中相应的标量加算/乘算字段。
@@ -111,6 +168,7 @@ with character.skill_effects.batch():
## 诊断和验证
找不到目标的旧关联仍按原逻辑跳过,同时记录在 `unresolved_targets`,供职业规则审查。例如刺客现存的“终结追击”名称失配;本次未推测其数值意图并修改规则。
类型化声明的缺失目标/引用、错误属性/运算、非有限数值会报错,避免新规则继续静默失效。
测试覆盖女弹药 2.32 场景及后续升降级、旅人条件、大枪加级、VP 切换、装备基线、角色属性、共享等级及链式共享、零覆盖、赋值冲突和循环依赖。全职业测试使用全部学习等级,在无 VP/VP1/VP2 下交换输入顺序,并另测搭配职业武器的情况。
@@ -122,6 +180,7 @@ with character.skill_effects.batch():
```sh
backend/.venv/bin/pytest backend/tests/unit/test_skill_effects.py -q
backend/.venv/bin/pytest backend/tests/unit/test_typed_skill_effects.py -q
backend/.venv/bin/pytest backend/tests/smoke/test_calc_availability.py -k configuration_order -q
```
@@ -133,29 +192,36 @@ from dataclasses import dataclass
from functools import wraps
from graphlib import CycleError, TopologicalSorter
from math import prod
from .skill_effect_types import (
CharacterAttribute, CharacterEffect, EffectDeclaration, MissingTargetPolicy, Operation,
SkillEffect, STATE_ATTRIBUTES,
)
# 会被关联修改、且参与后续关联求值的属性在此登记,并在技能类上提供默认值。
STATE_ATTRIBUTES = frozenset({'lv', 'ratio', 'maxLayer'})
# 状态属性定义在 skill_effect_types;技能类仍须提供相应默认值。
# 外部赋值触发重算的输入;登记在此不等于自动跟踪跨技能的该属性读取。
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',
})
CHARACTER_ATTRIBUTES = frozenset(attribute.value for attribute in CharacterAttribute)
_MISSING = object()
@dataclass(frozen=True)
class SkillContribution:
"""统一后的来源贡献;新声明和旧字典最终都编译为此结构。"""
source: object
"""提供本条效果的来源技能实例。"""
index: int
"""同一来源收集到的贡献序号,用于稳定汇总顺序。"""
target: object
"""接受贡献的技能或角色实例。"""
attribute: str
"""目标实例的底层属性名称。"""
operation: str
"""汇总运算:'+' 加算、'*' 乘算、'=' 覆盖。"""
value: float
"""已经完成等级取值、百分比转换和条件求值的实际贡献值。"""
def skill_effect_batch(method):
@@ -186,6 +252,7 @@ class SkillEffectEngine:
self._depth = 0
self.resolving = False
self.collecting = None
self._discovering = False
self._pending = []
self._reads = set()
self._dirty = True
@@ -241,21 +308,87 @@ class SkillEffectEngine:
def missing_target(self, source, name):
self.unresolved_targets.add((source.name, name))
def _collect(self, skill):
def collect_effect(self, source, declaration: EffectDeclaration):
"""Compile typed declarations into the same contributions as legacy rules.
Discovery reads structure only: no arithmetic or conditional branch is
evaluated before upstream state is ready. Inactive branches still expose
dependencies and possible state targets.
"""
if self.collecting is not source:
raise RuntimeError('类型化效果只能由技能关联引擎收集')
for dependency in declaration.dependencies():
target = dependency.target(source)
if target is not source and dependency.attribute.value in STATE_ATTRIBUTES:
self._reads.add(target)
attribute = declaration.attribute.value
if isinstance(declaration, CharacterEffect):
targets = [self.character]
else:
targets = self.character.GetSkills('all', True) if declaration.skills == '*' else []
if declaration.skills != '*':
for name in declaration.skills:
if name in declaration.except_skills:
continue
target = self.character.GetSkillByName(name)
if target is None:
if declaration.missing_target == MissingTargetPolicy.ERROR:
raise ValueError(f'类型化效果目标不存在: {source.name} -> {name}')
self.missing_target(source, name)
else:
targets.append(target)
targets = [target for target in targets if target.name not in declaration.except_skills]
for target in targets:
if attribute in STATE_ATTRIBUTES - {'lv'} and (target, attribute) not in self._state_base:
raise ValueError(f'类型化效果状态缺少默认值: {source.name} -> {target.name}.{attribute}')
if self._discovering:
# Retain legacy precondition level reads, but never prune graph edges
# based on its provisional result. New conditions should use `when`.
source.precondition()
value = 0
else:
if not source.precondition():
return
if declaration.weapon is not None and self.character.GetWeaponType()[0] not in declaration.weapon:
return
if declaration.when is not None and not declaration.when.evaluate(source):
return
value = declaration.evaluate(source)
for target in targets:
if target is not self.character and attribute == 'lv':
if target.level_source is not None:
continue
if not self._discovering and declaration.operation == Operation.ADD and not self.is_learned(target):
continue
self.record(source, target, attribute, declaration.operation.value, value)
def _collect(self, skill, *, discover=False):
# 一个重算周期会多次调用 effect;职业实现只能构造声明并调用公共收集入口。
self._pending = []
self._reads = set()
if not skill.association_source or skill.lv <= 0:
if not skill.association_source:
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
self._discovering = discover
try:
skill.effect(0, skill.lv)
if skill.lv > 0:
skill.effect(0, skill.lv)
elif discover:
# A typed level assignment can activate an initially unlearned
# source; its static dependencies must already be in the graph.
for declaration in skill.associate:
if isinstance(declaration, (SkillEffect, CharacterEffect)):
self.collect_effect(skill, declaration)
return tuple(self._pending), self._reads.copy()
finally:
self.collecting = None
self._discovering = False
@staticmethod
def _combine(base, contributions):
@@ -280,7 +413,7 @@ class SkillEffectEngine:
def resolve_states(self):
# 依赖来源:level_source、跨技能 lv 读取、指向 STATE_ATTRIBUTES 的关联。
# 其他跨技能属性读取不会自动建边;新增此类规则须先扩展依赖机制。
# 类型化 Read 可声明跨技能状态参数依赖;普通 Python 属性读取仍不会自动建边。
if self.resolving or not self._state_dirty:
return
self.resolving = True
@@ -299,7 +432,7 @@ class SkillEffectEngine:
if source is None:
raise ValueError(f'共享等级源技能不存在: {skill.name} -> {skill.level_source}')
graph[skill].add(source)
contributions, reads = self._collect(skill)
contributions, reads = self._collect(skill, discover=True)
graph[skill].update(reads)
for item in contributions:
if item.target is not self.character and item.attribute in STATE_ATTRIBUTES: