update
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"""技能基础包。
|
||||
|
||||
- base.py:技能基类、主动/被动与辅助技能,以及技能数据读取。
|
||||
- effects.py:类型化效果收集、依赖解析和批量重算。
|
||||
- effect_types.py:效果声明、表达式、条件及其属性/运算枚举。
|
||||
- enums.py:技能伤害类型枚举。
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
ActiveBufferSkill,
|
||||
ActiveSkill,
|
||||
BuffSkill,
|
||||
PassiveBufferSkill,
|
||||
PassiveSkill,
|
||||
Skill,
|
||||
get_data,
|
||||
characterLv,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Skill', 'ActiveSkill', 'PassiveSkill', 'BuffSkill',
|
||||
'ActiveBufferSkill', 'PassiveBufferSkill', 'get_data', 'characterLv',
|
||||
]
|
||||
@@ -0,0 +1,466 @@
|
||||
from copy import copy
|
||||
from collections.abc import Callable
|
||||
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
|
||||
from api.utils.openapi_data import logical_skill_detail_path
|
||||
from .effects import INPUT_ATTRIBUTES
|
||||
from .effect_types import EffectDeclaration
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.basic.character import Character
|
||||
|
||||
characterLv = 115 + 5
|
||||
|
||||
|
||||
def _identity(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
@cache
|
||||
def _load_skill_detail(prefix: str) -> dict[str, Any]:
|
||||
with logical_skill_detail_path(prefix).open(encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
@cache
|
||||
def _get_skill_data_suffixes(skill_class: type) -> tuple[str, ...]:
|
||||
return tuple(key.replace('data', '') for key in dir(skill_class) if key.startswith('data') and not key.startswith('plus'))
|
||||
|
||||
|
||||
@cache
|
||||
def _get_skill_damage_suffixes(skill_class: type) -> tuple[str, ...]:
|
||||
return tuple(key.replace('data', '') for key in dir(skill_class) if key.startswith('data') and not key.startswith('dataplus') and not key.startswith('dataType'))
|
||||
|
||||
|
||||
@cache
|
||||
def _get_skill_dataplus_suffixes(skill_class: type) -> tuple[str, ...]:
|
||||
return tuple(key.replace('dataplus', '') for key in dir(skill_class) if key.startswith('dataplus'))
|
||||
|
||||
|
||||
@cache
|
||||
def get_data(prefix: str, key: int | str, func: Callable[[Any], Any] = _identity):
|
||||
def get_skill_info():
|
||||
skill_data = _load_skill_detail(prefix)
|
||||
try:
|
||||
if isinstance(key, int):
|
||||
data = [0] + [row["optionValue"].get("value" + str(key+1), 0) for row in skill_data["levelInfo"]["rows"]]
|
||||
data = [0 if x is None or x == "-" else x for x in data]
|
||||
elif key == "vps":
|
||||
data = [ {"name":x["name"],"desc":x["desc"]} for x in skill_data.get("evolution",[])]
|
||||
elif key == "cdList":
|
||||
data = [0] + [row.get("coolTime", 0) for row in skill_data["levelInfo"]["rows"]]
|
||||
except Exception:
|
||||
# print(f"get skill {prefix}/{key} data error")
|
||||
data = None
|
||||
if isinstance(key, int) and not data:
|
||||
return [0]
|
||||
if key == 'vps' and data is None:
|
||||
return []
|
||||
return data
|
||||
|
||||
with get_redis_client() as redis:
|
||||
data = get_redis_info(redis, f'dcalc:skill:{prefix}:{key}', get_skill_info, without_gzip=True)
|
||||
return func(data)
|
||||
|
||||
|
||||
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 = ''
|
||||
"""技能名称"""
|
||||
_lv: int = 0
|
||||
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
|
||||
"""共享等级技能默认仅复用等级;需要独立提供关联效果时显式开启。"""
|
||||
id: int = 0
|
||||
"""技能ID"""
|
||||
icon: str = '$char/$uuid'
|
||||
"""技能图标,默认当前职业下该技能名称对应的图标,支持以下模板字符串
|
||||
$common 通用图标路径
|
||||
$char 当前职业图标路径
|
||||
$name 技能名称
|
||||
$id 技能ID
|
||||
"""
|
||||
type: Literal['active', 'passive'] = 'active'
|
||||
"""技能类型 active主动 passive被动"""
|
||||
learnLv: int = 0
|
||||
"""学习等级"""
|
||||
masterLv: int = 0
|
||||
"""精通等级"""
|
||||
maxLv: int = 0
|
||||
"""最大等级"""
|
||||
line: int = None
|
||||
"""技能行"""
|
||||
position: int = 0
|
||||
"""技能树位置"""
|
||||
rangeLv: int = 1
|
||||
"""学习间隔"""
|
||||
cube: int = 0
|
||||
"""无色消耗"""
|
||||
skillRation: int = 1
|
||||
"""技能面板倍率 [改变技能面板]"""
|
||||
skillDamage: int = 1
|
||||
"""技能技攻 [不改变技能面板]"""
|
||||
char: 'Character' = None
|
||||
"""技能所属角色"""
|
||||
damage: bool = False
|
||||
"""是否是伤害技能"""
|
||||
cd: float = 0
|
||||
"""技能冷却时间"""
|
||||
cdCut: float = 0
|
||||
"""技能冷却时间直接减少"""
|
||||
cdReduce: float = 1
|
||||
"""技能冷却缩减"""
|
||||
cdRatio: float = 1
|
||||
"""技能冷却倍率,直接体现在面板上,不被上限影响"""
|
||||
cdRecover: float = 1
|
||||
"""技能冷却恢复"""
|
||||
associate: list[EffectDeclaration] = []
|
||||
"""类型化关联声明;仅接受 SkillEffect/CharacterEffect。
|
||||
|
||||
value 是实际倍率、增量或覆盖值,目标优先使用 Ref 数组。
|
||||
动态 effect 只构造声明并调用 super().effect,由引擎统一收集和应用。
|
||||
维护示例见 core/basic/skill/effects.py 文件顶部说明。
|
||||
"""
|
||||
mode: list[str] = ['']
|
||||
currentMode: str = ''
|
||||
"""技能模式"""
|
||||
buffer: bool = False
|
||||
"""是否是增益技能"""
|
||||
bind: bool = None
|
||||
uuid: str = None
|
||||
hasVP: bool = None
|
||||
"""是否有VP形态"""
|
||||
hasUP: bool = None
|
||||
"""是否有技能强化"""
|
||||
upType: Literal['damage', 'buff', 'heal'] = 'damage'
|
||||
vps: list = []
|
||||
|
||||
def __init__(self, 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.icon = self._generate_icon()
|
||||
if self.line is None:
|
||||
self.line = self.learnLv
|
||||
if self.bind is None:
|
||||
self.bind = False
|
||||
if self.learnLv in [50, 85, 100]:
|
||||
self.bind = True
|
||||
if self.hasVP is None:
|
||||
if self.learnLv not in [50, 85, 100] and self.learnLv >= 35 and self.learnLv <= 80 and self.damage:
|
||||
self.hasVP = True
|
||||
else:
|
||||
self.hasVP = False
|
||||
if self.hasUP is None:
|
||||
if self.learnLv not in [50, 85, 100] and self.learnLv >= 15 and self.learnLv <= 80 and self.damage:
|
||||
self.hasUP = True
|
||||
else:
|
||||
self.hasUP = False
|
||||
# self.lv = self._calculate_lv()
|
||||
pass
|
||||
|
||||
def _extract_id(self):
|
||||
className = self.__class__.__name__
|
||||
return int(re.search(r'\d+', className).group()) if re.search(r'\d+', className) else 0
|
||||
|
||||
def _generate_icon(self):
|
||||
icon = self.icon or '$char/$uuid'
|
||||
icon = icon.replace('$char', f'/characters/{self.char.role}/{self.char.name}/skillCN')
|
||||
icon = icon.replace('$common', '/characters/common')
|
||||
icon = icon.replace('$name', f'{self.name}')
|
||||
icon = icon.replace('$uuid', f'{self.uuid}')
|
||||
icon = icon.replace('$id', f'{self.id}')
|
||||
return f'{icon}.png'
|
||||
|
||||
def calculate_lv(self):
|
||||
return min(int((characterLv - (15 if self.learnLv is None else self.learnLv)) / max(1,(1 if self.rangeLv is None else self.rangeLv)) + 1), self.masterLv) if self._lv == 0 else self._lv
|
||||
|
||||
@property
|
||||
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 resolve_skill(self.char, self.level_source).lv
|
||||
return self._lv
|
||||
|
||||
@lv.setter
|
||||
def lv(self, 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:
|
||||
self.effect(self._lv, value)
|
||||
self._lv = value
|
||||
|
||||
def effect(self, old: int, new: int):
|
||||
"""收集类型化声明;动态目标规则可重写此入口。"""
|
||||
engine = getattr(self.char, 'skill_effects', None)
|
||||
for declaration in self.associate:
|
||||
if engine is None:
|
||||
raise RuntimeError('类型化效果需要 SkillEffectEngine')
|
||||
engine.collect_effect(self, declaration)
|
||||
|
||||
def precondition(self):
|
||||
"""效果的前置条件。"""
|
||||
return True
|
||||
|
||||
def skillInfo(self, mode: str | None = None):
|
||||
pass
|
||||
|
||||
|
||||
class ActiveSkill(Skill):
|
||||
type: str = 'active'
|
||||
"""技能类型 active主动 passive被动"""
|
||||
damage: bool = True
|
||||
"""是否是伤害技能"""
|
||||
buffer: bool = False
|
||||
"""是否是buff技能"""
|
||||
vp: int = 0
|
||||
"""技能VP形态"""
|
||||
up: int = 0
|
||||
"""技能强化"""
|
||||
|
||||
def __init__(self, char):
|
||||
super().__init__(char)
|
||||
for i in _get_skill_data_suffixes(self.__class__):
|
||||
power = getattr(self, f'power{i}', None)
|
||||
if power is None:
|
||||
setattr(self, f'power{i}', 1)
|
||||
hit = getattr(self, f'hit{i}', None)
|
||||
if hit is None:
|
||||
setattr(self, f'hit{i}', 0)
|
||||
|
||||
def skillInfo(self, mode: str | None = None):
|
||||
basic = copy(self)
|
||||
for key, value in self.__dict__.items():
|
||||
if isinstance(value, list):
|
||||
setattr(basic, key, value.copy())
|
||||
elif isinstance(value, dict):
|
||||
setattr(basic, key, value.copy())
|
||||
basic.setUP()
|
||||
if mode is not None:
|
||||
basic.currentMode = mode
|
||||
basic.setMode(mode)
|
||||
basic.setVP()
|
||||
date = basic.getSkillData(self.lv)
|
||||
cd = basic.getSkillCD(mode)
|
||||
return date * basic.skillRation, basic.skillDamage, cd
|
||||
|
||||
def setMode(self, mode: str):
|
||||
pass
|
||||
|
||||
def setVP(self):
|
||||
if self.vp == 1:
|
||||
self.vp_1()
|
||||
elif self.vp == 2:
|
||||
self.vp_2()
|
||||
|
||||
def setUP(self):
|
||||
if not self.hasUP:
|
||||
return
|
||||
if self.up == 1:
|
||||
if self.learnLv < 35:
|
||||
self.skillRation *= 1.6
|
||||
else:
|
||||
self.skillRation *= 1.55
|
||||
elif self.up == 2:
|
||||
self.cdReduce *= 1 - 0.15
|
||||
if self.learnLv < 35:
|
||||
self.skillRation *= 1.43
|
||||
else:
|
||||
self.skillRation *= 1.38
|
||||
|
||||
def getSkillData(self, lv: int = 0):
|
||||
res = 0
|
||||
for i in _get_skill_damage_suffixes(self.__class__):
|
||||
data = getattr(self, f'data{i}', [])
|
||||
plus = getattr(self, f'plus{i}', 0)
|
||||
hit = getattr(self, f'hit{i}', 0)
|
||||
if len(data) == 0 or hit == 0:
|
||||
continue
|
||||
if lv < len(data):
|
||||
hit = getattr(self, f'hit{i}', 0)
|
||||
power = getattr(self, f'power{i}', 1)
|
||||
res += hit * power * (data[lv] + plus)
|
||||
for i in _get_skill_dataplus_suffixes(self.__class__):
|
||||
data = getattr(self, f'dataplus{i}', 0)
|
||||
hit = getattr(self, f'hitplus{i}', 1)
|
||||
power = getattr(self, f'powerplus{i}', 1)
|
||||
res += hit * power * data
|
||||
return res
|
||||
|
||||
def getWeaponCDRatio(self):
|
||||
weapon = self.char.charEquipInfo['武器'].equInfo
|
||||
if weapon is None or '传世武器' in weapon.categorize:
|
||||
return 1
|
||||
else:
|
||||
return 武器冷却惩罚(weapon.itemDetailType, self.char.输出类型)
|
||||
|
||||
|
||||
def getSkillCD(self, mode=None):
|
||||
if not isinstance(self.cd, (int, float)) or not self.cd:
|
||||
return '-'
|
||||
return max(0, round(max(self.cd * 0.3, self.cd * self.cdReduce / self.cdRecover * self.getWeaponCDRatio()) * self.cdRatio - self.cdCut / self.cdRecover , 2))
|
||||
|
||||
def vp_1(self):
|
||||
pass
|
||||
|
||||
def vp_2(self):
|
||||
pass
|
||||
|
||||
|
||||
class PassiveSkill(Skill):
|
||||
type: str = 'passive'
|
||||
"""技能类型 active主动 passive被动"""
|
||||
damage: bool = False
|
||||
"""是否是伤害技能"""
|
||||
buffer: bool = False
|
||||
"""是否是buff技能"""
|
||||
|
||||
|
||||
class BuffSkill(Skill):
|
||||
buffer: bool = True
|
||||
"""是否是增益技能"""
|
||||
damage: bool = False
|
||||
"""是否是伤害技能"""
|
||||
buffType = 'normal'
|
||||
"""增益类型 normal普通 buff 主BUFF awake 觉醒技能"""
|
||||
|
||||
INT: list[float] = []
|
||||
"""智力"""
|
||||
STR: list[float] = []
|
||||
"""力量"""
|
||||
Spirit: list[float] = []
|
||||
"""精神"""
|
||||
Vitality: list[float] = []
|
||||
"""体力"""
|
||||
"""主要属性"""
|
||||
|
||||
ATK: list[float] = []
|
||||
"""三攻加成"""
|
||||
ATKRatio: float = 1
|
||||
"""三攻*算倍率"""
|
||||
ATKPLUS: float = 0
|
||||
"""三攻加算加成"""
|
||||
|
||||
STRINT: list[float] = []
|
||||
"""力量智力加成"""
|
||||
STRINTRatio: float = 1
|
||||
"""力量智力*算倍率"""
|
||||
STRINTPLUS: float = 0
|
||||
"""力量智力加算加成"""
|
||||
|
||||
CarryRatio: list[float] = []
|
||||
"""对C的额外增伤倍率"""
|
||||
vp: int = 0
|
||||
"""技能VP形态"""
|
||||
up: int = 0
|
||||
"""技能强化"""
|
||||
|
||||
def __init__(self, char):
|
||||
super().__init__(char)
|
||||
for i in _get_skill_data_suffixes(self.__class__):
|
||||
power = getattr(self, f'power{i}', None)
|
||||
if power is None:
|
||||
setattr(self, f'power{i}', 1)
|
||||
hit = getattr(self, f'hit{i}', None)
|
||||
if hit is None:
|
||||
setattr(self, f'hit{i}', 0)
|
||||
|
||||
def getSkillCD(self, mode=None):
|
||||
return '-'
|
||||
|
||||
def skillInfo(self, mode: str | None = None):
|
||||
"""
|
||||
Returns:
|
||||
tuple: (奶自己的面板加成,[三攻,三攻倍率,三攻额外加成],[力智,力智倍率,力智额外加成],对C增伤倍率,cd)
|
||||
"""
|
||||
lv = self.lv
|
||||
value = 0
|
||||
value1 = 0 if lv >= len(self.ATK) else self.ATK[lv]
|
||||
value2 = 0 if lv >= len(self.STRINT) else self.STRINT[lv]
|
||||
value3 = 0 if lv >= len(self.CarryRatio) else self.CarryRatio[lv]
|
||||
self.setUP()
|
||||
self.setVP()
|
||||
if self.char.适用属性 == '智力':
|
||||
value = 0 if lv >= len(self.INT) else self.INT[lv]
|
||||
elif self.char.适用属性 == '力量':
|
||||
value = 0 if lv >= len(self.STR) else self.STR[lv]
|
||||
elif self.char.适用属性 == '体力':
|
||||
value = 0 if lv >= len(self.Vitality) else self.Vitality[lv]
|
||||
elif self.char.适用属性 == '精神':
|
||||
value = 0 if lv >= len(self.Spirit) else self.Spirit[lv]
|
||||
return value, [value1, self.ATKRatio, self.ATKPLUS], [value2, self.STRINTRatio, self.STRINTPLUS], value3, self.getSkillCD(mode)
|
||||
|
||||
def setVP(self):
|
||||
if self.vp == 1:
|
||||
self.vp_1()
|
||||
elif self.vp == 2:
|
||||
self.vp_2()
|
||||
|
||||
def setUP(self):
|
||||
if not self.hasUP:
|
||||
return
|
||||
self.char.SetStatus(四维=40)
|
||||
if self.up == 1:
|
||||
...
|
||||
elif self.up == 2:
|
||||
if self.upType == 'damage':
|
||||
self.cdReduce *= 1 - 0.15
|
||||
elif self.upType == 'heal':
|
||||
self.cdReduce *= 1 - 0.1
|
||||
|
||||
def vp_1(self):
|
||||
pass
|
||||
|
||||
def vp_2(self):
|
||||
pass
|
||||
|
||||
|
||||
class PassiveBufferSkill(BuffSkill):
|
||||
type: str = 'passive'
|
||||
|
||||
|
||||
class ActiveBufferSkill(BuffSkill):
|
||||
type: str = 'active'
|
||||
|
||||
def getWeaponCDRatio(self):
|
||||
weapon = self.char.charEquipInfo['武器'].equInfo
|
||||
if weapon is None or '传世武器' in weapon.categorize:
|
||||
return 1
|
||||
else:
|
||||
return 武器冷却惩罚(weapon.itemDetailType, self.char.输出类型)
|
||||
|
||||
def getSkillCD(self, mode=None):
|
||||
return max(0, round(max(self.cd * 0.3, self.cd * self.cdReduce / self.cdRecover * self.getWeaponCDRatio(), 1) - self.cdCut, 2))
|
||||
@@ -0,0 +1,559 @@
|
||||
"""有类型的技能关联声明;完整维护说明见 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
|
||||
from math import isfinite
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base 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: 被读取技能的 Ref,兼容旧名称字符串;省略时读取自身。
|
||||
"""
|
||||
attribute: SkillAttribute
|
||||
"""要读取的等级、ratio、最大层数、VP 或 UP。"""
|
||||
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, 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 resolve_skill(source.char, 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)
|
||||
|
||||
|
||||
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。"""
|
||||
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 接收 Ref 数组并兼容旧名称,默认所有伤害技能。
|
||||
|
||||
Args:
|
||||
value: 实际贡献值或表达式;乘算使用倍率,加算使用增量。
|
||||
operation: 汇总运算,默认乘算。
|
||||
attribute: 目标属性,默认技能技攻;也支持编号分段和职业专用输出属性。
|
||||
skills: 目标技能 Ref 或旧名称数组,默认 '*' 表示所有伤害技能。
|
||||
except_skills: 排除的技能 Ref 或旧名称数组。
|
||||
when: 生效条件,默认无额外条件。
|
||||
weapon: 允许的武器类型数组,默认不限制。
|
||||
missing_target: 缺失目标时默认报错;迁移旧名称失配时可显式跳过并记录。
|
||||
"""
|
||||
attribute: SkillAttribute | ProfessionAttribute | ComponentAttribute = SkillAttribute.DAMAGE
|
||||
"""目标属性,默认技能技攻;分段属性使用 Power/Hit/ExtraData/ExtraPower,职业属性使用 ProfessionAttribute 子类。"""
|
||||
skills: Sequence[SkillRef | str] | Literal['*'] = '*'
|
||||
"""目标技能 Ref 或旧名称数组;'*' 仅选择伤害技能,指定名称时可关联被动技能。"""
|
||||
except_skills: Sequence[SkillRef | str] = ()
|
||||
"""从目标中排除的技能 Ref 或旧名称数组。"""
|
||||
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', _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')
|
||||
|
||||
|
||||
@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 数组。"""
|
||||
@@ -0,0 +1,535 @@
|
||||
"""技能关联计算与维护说明
|
||||
|
||||
技能关联由 `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。传入的等级是基础等级,不含关联技能提供的等级;重复传入同一配置不会重复加级。
|
||||
2. 解析等级和参数依赖。`lv`、`ratio`、`maxLayer` 是状态属性;关联到这些属性的规则以及 `level_source` 组成依赖图,按拓扑顺序求值。关联规则读取其他技能等级时,依赖会自动记录。循环、缺失共享等级源、计算过程中改变状态依赖会报出具体技能。
|
||||
3. 基于最终状态收集伤害、冷却和角色属性贡献,并在装备提供的基础值之上应用一次。
|
||||
|
||||
`calc` 在处理配置、装备、时装、徽章等阶段使用同一个批次。需要读取等级时,可以先解析状态;伤害及角色属性关联在准备阶段结束后统一生效。
|
||||
|
||||
## 后续在哪里维护
|
||||
|
||||
| 新增或修改的影响 | 维护位置 | 要点 |
|
||||
| --- | --- | --- |
|
||||
| 技伤、冷却、攻击段数等普通关联 | 对应 `core/character/GF/职业/角色.py` 的技能类 `associate` | 通常不改引擎;目标属性应有默认值 |
|
||||
| 随 VP、等级或自身参数变化的关联 | `associate` 中的 `Read`、`LevelTable`、`Select`、`when` | 动态目标使用 `effect`,数值和条件使用表达式 |
|
||||
| 技能给其他技能加级 | `SkillEffect(attribute=SkillAttribute.LEVEL, operation=Operation.ADD, ...)` | value 直接填整数增量;只给已学习的目标加级 |
|
||||
| 生命之树这类共享等级 | 目标技能的 `level_source` | 指向源技能名称,不再写等级 getter/no-op setter |
|
||||
| 新增会被关联修改、并影响后续关联的参数 | 技能类默认值 + `effect_types.py` 的 `SkillAttribute`、`STATE_ATTRIBUTES` | 新参数同时检查读写范围;状态属性自动属于输入属性 |
|
||||
| 新增外部配置输入,改变它需要重新计算关联 | 技能类、配置赋值入口 + `INPUT_ATTRIBUTES` | 例如现有 `vp`;单纯登记不提供跨技能参数读取依赖 |
|
||||
| 新增职业专用输出属性 | 职业文件内继承 `ProfessionAttribute` 定义枚举,并在目标技能设置数值默认值 | 成员紧跟中文文档字符串,IDE 可显示含义;不可通过 `Read` 读取 |
|
||||
| 新增角色属性关联 | `effect_types.py` 的 `CharacterAttribute`,并核对 `character/base.py` 的 `SetStatus` | 当前仅支持对应标量字段;特殊运算需单独实现 |
|
||||
| 新增运算符、目标选择方式、跨技能非等级依赖 | `base.py` 的公共关联入口 + `effects.py` | 属于公共能力扩展,需配套单元测试 |
|
||||
| 装备改变技能等级或倍率 | 原装备规则及 `character/base.py` 的公共修改方法 | 使用已有批处理方法;低层直接写入须在 `batch()` 内 |
|
||||
| 仅影响单次技能展示/输出的模式、VP、UP 变换 | 原技能 `setMode`、`vp_N`、输出计算方法 | `skillInfo()` 使用临时副本,不要误改为持久关联 |
|
||||
|
||||
普通职业规则优先放在职业文件。只有新增公共能力时才修改引擎;不要再用属性 setter 手动撤销旧倍率并乘入新倍率。
|
||||
|
||||
## 声明示例和数值约定
|
||||
|
||||
关联统一使用 `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` 判断,所有条件均在建图时提供完整依赖。
|
||||
|
||||
## 职业规则
|
||||
|
||||
`associate` 仅接受 SkillEffect/CharacterEffect,旧字典会直接报错。动态 `effect(old, new)` 作为目标构造入口,在收集模式下以 `(0, 最终等级)` 执行;公共入口记录贡献,不修改目标。每次收集都从原始 `associate` 声明开始,取消 VP 时不会保留上一次分支。
|
||||
|
||||
动态规则只能生成关联声明,不能在收集过程中直接修改角色或目标技能。需要修改关联产生方式时,应改用声明,而不是另写参数 setter 撤销旧倍率。元素师原先的 `ratio` 补偿 setter 已移除,避免与公共引擎重复应用。
|
||||
|
||||
引擎在创建时保存初始声明和状态默认值。动态分支应新建并赋值 `self.associate`,不要对共享的类级列表执行 `append` 或原地改写;也不要把运行后修改 `associate` 当作注册新规则。一次重算可能多次调用 `effect`,因此不能在其中累计计数、直接调用角色加成方法或依赖随机结果。
|
||||
|
||||
依赖图目前自动识别三类依赖:共享等级源、收集时跨技能 `.lv` 读取、指向状态属性的关联。
|
||||
类型化表达式的 Read 额外提供跨技能状态参数依赖;普通 Python 代码直接读取其他技能的 `.ratio` 仍不会自动建立依赖。
|
||||
新规则请使用 Read;单独加入 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/unit/test_typed_skill_effects.py -q
|
||||
backend/.venv/bin/pytest backend/tests/smoke/test_calc_availability.py -k configuration_order -q
|
||||
```
|
||||
|
||||
第二项验证技能配置顺序(包括搭配武器的场景),不验证装备执行顺序。修改公共引擎后再运行完整 `backend/tests` 回归。
|
||||
"""
|
||||
from core.character.refs.base import resolve_skill
|
||||
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
|
||||
from .effect_types import (
|
||||
CharacterAttribute, CharacterEffect, EffectDeclaration, MissingTargetPolicy, Operation,
|
||||
SkillEffect, STATE_ATTRIBUTES,
|
||||
)
|
||||
|
||||
|
||||
# 状态属性定义在 effect_types;技能类仍须提供相应默认值。
|
||||
# 外部赋值触发重算的输入;登记在此不等于自动跟踪跨技能的该属性读取。
|
||||
INPUT_ATTRIBUTES = STATE_ATTRIBUTES | {'vp', 'up', 'comboCol', 'stage', 'currentMode'}
|
||||
# CharacterBase.SetStatus 实际支持的标量属性。
|
||||
# 扩展前核对 SetStatus 的实际运算语义;特殊字段不能仅加入白名单。
|
||||
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):
|
||||
"""将角色修改方法包入关联批次,使外部加成写入基础值,并在批次结束后重算。"""
|
||||
@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}
|
||||
for skill, declarations in self._associations.items():
|
||||
for declaration in declarations:
|
||||
self._validate_declaration(skill, declaration)
|
||||
self._output_base = {}
|
||||
self._depth = 0
|
||||
self.resolving = False
|
||||
self.collecting = None
|
||||
self._discovering = False
|
||||
self._pending = []
|
||||
self._reads = set()
|
||||
self._dirty = True
|
||||
self._state_dirty = True
|
||||
self.contributions = ()
|
||||
self.unresolved_targets = set()
|
||||
|
||||
def owns(self, skill):
|
||||
# skillInfo 使用浅拷贝,仅用于本次技能输出计算。
|
||||
"""判断技能是否为本引擎管理的原实例,排除 skillInfo 创建的临时副本。"""
|
||||
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):
|
||||
"""按当前最终等级与 value 的差值调整基础等级,避免装备加级重复计入关联加级。"""
|
||||
if skill.level_source is not None:
|
||||
return
|
||||
# 装备 += 加级只调整输入,不能把已计算的精通加级写回基础等级。
|
||||
# 配置中的绝对基础等级通过 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):
|
||||
"""将 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))
|
||||
|
||||
@staticmethod
|
||||
def _validate_declaration(source, declaration):
|
||||
"""校验来源技能的单条声明类型;旧字典及其他不支持的类型直接报错。"""
|
||||
if not isinstance(declaration, (SkillEffect, CharacterEffect)):
|
||||
raise TypeError(
|
||||
f'技能 {source.name} 的 associate 仅接受 SkillEffect/CharacterEffect,'
|
||||
f'不支持 {type(declaration).__name__}'
|
||||
)
|
||||
|
||||
def collect_effect(self, source, declaration: EffectDeclaration):
|
||||
"""将来源技能的一条类型化声明解析为目标贡献。
|
||||
|
||||
建图阶段仅收集依赖和可能的状态目标,不计算表达式或按临时条件裁剪依赖。
|
||||
求值阶段检查前置条件、武器和 when 条件,再记录实际贡献;必须在来源收集上下文中调用。"""
|
||||
self._validate_declaration(source, declaration)
|
||||
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 = 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)
|
||||
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}')
|
||||
|
||||
if self._discovering:
|
||||
# 保留 precondition 中的等级读取依赖,但不根据临时结果裁剪依赖边。
|
||||
# 新条件优先通过 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;职业实现只能构造声明并调用公共收集入口。
|
||||
"""收集单个技能的贡献与读取依赖,并在结束时清理收集上下文。
|
||||
|
||||
discover=True 表示仅发现依赖;每次从初始声明开始,避免残留上次动态分支。"""
|
||||
self._pending = []
|
||||
self._reads = set()
|
||||
if not skill.association_source:
|
||||
return (), set()
|
||||
# 动态 VP 分支可能仅在启用时覆盖声明;每次恢复初始声明,
|
||||
# 确保取消 VP 后不会保留上次的分支。
|
||||
skill.associate = self._associations[skill]
|
||||
self.collecting = skill
|
||||
self._discovering = discover
|
||||
try:
|
||||
if skill.lv > 0:
|
||||
skill.effect(0, skill.lv)
|
||||
elif discover:
|
||||
# 等级覆盖可能激活尚未学习的来源,因此建图时也要收集其静态依赖。
|
||||
for declaration in skill.associate:
|
||||
self.collect_effect(skill, declaration)
|
||||
return tuple(self._pending), self._reads.copy()
|
||||
finally:
|
||||
self.collecting = None
|
||||
self._discovering = False
|
||||
|
||||
@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 的关联。
|
||||
# 类型化 Read 可声明跨技能状态参数依赖;普通 Python 属性读取仍不会自动建边。
|
||||
"""恢复输入基线、建立依赖图,并按拓扑顺序解析最终等级和状态参数。
|
||||
|
||||
共享等级源缺失、循环依赖或求值时新增依赖与状态目标均报错;本阶段不应用输出贡献。"""
|
||||
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 = resolve_skill(self.character, skill.level_source)
|
||||
if source is None:
|
||||
raise ValueError(f'共享等级源技能不存在: {skill.name} -> {skill.level_source}')
|
||||
graph[skill].add(source)
|
||||
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:
|
||||
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:
|
||||
# 拓扑顺序保证上游等级和参数贡献已完成计算。
|
||||
if skill.level_source is not None:
|
||||
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}')
|
||||
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()
|
||||
@@ -0,0 +1,16 @@
|
||||
from enum import Enum
|
||||
|
||||
class EnumDamageType(Enum):
|
||||
"""伤害类型"""
|
||||
DIRECT = 'direct'
|
||||
"""直伤"""
|
||||
SHOCK = 'shock'
|
||||
"""感电伤害"""
|
||||
BLEEDING = 'bleeding'
|
||||
"""出血伤害"""
|
||||
BURN = 'burn'
|
||||
"""灼烧伤害"""
|
||||
POISON = 'poison'
|
||||
"""中毒伤害"""
|
||||
ENCHANTPOISON = 'enchantpoison'
|
||||
"""涂毒伤害"""
|
||||
Reference in New Issue
Block a user