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

This commit was merged in pull request #11.
This commit is contained in:
2026-10-13 18:19:20 +08:00
parent 927866d366
commit 66ff3ff63b
175 changed files with 14299 additions and 440 deletions
+24
View File
@@ -1,6 +1,7 @@
from collections.abc import Mapping
from typing import Any, Literal
from core.character.refs.base import SkillRef, resolve_skill
from core.abstract.character import CharacterProperty
from core.basic.skill import Skill
from core.basic.skill_effects import SkillEffectEngine, skill_effect_batch
@@ -35,6 +36,12 @@ class CharacterBase(CharacterProperty):
BindAwake: int
"""绑定觉醒等级"""
skills: list['Skill']
skills_ref_dict: dict[SkillRef, 'Skill']
"""当前角色的技能引用索引;不会共享其他角色的技能实例。"""
skills_code_dict: dict[str, 'Skill']
"""当前角色的原始 code 索引。"""
skill_ref_profession: str | None
"""技能数据来源职业范围,职业变体可复用来源职业。"""
skills_dict: dict[str, 'Skill']
skills_id_dict: dict[str, 'Skill']
skills_uuid_dict: dict[str, 'Skill']
@@ -172,6 +179,9 @@ class CharacterBase(CharacterProperty):
self.equ_options = {}
self.max_point = 0
self.skills = []
self.skills_ref_dict = {}
self.skills_code_dict = {}
self.skill_ref_profession = None
self.skills_dict = {}
self.skills_id_dict = {}
self.skills_uuid_dict = {}
@@ -368,6 +378,20 @@ class CharacterBase(CharacterProperty):
# region 技能查询
def GetSkill(self, reference: SkillRef | str) -> 'Skill | None':
"""按类型化引用查询当前角色技能,兼容旧显示名称字符串。"""
return resolve_skill(self, reference)
def GetSkillByRef(self, reference: SkillRef) -> 'Skill | None':
"""按技能身份查询;校验职业范围,缺失返回 None。"""
if not isinstance(reference, SkillRef):
raise TypeError('GetSkillByRef 必须传入 SkillRef')
return resolve_skill(self, reference)
def GetSkillByCode(self, code: str) -> 'Skill | None':
"""在当前角色中按原始 code 查询,不与显示名称隐式混用。"""
return self.skills_code_dict.get(code)
def GetSkillByName(self, name) -> 'Skill':
"""通过技能名获取技能"""
return self.skills_dict.get(name, None)
@@ -2,6 +2,7 @@ import sys
from functools import cache
from uuid import uuid1
from core.character.refs.base import bind_skill_refs
from core.basic.equipment import OathSuit, Suit, get_equipment
from ..formula import 增幅计算, 强化技攻, 武器强化计算, 精通计算, 耳环计算, 左右计算, 锻造计算
from ..roleinfo import CharacterEmblemInfo, CharacterEquipInfo, CharacterOathInfo, get_key_by_value
@@ -85,6 +86,9 @@ class CalcEquipMixin(CharacterBase):
self.skills_id_dict[str(skill.id)] = skill
self.skills_uuid_dict[str(skill.uuid)] = skill
self.skills.append(skill)
# 职业变体通过数据 prefix 复用同一身份范围,不依赖显示名称或模块别名。
prefix = getattr(sys.modules[self.moduleName], 'prefix', '')
bind_skill_refs(self, '.'.join(prefix.split('/')[:2]))
# endregion
+12 -3
View File
@@ -4,6 +4,7 @@ from functools import cache
import json
import re
from typing import Any, Literal, TYPE_CHECKING
from core.character.refs.base import SkillRef, resolve_skill
from .formula import 武器冷却惩罚
from database.connect import get_redis_client
from api.core.Redis import get_redis_info
@@ -97,7 +98,15 @@ class Skill:
name: str = ''
"""技能名称"""
_lv: int = 0
level_source: str | None = None
ref: SkillRef | None = None
"""技能身份引用;职业类绑定 Ref 成员,code 由此派生。"""
@property
def code(self) -> str | None:
"""技能树原始 code;未迁移的技能返回 None,不单独维护副本。"""
return self.ref.code if self.ref is not None else None
level_source: SkillRef | str | None = None
"""共享等级的源技能;目标不独立加级,也不使用自身 maxLv 截断。"""
association_source: bool | None = None
"""共享等级技能默认仅复用等级;需要独立提供关联效果时显式开启。"""
@@ -147,7 +156,7 @@ class Skill:
associate: list[dict[str, Any] | EffectDeclaration] = []
"""关联声明;维护示例和扩展边界见 core/basic/skill_effects.py 文件顶部说明。
新规则可用 SkillEffect/CharacterEffect,与旧字典混用;目标仍使用技能名称数组。
新规则使用 SkillEffect/CharacterEffect 和 Ref 目标数组,兼容旧字典及名称字符串。
新声明 value 是实际倍率/增量/覆盖值,以下 type、data、ratio 约定仅适用于旧字典。
type 默认 '*skillDamage':*乘算、+加算、=覆盖;$*PAtkP 等作用于角色。
skills 默认 '*',表示所有伤害技能;显式列表使用技能名称,可指定被动技能。
@@ -223,7 +232,7 @@ class Skill:
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 resolve_skill(self.char, self.level_source).lv
return self._lv
@lv.setter
+26 -16
View File
@@ -1,10 +1,11 @@
"""有类型的技能关联声明;完整维护说明见 skill_effects.py 文件顶部。
表达式不可变、无副作用,并在求值前声明依赖。关联目标仍使用技能名称数组。
表达式不可变、无副作用,并在求值前声明依赖。关联目标使用 Ref 数组,兼容旧技能名称。
公开类型、枚举成员和字段的中文文档用于编辑器补全与悬停提示。
"""
from __future__ import annotations
from core.character.refs.base import SkillRef, resolve_skill
from collections.abc import Sequence
from dataclasses import dataclass, field
from enum import Enum
@@ -214,22 +215,22 @@ class Read(Expression):
Args:
attribute: 要读取的等级、ratio、最大层数、VP 或 UP。
skill: 被读取技能的名称,省略时读取自身。
skill: 被读取技能的 Ref,兼容旧名称字符串;省略时读取自身。
"""
attribute: SkillAttribute
"""要读取的等级、ratio、最大层数、VP 或 UP。"""
skill: str | None = None
"""源技能名称;省略时读取声明所属技能自身。"""
skill: SkillRef | str | None = None
"""源技能 Ref 或旧名称;省略时读取声明所属技能自身。"""
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 的技能引用必须是非空技能名称,省略时读取自身')
if self.skill is not None and not isinstance(self.skill, SkillRef) and (not isinstance(self.skill, str) or not self.skill):
raise TypeError('Read 的技能引用必须是 SkillRef 或非空技能名称,省略时读取自身')
def target(self, source: Skill) -> Skill:
"""按名称解析被读取的技能,并检查目标属性是否存在。"""
target = source if self.skill is None else source.char.GetSkillByName(self.skill)
target = source if self.skill is None else resolve_skill(source.char, self.skill)
if target is None:
raise ValueError(f'效果表达式引用技能不存在: {source.name} -> {self.skill}')
if not hasattr(target, self.attribute.value):
@@ -445,6 +446,15 @@ def _names(values: Sequence[str], label: str) -> tuple[str, ...]:
return tuple(values)
def _skill_targets(values: Sequence[SkillRef | str], label: str) -> tuple[SkillRef | str, ...]:
"""复制引用数组,兼容非空的旧技能名称;拒绝单个引用或字符串冒充数组。"""
if isinstance(values, (str, SkillRef)) or not isinstance(values, Sequence):
raise TypeError(f'{label} 必须是技能引用数组')
if not all(isinstance(value, SkillRef) or isinstance(value, str) and value for value in values):
raise TypeError(f'{label} 必须由 SkillRef 或非空技能名称组成')
return tuple(values)
@dataclass(frozen=True, kw_only=True)
class _Effect:
"""效果声明公共字段;职业代码使用 SkillEffect 或 CharacterEffect。"""
@@ -485,24 +495,24 @@ class MissingTargetPolicy(str, Enum):
@dataclass(frozen=True, kw_only=True)
class SkillEffect(_Effect):
"""作用于技能的效果声明;skills 继续接收技能名称数组,默认所有伤害技能。
"""作用于技能的效果声明;skills 接收 Ref 数组并兼容旧名称,默认所有伤害技能。
Args:
value: 实际贡献值或表达式;乘算使用倍率,加算使用增量。
operation: 汇总运算,默认乘算。
attribute: 目标属性,默认技能技攻;也支持编号分段和职业专用输出属性。
skills: 目标技能名称数组,默认 '*' 表示所有伤害技能。
except_skills: 排除的技能名称数组。
skills: 目标技能 Ref 或旧名称数组,默认 '*' 表示所有伤害技能。
except_skills: 排除的技能 Ref 或旧名称数组。
when: 生效条件,默认无额外条件。
weapon: 允许的武器类型数组,默认不限制。
missing_target: 缺失目标时默认报错;迁移旧名称失配时可显式跳过并记录。
"""
attribute: SkillAttribute | ProfessionAttribute | ComponentAttribute = SkillAttribute.DAMAGE
"""目标属性,默认技能技攻;分段属性使用 Power/Hit/ExtraData/ExtraPower,职业属性使用 ProfessionAttribute 子类。"""
skills: Sequence[str] | Literal['*'] = '*'
"""目标技能名称数组;'*' 仅选择伤害技能,指定名称时可关联被动技能。"""
except_skills: Sequence[str] = ()
"""从目标中排除的技能名称数组。"""
skills: Sequence[SkillRef | str] | Literal['*'] = '*'
"""目标技能 Ref 或旧名称数组;'*' 仅选择伤害技能,指定名称时可关联被动技能。"""
except_skills: Sequence[SkillRef | str] = ()
"""从目标中排除的技能 Ref 或旧名称数组。"""
missing_target: MissingTargetPolicy = MissingTargetPolicy.ERROR
"""缺失目标处理策略;SKIP 仅用于保留已确认的旧名称失配行为。"""
@@ -519,8 +529,8 @@ class SkillEffect(_Effect):
):
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'))
object.__setattr__(self, 'skills', _skill_targets(self.skills, 'skills'))
object.__setattr__(self, 'except_skills', _skill_targets(self.except_skills, 'except_skills'))
if not isinstance(self.missing_target, MissingTargetPolicy):
raise TypeError('缺失目标策略必须使用 MissingTargetPolicy')
+17 -4
View File
@@ -3,6 +3,16 @@
技能关联由 `core/basic/skill_effects.py` 的 `SkillEffectEngine` 统一处理。
`lv`、VP 等输入变化不再通过历史倍率乘除更新目标,而是重新解析最终状态并汇总本次关联贡献。
## 技能身份与引用维护
职业模块导入自己的 Ref;技能类声明 `ref = Ref.XXX`,`code` 从 ref 派生,name 只负责显示。
角色初始化根据技能数据 prefix 校验职业范围,并创建独立的 ref/code 索引;输出圣骑士等职业变体复用数据来源范围。
SkillEffect.skills、except_skills、Read.skill、level_source 统一接受 Ref,旧名称字符串继续兼容。
公共技能引用只匹配当前角色实际绑定的技能实例。GetSkill(Ref.XXX) 查询引用,GetSkillByCode 查询原始 code;
字符串不会隐式当作 code。重复绑定、跨职业引用直接报错;新增规则应使用 Ref,旧失配名称继续保留 SKIP 诊断。
引用生成、公共分组、编辑器中文提示的维护说明见 core/character/refs/base.py。
SkillRef.name 只影响提示,不参与相等性和哈希;技能名称变更不会改变引用身份。
## 计算阶段
1. `SetSkills` 批量读取基础等级、VP、UP。传入的等级是基础等级,不含关联技能提供的等级;重复传入同一配置不会重复加级。
@@ -186,6 +196,7 @@ backend/.venv/bin/pytest backend/tests/smoke/test_calc_availability.py -k config
第二项验证技能配置顺序(包括搭配武器的场景),不验证装备执行顺序。修改公共引擎后再运行完整 `backend/tests` 回归。
"""
from core.character.refs.base import resolve_skill
from collections import defaultdict
from contextlib import contextmanager
from dataclasses import dataclass
@@ -331,14 +342,16 @@ class SkillEffectEngine:
for name in declaration.skills:
if name in declaration.except_skills:
continue
target = self.character.GetSkillByName(name)
target = resolve_skill(self.character, 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]
excluded = {id(target) for ref in declaration.except_skills
if (target := resolve_skill(self.character, ref)) is not None}
targets = [target for target in targets if id(target) not in excluded]
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}')
@@ -428,7 +441,7 @@ class SkillEffectEngine:
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)
source = resolve_skill(self.character, skill.level_source)
if source is None:
raise ValueError(f'共享等级源技能不存在: {skill.name} -> {skill.level_source}')
graph[skill].add(source)
@@ -447,7 +460,7 @@ class SkillEffectEngine:
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
skill._lv = resolve_skill(self.character, skill.level_source).lv
contributions, reads = self._collect(skill)
if not reads.issubset(graph[skill]):
raise ValueError(f'技能关联状态依赖随计算改变: {skill.name}')