Files
dcalc/backend/core/basic/skill.py
T

609 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 .formula import 武器冷却惩罚
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
characterLv = 115 + 5
def _identity(value: Any) -> Any:
return value
@cache
def _load_custom_cn() -> list[dict[str, Any]]:
with OPENAPI_CUSTOM_CN_PATH.open(encoding='utf-8') as f:
return json.load(f)
@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):
prefix_parts = prefix.split('/')
job = prefix_parts[0]
jobGrow = prefix_parts[1]
skill = prefix_parts[-1]
def get_skill_info():
skill_data = _load_skill_detail(prefix)
custom_cn = get_redis_info(redis, 'dcalc:custom_cn', _load_custom_cn, without_gzip=True)
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 == "custom":
data = next((item.get('custom', 1.0) for item in custom_cn or []
if item['job'] == job and item['jobGrow'] == jobGrow and item['skillId'] == skill), 1.0)
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 []
if key == 'custom' and data is None:
return 1.0
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
level_source: 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[dict[str, Any] | EffectDeclaration] = []
"""关联声明;维护示例和扩展边界见 core/basic/skill_effects.py 文件顶部说明。
新规则可用 SkillEffect/CharacterEffect,与旧字典混用;目标仍使用技能名称数组。
新声明 value 是实际倍率/增量/覆盖值,以下 type、data、ratio 约定仅适用于旧字典。
type 默认 '*skillDamage':*乘算、+加算、=覆盖;$*PAtkP 等作用于角色。
skills 默认 '*',表示所有伤害技能;显式列表使用技能名称,可指定被动技能。
exceptSkills 默认 [];weapon 默认职业武器选项;precondition() 控制前置条件。
data 按来源等级取值;ratio 是数据除数,默认 100,与技能的 ratio 属性不同。
+lv 的整数加级通常设置 ratio=1;=覆盖直接使用 data 值,不除以 ratio。
动态 effect 以 (0, 最终等级) 多次收集,只构造声明并调用 super().effect。
自定义 type 方法仍有兼容入口,但直接修改目标会绕过贡献收集,新增规则勿照搬。
"""
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 = []
custom: float = 1.0
"""国服特色加强"""
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 self.char.GetSkillByName(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):
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)
ratio = assoc.get('ratio', 100)
weapon = assoc.get('weapon', weapon_options)
exceptSkills = assoc.get('exceptSkills', [])
if type[0] not in ['*', '+', '=', '$']:
if self.precondition() and (weapon_type in weapon or len(weapon) == len(weapon_options)):
getattr(self, type)(old, new, data, skills, exceptSkills, ratio, weapon)
continue
if self.precondition():
self.apply_association(type, old, new, data, skills, exceptSkills, ratio, weapon, weapon_type, weapon_options)
def precondition(self):
"""effect的前置条件"""
return True
def apply_association(self, type, old, new, data, skills, exceptSkills, ratio=100, weapon=(), weapon_type=None, weapon_options=()):
try:
if (weapon_type not in weapon) and not (weapon_type is None and len(weapon) == len(weapon_options)):
return
if type.startswith('$*'):
if 'cdReduce' in type:
value = (1 - self.get_data_value(data, new) / ratio) / (1 - self.get_data_value(data, old) / ratio)
else:
value = (1 + self.get_data_value(data, new) / ratio) / (1 + self.get_data_value(data, old) / ratio)
engine = getattr(self.char, 'skill_effects', None)
if engine is not None and engine.collecting is self:
if type[2:] in CHARACTER_ATTRIBUTES:
engine.record(self, self.char, type[2:], '*', value)
else:
self.char.SetStatus(**{type[2:]: value})
elif type.startswith('$+'):
value = self.get_data_value(data, new) / ratio - self.get_data_value(data, old) / ratio
engine = getattr(self.char, 'skill_effects', None)
if engine is not None and engine.collecting is self:
if type[2:] in CHARACTER_ATTRIBUTES:
engine.record(self, self.char, type[2:], '+', value)
else:
self.char.SetStatus(**{type[2:]: value})
else:
exceptSkills = set(exceptSkills)
if skills == '*':
for skill in self.char.GetSkills('all', True):
if skill.name in exceptSkills:
continue
self.update_skill_attribute(skill, type, old, new, data, ratio, weapon, weapon_type, weapon_options)
else:
for name in skills:
if name in exceptSkills:
continue
skill = self.char.GetSkillByName(name)
if skill is not None:
self.update_skill_attribute(skill, type, old, new, data, ratio, weapon, weapon_type, weapon_options)
else:
engine = getattr(self.char, 'skill_effects', None)
if engine is not None and engine.collecting is self:
engine.missing_target(self, name)
except Exception as e:
# print(f"Error applying association {type} for skill {self.name}: {e}")
raise e
def update_skill_attribute(self, skill, type, old, new, data, ratio=100, weapon=(), weapon_type=None, weapon_options=()):
if (weapon_type not in weapon) and not (weapon_type is None and len(weapon) == len(weapon_options)):
return
old_value = self.get_data_value(data, old)
new_value = self.get_data_value(data, new)
engine = getattr(self.char, 'skill_effects', None)
if engine is not None and engine.collecting is self:
if type[1:] == 'lv' and skill.level_source is not None:
return
if type.startswith('*'):
if 'cdReduce' in type or 'cdRatio' in type:
value = (1 - new_value / ratio) / (1 - old_value / ratio)
else:
value = (1 + new_value / ratio) / (1 + old_value / ratio)
elif type.startswith('+'):
if type[1:] == 'lv' and not engine.is_learned(skill):
return
value = (new_value - old_value) / ratio
else:
value = new_value
engine.record(self, skill, type[1:], type[0], value)
return
if type.startswith('*'):
if 'cdReduce' in type or type == 'cd' or 'cdRatio' in type:
setattr(skill, type[1:], getattr(skill, type[1:]) * (1 - new_value / ratio) / (1 - old_value / ratio))
else:
setattr(skill, type[1:], getattr(skill, type[1:]) * (1 + new_value / ratio) / (1 + old_value / ratio))
elif type.startswith('+'):
if type[1:] == 'lv' and self.char.setInfo.get('skills', {}).get(str(skill.id),{}).get('lv',0) == 0:
return
else:
setattr(skill, type[1:], getattr(skill, type[1:]) + new_value / ratio - old_value / ratio)
elif type.startswith('='):
setattr(skill, type[1:], new_value)
@staticmethod
def get_data_value(data, index: int):
if 0 <= index < len(data):
return data[index]
return 0
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.custom, 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 getQuickCDRatio(self):
return 1.0
cdr = 0
if 15 <= self.learnLv <= 30:
cdr = 0.01
elif 35 <= self.learnLv <= 70:
cdr = 0.02
elif 75 <= self.learnLv <= 100:
cdr = 0.05
if self.learnLv in [50, 85, 100]:
cdr = 0.05
return 1 - cdr
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.getQuickCDRatio()) * 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 getQuickCDRatio(self):
return 1.0
cdr = 0
if 15 <= self.learnLv <= 30:
cdr = 0.01
elif 35 <= self.learnLv <= 70:
cdr = 0.02
elif 75 <= self.learnLv <= 100:
cdr = 0.05
if self.learnLv in [50, 85, 100]:
cdr = 0.05
return 1 - cdr
def getSkillCD(self, mode=None):
return max(0, round(max(self.cd * 0.3, self.cd * self.cdReduce / self.cdRecover * self.getWeaponCDRatio() * self.getQuickCDRatio(), 1) - self.cdCut, 2))