feat:优化职业技能关联实现
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user