From 060de9494f3ba580c8b04232b104c53530795b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E7=93=9C=C2=B0?= Date: Thu, 4 Jun 2026 11:03:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/core/RedisPreheat.py | 187 ++++++++++++++++-- backend/core/basic/character/base.py | 90 ++++++--- backend/core/basic/skill.py | 40 ++-- .../core/equipment/version_0/oathSuitSkill.py | 5 +- 4 files changed, 262 insertions(+), 60 deletions(-) diff --git a/backend/api/core/RedisPreheat.py b/backend/api/core/RedisPreheat.py index 4faf2f3a..abdaca82 100644 --- a/backend/api/core/RedisPreheat.py +++ b/backend/api/core/RedisPreheat.py @@ -1,24 +1,33 @@ from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed import json +from pathlib import Path import uuid import redis from api.core.Gzip import gzip_zip +from api.utils.skill_detail import normalize_skill_detail from core.basic.character import createCharacter +from core.basic.equipment import get_equipment from core.character.adventure import adv, get_adv_list -from database.connect import RedisPool +from database.connect import BACKEND_DIR, RedisPool PreheatLoader = Callable[[], object] PreheatTask = tuple[str, PreheatLoader] PREHEAT_LOCK_KEY = 'dcalc_sys:preheat:lock' PREHEAT_LOCK_EXPIRE_SECONDS = 15 * 60 +SKILL_DETAIL_PARTS = ('cn', 'skillDetail') def _load_skills(): - with open('./openapi/data/skills.json', encoding='utf-8') as file: + with (BACKEND_DIR / 'openapi' / 'data' / 'skills.json').open(encoding='utf-8') as file: + return json.load(file) + + +def _load_custom_cn(): + with (BACKEND_DIR / 'openapi' / 'custom_cn.json').open(encoding='utf-8') as file: return json.load(file) @@ -44,6 +53,125 @@ def _build_character_preheat_tasks(equ_version: str = '0') -> list[PreheatTask]: return tasks +def _iter_skill_detail_files() -> list[Path]: + data_dir = BACKEND_DIR / 'openapi' / 'data' + return sorted(data_dir.glob('*/' + '*/' + '/'.join(SKILL_DETAIL_PARTS) + '/*.json')) + + +def _skill_prefix_from_path(path: Path) -> str: + relative = path.relative_to(BACKEND_DIR / 'openapi' / 'data').with_suffix('') + return relative.as_posix() + + +def _openapi_skill_detail_key(prefix: str) -> str: + parts = prefix.split('/') + return f'openapi:{parts[0]}:{parts[1]}:{parts[-1]}' + + +def _load_skill_detail(path: Path): + with path.open(encoding='utf-8') as file: + return json.load(file) + + +def _option_value_count(skill_data: dict) -> int: + rows = skill_data.get('levelInfo', {}).get('rows') or [] + if not rows: + return 0 + return max( + ( + int(key.replace('value', '')) + for row in rows + for key in (row.get('optionValue') or {}) + if key.startswith('value') and key.replace('value', '').isdigit() + ), + default=0, + ) + + +def _skill_data_value(skill_data: dict, index: int) -> list: + key = f'value{index + 1}' + rows = skill_data.get('levelInfo', {}).get('rows') or [] + data = [0] + [(row.get('optionValue') or {}).get(key, 0) for row in rows] + return [0 if item is None or item == '-' else item for item in data] + + +def _custom_value(custom_cn: list[dict], prefix: str): + parts = prefix.split('/') + job = parts[0] + job_grow = parts[1] + skill_id = parts[-1] + return next( + ( + item.get('custom', 1.0) + for item in custom_cn + if item.get('job') == job and item.get('jobGrow') == job_grow and item.get('skillId') == skill_id + ), + 1.0, + ) + + +def _build_skill_data_preheat_tasks(custom_cn: list[dict]) -> list[PreheatTask]: + tasks: list[PreheatTask] = [] + for path in _iter_skill_detail_files(): + prefix = _skill_prefix_from_path(path) + skill_data = _load_skill_detail(path) + + tasks.append( + ( + _openapi_skill_detail_key(prefix), + lambda skill_data=skill_data: normalize_skill_detail(skill_data), + ) + ) + tasks.append((f'dcalc:skill:{prefix}:custom', lambda prefix=prefix: _custom_value(custom_cn, prefix))) + + if skill_data.get('evolution'): + tasks.append( + ( + f'dcalc:skill:{prefix}:vps', + lambda skill_data=skill_data: [{'name': item['name'], 'desc': item['desc']} for item in skill_data.get('evolution', [])], + ) + ) + + for index in range(_option_value_count(skill_data)): + tasks.append((f'dcalc:skill:{prefix}:{index}', lambda skill_data=skill_data, index=index: _skill_data_value(skill_data, index))) + return tasks + + +def _build_skill_tree_preheat_tasks() -> list[PreheatTask]: + tasks: list[PreheatTask] = [] + data_dir = BACKEND_DIR / 'openapi' / 'data' + for path in sorted(data_dir.glob('*/' + '*/cn/skill_tree.json')): + relative = path.relative_to(data_dir) + job_id = relative.parts[0] + job_grow_id = relative.parts[1] + tasks.append( + ( + f'openapi:{job_id}:{job_grow_id}:skills', + lambda path=path: json.loads(path.read_text(encoding='utf-8')), + ) + ) + return tasks + + +def _build_oath_preheat_tasks(equ_version: str = '0') -> list[PreheatTask]: + tasks: list[PreheatTask] = [] + equ = get_equipment(equ_version) + for oath in equ.oath_suits: + for skill in oath.skills: + key = f'dcalc:oath:{oath.suitId}:{oath.rarity}:{oath.level}:{skill.skillId}' + tasks.append( + ( + key, + lambda oath=oath, skill=skill: { + **skill.__dict__, + 'point': oath.points.split(',')[oath.level - 1], + 'imageUrl': oath.imageUrl, + }, + ) + ) + return tasks + + def _write_preheated_value(redis_conn: redis.Redis, key: str, value: object, expire: int = 0, without_gzip: bool = False): if value is None: return @@ -60,9 +188,14 @@ def _run_preheat_task(key: str, loader: PreheatLoader) -> tuple[str, object]: def delete_keys_with_prefix(redis_conn: redis.Redis, prefix: str): - keys = redis_conn.keys(f'{prefix}*') - if keys: - redis_conn.delete(*keys) + batch: list[bytes] = [] + for key in redis_conn.scan_iter(f'{prefix}*', count=500): + batch.append(key) + if len(batch) >= 500: + redis_conn.delete(*batch) + batch.clear() + if batch: + redis_conn.delete(*batch) def _acquire_preheat_lock(redis_conn: redis.Redis) -> str | None: @@ -91,15 +224,14 @@ def preheat_redis_cache() -> list[str]: # if config.DEBUG_MODE: # print('Skip redis preheat in debug mode.') # return [] - base_preheat_tasks: list[PreheatTask] = [ - ('dcalc:adventure', get_adv_list), - ('openapi:skills', _load_skills), - ] - character_preheat_tasks = _build_character_preheat_tasks() warmed_keys: list[str] = [] with redis.Redis(connection_pool=RedisPool) as redis_conn: - lock_token = _acquire_preheat_lock(redis_conn) + try: + lock_token = _acquire_preheat_lock(redis_conn) + except redis.RedisError as exc: + print(f'Skip redis preheat: redis unavailable ({exc}).') + return [] if not lock_token: print('Skip redis preheat: another instance is warming cache.') return [] @@ -108,13 +240,44 @@ def preheat_redis_cache() -> list[str]: delete_keys_with_prefix(redis_conn, 'dcalc:') delete_keys_with_prefix(redis_conn, 'openapi:') + custom_cn = _load_custom_cn() + base_preheat_tasks: list[PreheatTask] = [ + ('dcalc:adventure', get_adv_list), + ('dcalc:custom_cn', lambda: custom_cn), + ('openapi:skills', _load_skills), + ] + shared_preheat_tasks = [ + *_build_skill_tree_preheat_tasks(), + *_build_skill_data_preheat_tasks(custom_cn), + *_build_oath_preheat_tasks(), + ] + character_preheat_tasks = _build_character_preheat_tasks() + for key, loader in base_preheat_tasks: try: - _write_preheated_value(redis_conn, key, loader()) + _write_preheated_value(redis_conn, key, loader(), without_gzip=key == 'dcalc:custom_cn') warmed_keys.append(key) except Exception as exc: print(f'Redis preheat failed for {key}: {exc}') + max_workers = min(8, len(shared_preheat_tasks)) + if max_workers > 0: + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_map = {executor.submit(_run_preheat_task, key, loader): key for key, loader in shared_preheat_tasks} + for future in as_completed(future_map): + key = future_map[future] + try: + _, value = future.result() + _write_preheated_value( + redis_conn, + key, + value, + without_gzip=key.startswith('dcalc:skill:'), + ) + warmed_keys.append(key) + except Exception as exc: + print(f'Redis preheat failed for {key}: {exc}') + max_workers = min(8, len(character_preheat_tasks)) if max_workers > 0: with ThreadPoolExecutor(max_workers=max_workers) as executor: diff --git a/backend/core/basic/character/base.py b/backend/core/basic/character/base.py index 79b43763..11d4d7a8 100644 --- a/backend/core/basic/character/base.py +++ b/backend/core/basic/character/base.py @@ -9,6 +9,25 @@ from ..roleinfo import CharacterEmblemInfo, CharacterEquipInfo, get_key_by_value from .jade import Jade +ELEMENT_STATUS_KEYS = { + 'ElementA', + 'ElementDB', + 'ElementR', + '火强', + '冰强', + '光强', + '暗强', + '火抗', + '冰抗', + '光抗', + '暗抗', + '火属性攻击', + '冰属性攻击', + '光属性攻击', + '暗属性攻击', +} + + class CharacterBase(CharacterProperty): # region 额外类属性(CharacterProperty 中未声明的) @@ -19,6 +38,7 @@ class CharacterBase(CharacterProperty): skills_id_dict: dict[str, 'Skill'] skills_uuid_dict: dict[str, 'Skill'] skill_names_cache: dict[tuple[str, bool | str], list[str]] + skills_cache: dict[tuple[str, bool | str], list['Skill']] equVersion: str = '0' """装备版本""" charEquipInfo: dict[str, 'CharacterEquipInfo'] @@ -153,6 +173,7 @@ class CharacterBase(CharacterProperty): self.skills_id_dict = {} self.skills_uuid_dict = {} self.skill_names_cache = {} + self.skills_cache = {} self.load_skills() # region 角色属性设置 @@ -249,33 +270,38 @@ class CharacterBase(CharacterProperty): # frame = sys._getframe(1) # 1 表示上一级调用帧 # caller_name = frame.f_code.co_name # print(caller_name, kwargs.get('SkillAttack', 0)) - self.STR += 力量 + kwargs.get('STR', 0) + 四维 + 力智 - self.INT += 智力 + kwargs.get('INT', 0) + 四维 + 力智 - self.Spirit += 精神 + kwargs.get('Spirit', 0) + 四维 + 体精 - self.Vitality += 体力 + kwargs.get('Vitality', 0) + 四维 + 体精 - self.AtkP += 物攻 + kwargs.get('AtkP', 0) + 三攻 - self.AtkM += 魔攻 + kwargs.get('AtkM', 0) + 三攻 - self.AtkI += 独立 + kwargs.get('AtkI', 0) + 三攻 - self.PAtkP *= kwargs.get('PAtkP', 1.0) - self.PAtkM *= kwargs.get('PAtkM', 1.0) - self.PAtkI *= kwargs.get('PAtkI', 1.0) - for elem in ['火', '冰', '光', '暗']: - self.ElementA[elem] = kwargs.get(f'{elem}属性攻击', False) or kwargs.get('ElementA', {}).get(elem, False) or self.ElementA[elem] - self.ElementDB[elem] += kwargs.get(f'{elem}强', 0) + kwargs.get('ElementDB', {}).get(elem, 0) + 全属强 - self.ElementR[elem] += kwargs.get(f'{elem}抗', 0) + kwargs.get('ElementR', {}).get(elem, 0) + 全属抗 - self.CriticalM += kwargs.get('CriticalM', 0) + 魔暴 - self.CriticalP += kwargs.get('CriticalP', 0) + 物暴 - self.SkillAttack *= (1 + 技攻) * (1 + kwargs.get('SkillAttack', 0)) - self.SpeedA += 攻击速度 + kwargs.get('SpeedA', 0) + 三速 - self.SpeedM += 移动速度 + kwargs.get('SpeedM', 0) + 三速 - self.SpeedR += 施放速度 + kwargs.get('SpeedR', 0) + 三速 - self.HitP += 命中率 + kwargs.get('HitP', 0) - self.Hit += 命中 + kwargs.get('Hit', 0) - self.Attack += 攻击强化 + kwargs.get('Attack', 0) - self.AttackP += 攻击强化P + kwargs.get('AttackP', 0.0) - self.Buffer += 增益量 + kwargs.get('Buffer', 0) - self.BufferP += 增益量P + kwargs.get('BufferP', 0) - self.EquEffectRatio += kwargs.get('EquEffectRatio', 0.0) + kget = kwargs.get + self.STR += 力量 + kget('STR', 0) + 四维 + 力智 + self.INT += 智力 + kget('INT', 0) + 四维 + 力智 + self.Spirit += 精神 + kget('Spirit', 0) + 四维 + 体精 + self.Vitality += 体力 + kget('Vitality', 0) + 四维 + 体精 + self.AtkP += 物攻 + kget('AtkP', 0) + 三攻 + self.AtkM += 魔攻 + kget('AtkM', 0) + 三攻 + self.AtkI += 独立 + kget('AtkI', 0) + 三攻 + self.PAtkP *= kget('PAtkP', 1.0) + self.PAtkM *= kget('PAtkM', 1.0) + self.PAtkI *= kget('PAtkI', 1.0) + if 全属强 or 全属抗 or ELEMENT_STATUS_KEYS.intersection(kwargs): + element_a = kget('ElementA', {}) + element_db = kget('ElementDB', {}) + element_r = kget('ElementR', {}) + for elem in ['火', '冰', '光', '暗']: + self.ElementA[elem] = kget(f'{elem}属性攻击', False) or element_a.get(elem, False) or self.ElementA[elem] + self.ElementDB[elem] += kget(f'{elem}强', 0) + element_db.get(elem, 0) + 全属强 + self.ElementR[elem] += kget(f'{elem}抗', 0) + element_r.get(elem, 0) + 全属抗 + self.CriticalM += kget('CriticalM', 0) + 魔暴 + self.CriticalP += kget('CriticalP', 0) + 物暴 + self.SkillAttack *= (1 + 技攻) * (1 + kget('SkillAttack', 0)) + self.SpeedA += 攻击速度 + kget('SpeedA', 0) + 三速 + self.SpeedM += 移动速度 + kget('SpeedM', 0) + 三速 + self.SpeedR += 施放速度 + kget('SpeedR', 0) + 三速 + self.HitP += 命中率 + kget('HitP', 0) + self.Hit += 命中 + kget('Hit', 0) + self.Attack += 攻击强化 + kget('Attack', 0) + self.AttackP += 攻击强化P + kget('AttackP', 0.0) + self.Buffer += 增益量 + kget('Buffer', 0) + self.BufferP += 增益量P + kget('BufferP', 0) + self.EquEffectRatio += kget('EquEffectRatio', 0.0) def AddElementDB(self, element: str, value: float, type: int = 0) -> None: """ @@ -343,10 +369,14 @@ class CharacterBase(CharacterProperty): def GetSkillNames(self, type: Literal['active', 'passive', 'all'] = 'all', damage: Literal[True, False, 'all'] = 'all') -> list[str]: """获取技能名字""" + return [skill.name for skill in self.GetSkills(type, damage)] + + def GetSkills(self, type: Literal['active', 'passive', 'all'] = 'all', damage: Literal[True, False, 'all'] = 'all') -> list['Skill']: + """获取技能对象""" key = (type, damage) - if key not in self.skill_names_cache: - self.skill_names_cache[key] = [skill.name for skill in self.skills if (type == 'all' or skill.type == type) and (damage == 'all' or skill.damage == damage)] - return self.skill_names_cache[key] + if key not in self.skills_cache: + self.skills_cache[key] = [skill for skill in self.skills if (type == 'all' or skill.type == type) and (damage == 'all' or skill.damage == damage)] + return self.skills_cache[key] def GetWeaponType(self) -> tuple[str, str]: """获取武器类型""" diff --git a/backend/core/basic/skill.py b/backend/core/basic/skill.py index e8d132c9..ec5695f8 100644 --- a/backend/core/basic/skill.py +++ b/backend/core/basic/skill.py @@ -19,6 +19,18 @@ def _identity(value: Any) -> Any: return value +@cache +def _load_custom_cn() -> list[dict[str, Any]]: + with (OPENAPI_DIR / 'custom_cn.json').open(encoding='utf-8') as f: + return json.load(f) + + +@cache +def _load_skill_detail(prefix: str) -> dict[str, Any]: + with (OPENAPI_DIR / 'data' / f'{prefix}.json').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')) @@ -41,15 +53,9 @@ def get_data(prefix: str, key: int | str, func: Callable[[Any], Any] = _identity jobGrow = prefix_parts[1] skill = prefix_parts[-1] - def get_custom_cn(): - with (OPENAPI_DIR / 'custom_cn.json').open(encoding='utf-8') as f: - custom_cn = json.load(f) - return custom_cn - def get_skill_info(): - with (OPENAPI_DIR / 'data' / f'{prefix}.json').open(encoding='utf-8') as f: - skill_data = json.load(f) - custom_cn = get_redis_info(redis, 'dcalc:custom_cn', get_custom_cn, without_gzip=True) + 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"]] @@ -235,15 +241,19 @@ class Skill: value = self.get_data_value(data, new) / ratio - self.get_data_value(data, old) / ratio self.char.SetStatus(**{type[2:]: value}) else: - if skills == '*': - skills = self.char.GetSkillNames('all', True) exceptSkills = set(exceptSkills) - for name in skills: - if name in exceptSkills: - continue - skill = self.char.GetSkillByName(name) - if skill is not None: + 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) except Exception as e: # print(f"Error applying association {type} for skill {self.name}: {e}") raise e diff --git a/backend/core/equipment/version_0/oathSuitSkill.py b/backend/core/equipment/version_0/oathSuitSkill.py index 81b6c485..85a4f24e 100644 --- a/backend/core/equipment/version_0/oathSuitSkill.py +++ b/backend/core/equipment/version_0/oathSuitSkill.py @@ -1,4 +1,4 @@ -import inspect +import sys from core.abstract.character import CharacterProperty from core.basic.equipment import EquEffect @@ -6,8 +6,7 @@ from .register import register def check_suit(char: CharacterProperty) -> bool: - caller_frame = inspect.stack()[1] - fun_name = caller_frame.function + fun_name = sys._getframe(1).f_code.co_name suitId = int(int(fun_name.split('_')[-1]) / 10000) + 16000 result = next((x for x in char.suitInfo if str(x.suitId) == str(suitId)), None) is not None return result