codex/backend-optimization #2

Merged
imelon merged 11 commits from codex/backend-optimization into master 2026-06-05 11:36:19 +08:00
25 changed files with 1634 additions and 71 deletions
+36
View File
@@ -0,0 +1,36 @@
# dcalc backend
## Run
```bash
uv sync
python main.py
```
## Test
```bash
uv run pytest -v
```
## Quality
```bash
uv run ruff check .
```
## Runtime Entry
- `main.py`: FastAPI application and uvicorn entry.
- `api/routes/calc.py`: `/api/calc` HTTP route.
- `services/calc_service.py`: calculation orchestration.
- `core/basic/character/`: character calculation implementation.
- `core/basic/equipment.py`: equipment data models and compatibility access.
- `core/basic/equipment_registry.py`: equipment version registry and cache.
- `database/connect.py`: SQLite and Redis connections.
## Compatibility Rules
- Do not change `/calc` request and response contract without frontend migration approval.
- Do not change frontendNew result data shape during backend refactors.
- Keep Skill and Forge business formulas behavior-compatible unless a dedicated formula task says otherwise.
+2 -3
View File
@@ -2,7 +2,7 @@ from api.core.Gzip import register_gzip_request
from fastapi import APIRouter, Body from fastapi import APIRouter, Body
from api.core.Response import response from api.core.Response import response
from api.dp import AltersDep from api.dp import AltersDep
from core.basic.character import createCharacter from services.calc_service import calculate
router = APIRouter() router = APIRouter()
@@ -11,5 +11,4 @@ register_gzip_request(router)
@router.post('/calc') @router.post('/calc')
async def calc(state: AltersDep, config=Body(None)): async def calc(state: AltersDep, config=Body(None)):
character = createCharacter(state.alter, state.equVersion) return response(data=calculate(state, config))
return response(data=character.calc(config))
+2 -1
View File
@@ -19,7 +19,8 @@ class Config(BaseSettings):
HELPER_ID: str = '' HELPER_ID: str = ''
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=('config/.env', 'config/.env.dev', 'config/.env.prod') env_file=('config/.env', 'config/.env.dev', 'config/.env.prod'),
extra='ignore',
) )
+13 -15
View File
@@ -6,6 +6,8 @@ from core.basic.equipment import OathSuit, Suit, get_equipment
from ..formula import 增幅计算, 强化技攻, 武器强化计算, 精通计算, 耳环计算, 左右计算, 锻造计算 from ..formula import 增幅计算, 强化技攻, 武器强化计算, 精通计算, 耳环计算, 左右计算, 锻造计算
from ..roleinfo import CharacterEmblemInfo, CharacterEquipInfo, CharacterOathInfo, get_key_by_value from ..roleinfo import CharacterEmblemInfo, CharacterEquipInfo, CharacterOathInfo, get_key_by_value
from .base import CharacterBase from .base import CharacterBase
from .config_view import CalcConfigView
from .effect_apply import apply_uppercase_attrs
@cache @cache
@@ -162,8 +164,7 @@ class CalcEquipMixin(CharacterBase):
# 如果存在誓约和套装是同一个套装 # 如果存在誓约和套装是同一个套装
# 则直接不执行原套装效果,执行誓约套装增强的效果 # 则直接不执行原套装效果,执行誓约套装增强的效果
oathEffect = oath_suit_by_suit_id.get(suit.suitId, None) oathEffect = oath_suit_by_suit_id.get(suit.suitId, None)
filtered_dict = {k: v for k, v in suit.__dict__.items() if k[0].isupper()} apply_uppercase_attrs(self, suit)
self.SetStatus(**filtered_dict)
if func is not None and oathEffect is None: if func is not None and oathEffect is None:
# print(f'套装 {suit.name} 生效') # print(f'套装 {suit.name} 生效')
func(self) func(self)
@@ -184,13 +185,11 @@ class CalcEquipMixin(CharacterBase):
skills = [x for x in oathSuit.skills if x.suitId == oathSuit.suitId] skills = [x for x in oathSuit.skills if x.suitId == oathSuit.suitId]
skill = next((x for x in oathSuit.skills if str(x.skillId) == str(self.charOathSkillId)), None) skill = next((x for x in oathSuit.skills if str(x.skillId) == str(self.charOathSkillId)), None)
# 誓约套装效果生效 # 誓约套装效果生效
filtered_dict = {k: v for k, v in oathSuit.__dict__.items() if k[0].isupper()} apply_uppercase_attrs(self, oathSuit)
self.SetStatus(**filtered_dict)
if skill is not None: if skill is not None:
oathSkillEffect.append(skill) oathSkillEffect.append(skill)
# 誓约技能效果生效 # 誓约技能效果生效
filtered_dict = {k: v for k, v in skill.__dict__.items() if k[0].isupper()} apply_uppercase_attrs(self, skill)
self.SetStatus(**filtered_dict)
self.oathSkillInfo.append(skill) self.oathSkillInfo.append(skill)
self.oathSuitInfo.append(oathSuit) self.oathSuitInfo.append(oathSuit)
if oathSuit.suitId == max_point_oath_suit: if oathSuit.suitId == max_point_oath_suit:
@@ -235,8 +234,7 @@ class CalcEquipMixin(CharacterBase):
fun = effects.execture(f'equ_{equ.id}') fun = effects.execture(f'equ_{equ.id}')
if fun is None: if fun is None:
continue continue
filtered_dict = {k: v for k, v in equ.__dict__.items() if k[0].isupper()} apply_uppercase_attrs(self, equ)
self.SetStatus(**filtered_dict)
fun(self) fun(self)
fun = effects.execture(f'enchant_{enchat}') fun = effects.execture(f'enchant_{enchat}')
if fun is not None: if fun is not None:
@@ -286,8 +284,7 @@ class CalcEquipMixin(CharacterBase):
fun = effects.execture(f'oath_{oath.id}') fun = effects.execture(f'oath_{oath.id}')
if fun is None: if fun is None:
continue continue
filtered_dict = {k: v for k, v in oath.__dict__.items() if k[0].isupper()} apply_uppercase_attrs(self, oath)
self.SetStatus(**filtered_dict)
fun(self) fun(self)
pass pass
@@ -403,12 +400,13 @@ class CalcEquipMixin(CharacterBase):
# region 主计算入口 # region 主计算入口
def calc(self, setInfo: dict[str, dict]): def calc(self, setInfo: dict[str, dict]):
self.calc_init(setInfo) config = CalcConfigView(setInfo).as_legacy_dict()
self.calc_init(config)
self.SetBaseStatus() self.SetBaseStatus()
self.calc_jade(setInfo.get('jades', [])) self.calc_jade(config.get('jades', []))
self.calc_avatar(setInfo.get('avatar', {})) self.calc_avatar(config.get('avatar', {}))
self.calc_basic() self.calc_basic()
self.calc_sundry(setInfo.get('sundry', {})) self.calc_sundry(config.get('sundry', {}))
self.calc_emblems() self.calc_emblems()
suit = self.calc_suits() suit = self.calc_suits()
self.calc_equs() self.calc_equs()
@@ -430,7 +428,7 @@ class CalcEquipMixin(CharacterBase):
self.输出类型 = '魔法百分比' self.输出类型 = '魔法百分比'
else: else:
self.输出类型 = '物理百分比' self.输出类型 = '物理百分比'
skillInfos = self.calc_carry_skills(setInfo.get('DSB', False), setInfo.get('BUFF', False)) skillInfos = self.calc_carry_skills(config.get('DSB', False), config.get('BUFF', False))
info = self.get_carry_info() info = self.get_carry_info()
return {'uuid': uuid1().hex, 'skills': skillInfos, 'info': info, 'suits': suit, 'buffer': self.buffer} return {'uuid': uuid1().hex, 'skills': skillInfos, 'info': info, 'suits': suit, 'buffer': self.buffer}
@@ -0,0 +1,61 @@
from collections.abc import Mapping
from typing import Any
class CalcConfigView:
def __init__(self, raw: Mapping[str, Any] | None):
self.raw = dict(raw or {})
@property
def equips(self) -> dict[str, dict[str, Any]]:
return dict(self.raw.get('equips') or {})
@property
def oaths(self) -> dict[str, dict[str, Any]]:
return dict(self.raw.get('oaths') or {})
@property
def emblems(self) -> dict[str, dict[str, Any]]:
return dict(self.raw.get('emblems') or {})
@property
def skills(self) -> dict[str, dict[str, Any]]:
return dict(self.raw.get('skills') or {})
@property
def avatar(self) -> dict[str, Any]:
return dict(self.raw.get('avatar') or {})
@property
def sundry(self) -> dict[str, Any]:
return dict(self.raw.get('sundry') or {})
@property
def jades(self) -> dict[str, Any]:
return dict(self.raw.get('jades') or {})
@property
def options(self) -> dict[str, Any]:
return dict(self.raw.get('options') or {})
@property
def dsb(self) -> bool:
return bool(self.raw.get('DSB', False))
@property
def buff(self) -> bool:
return bool(self.raw.get('BUFF', False))
def as_legacy_dict(self) -> dict[str, Any]:
data = dict(self.raw)
data.setdefault('equips', {})
data.setdefault('oaths', {})
data.setdefault('emblems', {})
data.setdefault('skills', {})
data.setdefault('avatar', {})
data.setdefault('sundry', {})
data.setdefault('jades', {})
data.setdefault('options', {})
data.setdefault('DSB', False)
data.setdefault('BUFF', False)
return data
@@ -0,0 +1,17 @@
from typing import Any
def uppercase_attrs(source: Any) -> dict[str, Any]:
return {key: value for key, value in source.__dict__.items() if key and key[0].isupper()}
def apply_uppercase_attrs(character: Any, source: Any) -> None:
character.SetStatus(**uppercase_attrs(source))
def execute_effect(effects: Any, key: str, *args: Any) -> bool:
fun = effects.execture(key)
if fun is None:
return False
fun(*args)
return True
+3 -8
View File
@@ -4,6 +4,7 @@ from functools import cache
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database.models import EmblemData, OathData, OathSuitData, OathSuitSkillData, PrimerData, EquData, SuitData, EnchantData, JadeData from database.models import EmblemData, OathData, OathSuitData, OathSuitSkillData, PrimerData, EquData, SuitData, EnchantData, JadeData
from database.connect import get_db_engine as get_engine from database.connect import get_db_engine as get_engine
from core.basic.equipment_registry import EquipmentRegistry
def parse_to_number_list(info: str, default: Sequence[float] = (0,)) -> list[float]: def parse_to_number_list(info: str, default: Sequence[float] = (0,)) -> list[float]:
@@ -422,14 +423,8 @@ class OathSuit:
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
global equ0 _equipment_registry = EquipmentRegistry(Equipments)
equ0 = Equipments('0')
def get_equipment(version: str = '0') -> Equipments: def get_equipment(version: str = '0') -> Equipments:
# print('加载装备信息') return _equipment_registry.get(version)
try:
temp = eval(f'equ{version}'.format(version))
except Exception:
temp = equ0
return temp
+22
View File
@@ -0,0 +1,22 @@
from functools import cache
from pathlib import Path
from database.connect import BACKEND_DIR
class EquipmentRegistry:
def __init__(self, equipment_cls):
self.equipment_cls = equipment_cls
@cache
def get(self, version: str = '0'):
normalized = version or '0'
if not self._version_exists(normalized):
if normalized == '0':
return self.equipment_cls(normalized)
return self.get('0')
return self.equipment_cls(normalized)
def _version_exists(self, version: str) -> bool:
db_path = Path(BACKEND_DIR) / 'dataFiles' / f'dcalc_{version}.db'
return db_path.exists()
@@ -1,10 +1,10 @@
#8d4d2001cdb357e41633c234eb7501b5 #8d4d2001cdb357e41633c234eb7501b5
from core.basic.skill import PassiveSkill, ActiveSkill, get_data from core.basic.skill import PassiveSkill, ActiveSkill, get_data
from core.basic.character import Character from core.basic.character import Character
prefix = "imperialknight/infighter/cn/skillDetail" prefix = "imperialknight/breaker/cn/skillDetail"
# 레이즈 어퍼컷 # 레이즈 어퍼컷
# imperialknight/infighter/3c5604bdbb0240b8f130f59ab40509c3 # imperialknight/breaker/3c5604bdbb0240b8f130f59ab40509c3
# 8d4d2001cdb357e41633c234eb7501b5/3c5604bdbb0240b8f130f59ab40509c3 # 8d4d2001cdb357e41633c234eb7501b5/3c5604bdbb0240b8f130f59ab40509c3
class Skill0(ActiveSkill): class Skill0(ActiveSkill):
""" """
@@ -29,7 +29,7 @@ class Skill0(ActiveSkill):
data0 = get_data(f'{prefix}/{uuid}', 0) data0 = get_data(f'{prefix}/{uuid}', 0)
# 덱 브레이커 # 덱 브레이커
# imperialknight/infighter/717f1e2104fe4b796f800352fa143ecc # imperialknight/breaker/717f1e2104fe4b796f800352fa143ecc
# 8d4d2001cdb357e41633c234eb7501b5/717f1e2104fe4b796f800352fa143ecc # 8d4d2001cdb357e41633c234eb7501b5/717f1e2104fe4b796f800352fa143ecc
class Skill1(PassiveSkill): class Skill1(PassiveSkill):
""" """
@@ -56,7 +56,7 @@ class Skill1(PassiveSkill):
data1 = get_data(f'{prefix}/{uuid}', 1) data1 = get_data(f'{prefix}/{uuid}', 1)
# 聚焦射击 # 聚焦射击
# imperialknight/infighter/eb71e1d82d92c7e1d40500a0dcd77aa6 # imperialknight/breaker/eb71e1d82d92c7e1d40500a0dcd77aa6
# 8d4d2001cdb357e41633c234eb7501b5/eb71e1d82d92c7e1d40500a0dcd77aa6 # 8d4d2001cdb357e41633c234eb7501b5/eb71e1d82d92c7e1d40500a0dcd77aa6
class Skill2(ActiveSkill): class Skill2(ActiveSkill):
""" """
@@ -80,7 +80,7 @@ class Skill2(ActiveSkill):
data0 = get_data(f'{prefix}/{uuid}', 0) data0 = get_data(f'{prefix}/{uuid}', 0)
# 后跳 # 后跳
# imperialknight/infighter/7822d6d52e10964a6755f142c666b494 # imperialknight/breaker/7822d6d52e10964a6755f142c666b494
# 8d4d2001cdb357e41633c234eb7501b5/7822d6d52e10964a6755f142c666b494 # 8d4d2001cdb357e41633c234eb7501b5/7822d6d52e10964a6755f142c666b494
class Skill3(ActiveSkill): class Skill3(ActiveSkill):
""" """
@@ -101,7 +101,7 @@ class Skill3(ActiveSkill):
# 基础精通 # 基础精通
# imperialknight/infighter/5a56514f35cf0270ae8d6c65f8fefd78 # imperialknight/breaker/5a56514f35cf0270ae8d6c65f8fefd78
# 8d4d2001cdb357e41633c234eb7501b5/5a56514f35cf0270ae8d6c65f8fefd78 # 8d4d2001cdb357e41633c234eb7501b5/5a56514f35cf0270ae8d6c65f8fefd78
class Skill4(PassiveSkill): class Skill4(PassiveSkill):
""" """
@@ -132,7 +132,7 @@ class Skill4(PassiveSkill):
data2 = get_data(f'{prefix}/{uuid}', 2) data2 = get_data(f'{prefix}/{uuid}', 2)
# 受身蹲伏 # 受身蹲伏
# imperialknight/infighter/ce26c6b69d02a440a81b552bec94f03b # imperialknight/breaker/ce26c6b69d02a440a81b552bec94f03b
# 8d4d2001cdb357e41633c234eb7501b5/ce26c6b69d02a440a81b552bec94f03b # 8d4d2001cdb357e41633c234eb7501b5/ce26c6b69d02a440a81b552bec94f03b
class Skill5(ActiveSkill): class Skill5(ActiveSkill):
""" """
@@ -163,7 +163,7 @@ class Skill5(ActiveSkill):
data2 = get_data(f'{prefix}/{uuid}', 2) data2 = get_data(f'{prefix}/{uuid}', 2)
# 技能连招 # 技能连招
# imperialknight/infighter/10b31c7e8cd2d60b9bcf3b1d3ada4b0a # imperialknight/breaker/10b31c7e8cd2d60b9bcf3b1d3ada4b0a
# 8d4d2001cdb357e41633c234eb7501b5/10b31c7e8cd2d60b9bcf3b1d3ada4b0a # 8d4d2001cdb357e41633c234eb7501b5/10b31c7e8cd2d60b9bcf3b1d3ada4b0a
class Skill6(ActiveSkill): class Skill6(ActiveSkill):
""" """
@@ -206,7 +206,7 @@ class Skill6(ActiveSkill):
# 防具精通 # 防具精通
# imperialknight/infighter/892ef624d8bf3d7fc045f84825fd6104 # imperialknight/breaker/892ef624d8bf3d7fc045f84825fd6104
# 8d4d2001cdb357e41633c234eb7501b5/892ef624d8bf3d7fc045f84825fd6104 # 8d4d2001cdb357e41633c234eb7501b5/892ef624d8bf3d7fc045f84825fd6104
class Skill7(PassiveSkill): class Skill7(PassiveSkill):
""" """
@@ -228,7 +228,7 @@ class Skill7(PassiveSkill):
# 近敌灭杀 # 近敌灭杀
# imperialknight/infighter/9dda3f4a849dba1a288dd65e116860f2 # imperialknight/breaker/9dda3f4a849dba1a288dd65e116860f2
# 8d4d2001cdb357e41633c234eb7501b5/9dda3f4a849dba1a288dd65e116860f2 # 8d4d2001cdb357e41633c234eb7501b5/9dda3f4a849dba1a288dd65e116860f2
class Skill8(ActiveSkill): class Skill8(ActiveSkill):
""" """
@@ -257,7 +257,7 @@ class Skill8(ActiveSkill):
data1 = get_data(f'{prefix}/{uuid}', 1) data1 = get_data(f'{prefix}/{uuid}', 1)
# 加农炮 # 加农炮
# imperialknight/infighter/4655101518604f874721b3cc249aae10 # imperialknight/breaker/4655101518604f874721b3cc249aae10
# 8d4d2001cdb357e41633c234eb7501b5/4655101518604f874721b3cc249aae10 # 8d4d2001cdb357e41633c234eb7501b5/4655101518604f874721b3cc249aae10
class Skill9(ActiveSkill): class Skill9(ActiveSkill):
""" """
@@ -286,7 +286,7 @@ class Skill9(ActiveSkill):
# 施放技能过程中所受伤害 -90% # 施放技能过程中所受伤害 -90%
# 퀵 바이섹션 # 퀵 바이섹션
# imperialknight/infighter/0969cd4054d93da07708108c0cc1c4b5 # imperialknight/breaker/0969cd4054d93da07708108c0cc1c4b5
# 8d4d2001cdb357e41633c234eb7501b5/0969cd4054d93da07708108c0cc1c4b5 # 8d4d2001cdb357e41633c234eb7501b5/0969cd4054d93da07708108c0cc1c4b5
class Skill10(ActiveSkill): class Skill10(ActiveSkill):
""" """
@@ -310,7 +310,7 @@ class Skill10(ActiveSkill):
data0 = get_data(f'{prefix}/{uuid}', 0) data0 = get_data(f'{prefix}/{uuid}', 0)
# 러프 보딩 # 러프 보딩
# imperialknight/infighter/c9664191611af31142e052dfaef84530 # imperialknight/breaker/c9664191611af31142e052dfaef84530
# 8d4d2001cdb357e41633c234eb7501b5/c9664191611af31142e052dfaef84530 # 8d4d2001cdb357e41633c234eb7501b5/c9664191611af31142e052dfaef84530
class Skill11(ActiveSkill): class Skill11(ActiveSkill):
""" """
@@ -346,7 +346,7 @@ class Skill11(ActiveSkill):
data2 = get_data(f'{prefix}/{uuid}', 2) data2 = get_data(f'{prefix}/{uuid}', 2)
# 强化 - 后跳 # 强化 - 后跳
# imperialknight/infighter/2b340542e776818b78f3212af184bd6b # imperialknight/breaker/2b340542e776818b78f3212af184bd6b
# 8d4d2001cdb357e41633c234eb7501b5/2b340542e776818b78f3212af184bd6b # 8d4d2001cdb357e41633c234eb7501b5/2b340542e776818b78f3212af184bd6b
class Skill12(PassiveSkill): class Skill12(PassiveSkill):
""" """
@@ -379,7 +379,7 @@ class Skill12(PassiveSkill):
data1 = get_data(f'{prefix}/{uuid}', 1) data1 = get_data(f'{prefix}/{uuid}', 1)
# 跃翔 # 跃翔
# imperialknight/infighter/1fea5a626f15230237946a11a9d11582 # imperialknight/breaker/1fea5a626f15230237946a11a9d11582
# 8d4d2001cdb357e41633c234eb7501b5/1fea5a626f15230237946a11a9d11582 # 8d4d2001cdb357e41633c234eb7501b5/1fea5a626f15230237946a11a9d11582
class Skill13(ActiveSkill): class Skill13(ActiveSkill):
""" """
@@ -406,7 +406,7 @@ class Skill13(ActiveSkill):
data0 = get_data(f'{prefix}/{uuid}', 0) data0 = get_data(f'{prefix}/{uuid}', 0)
# 노 쿼터 # 노 쿼터
# imperialknight/infighter/f2fb27162beb0b87a7cb9af7900e95f2 # imperialknight/breaker/f2fb27162beb0b87a7cb9af7900e95f2
# 8d4d2001cdb357e41633c234eb7501b5/f2fb27162beb0b87a7cb9af7900e95f2 # 8d4d2001cdb357e41633c234eb7501b5/f2fb27162beb0b87a7cb9af7900e95f2
class Skill14(PassiveSkill): class Skill14(PassiveSkill):
""" """
@@ -431,7 +431,7 @@ class Skill14(PassiveSkill):
data1 = get_data(f'{prefix}/{uuid}', 1) data1 = get_data(f'{prefix}/{uuid}', 1)
# 将飞锚附加到震动箭上, 驱动迷雾能量发射。 # 将飞锚附加到震动箭上, 驱动迷雾能量发射。
# imperialknight/infighter/cfacda0647b9a0f595df2c2aad30c18d # imperialknight/breaker/cfacda0647b9a0f595df2c2aad30c18d
# 8d4d2001cdb357e41633c234eb7501b5/cfacda0647b9a0f595df2c2aad30c18d # 8d4d2001cdb357e41633c234eb7501b5/cfacda0647b9a0f595df2c2aad30c18d
class Skill15(ActiveSkill): class Skill15(ActiveSkill):
""" """
@@ -460,7 +460,7 @@ class Skill15(ActiveSkill):
# 施放技能过程中所受伤害 -90% # 施放技能过程中所受伤害 -90%
# 훅 샷 # 훅 샷
# imperialknight/infighter/2f5d03c7848effbc0a23f4df45d9ca46 # imperialknight/breaker/2f5d03c7848effbc0a23f4df45d9ca46
# 8d4d2001cdb357e41633c234eb7501b5/2f5d03c7848effbc0a23f4df45d9ca46 # 8d4d2001cdb357e41633c234eb7501b5/2f5d03c7848effbc0a23f4df45d9ca46
class Skill16(ActiveSkill): class Skill16(ActiveSkill):
""" """
@@ -497,7 +497,7 @@ class Skill16(ActiveSkill):
data2 = get_data(f'{prefix}/{uuid}', 2) data2 = get_data(f'{prefix}/{uuid}', 2)
# 어베스트 # 어베스트
# imperialknight/infighter/45442bbbe33540b4deeec29437dae70c # imperialknight/breaker/45442bbbe33540b4deeec29437dae70c
# 8d4d2001cdb357e41633c234eb7501b5/45442bbbe33540b4deeec29437dae70c # 8d4d2001cdb357e41633c234eb7501b5/45442bbbe33540b4deeec29437dae70c
class Skill17(ActiveSkill): class Skill17(ActiveSkill):
""" """
@@ -525,7 +525,7 @@ class Skill17(ActiveSkill):
data1 = get_data(f'{prefix}/{uuid}', 1) data1 = get_data(f'{prefix}/{uuid}', 1)
# 선체 파괴 # 선체 파괴
# imperialknight/infighter/01c3a2fb793d293a25ed8dc7a0d70c1a # imperialknight/breaker/01c3a2fb793d293a25ed8dc7a0d70c1a
# 8d4d2001cdb357e41633c234eb7501b5/01c3a2fb793d293a25ed8dc7a0d70c1a # 8d4d2001cdb357e41633c234eb7501b5/01c3a2fb793d293a25ed8dc7a0d70c1a
class Skill18(ActiveSkill): class Skill18(ActiveSkill):
""" """
@@ -555,7 +555,7 @@ class Skill18(ActiveSkill):
data1 = get_data(f'{prefix}/{uuid}', 1) data1 = get_data(f'{prefix}/{uuid}', 1)
# 暴击 # 暴击
# imperialknight/infighter/fc1262c19f3d0477ee8eda47b8db8696 # imperialknight/breaker/fc1262c19f3d0477ee8eda47b8db8696
# 8d4d2001cdb357e41633c234eb7501b5/fc1262c19f3d0477ee8eda47b8db8696 # 8d4d2001cdb357e41633c234eb7501b5/fc1262c19f3d0477ee8eda47b8db8696
class Skill19(PassiveSkill): class Skill19(PassiveSkill):
""" """
@@ -577,7 +577,7 @@ class Skill19(PassiveSkill):
data0 = get_data(f'{prefix}/{uuid}', 0) data0 = get_data(f'{prefix}/{uuid}', 0)
# 스위핑 스트라이크 # 스위핑 스트라이크
# imperialknight/infighter/8f73f243041c2d27739fe7696f02bf9b # imperialknight/breaker/8f73f243041c2d27739fe7696f02bf9b
# 8d4d2001cdb357e41633c234eb7501b5/8f73f243041c2d27739fe7696f02bf9b # 8d4d2001cdb357e41633c234eb7501b5/8f73f243041c2d27739fe7696f02bf9b
class Skill20(ActiveSkill): class Skill20(ActiveSkill):
""" """
@@ -612,7 +612,7 @@ class Skill20(ActiveSkill):
data3 = get_data(f'{prefix}/{uuid}', 3) data3 = get_data(f'{prefix}/{uuid}', 3)
# 백병전 전문가 # 백병전 전문가
# imperialknight/infighter/8c2379737c5acc935c1731f67f607655 # imperialknight/breaker/8c2379737c5acc935c1731f67f607655
# 8d4d2001cdb357e41633c234eb7501b5/8c2379737c5acc935c1731f67f607655 # 8d4d2001cdb357e41633c234eb7501b5/8c2379737c5acc935c1731f67f607655
class Skill21(PassiveSkill): class Skill21(PassiveSkill):
""" """
@@ -639,7 +639,7 @@ class Skill21(PassiveSkill):
data1 = get_data(f'{prefix}/{uuid}', 1) data1 = get_data(f'{prefix}/{uuid}', 1)
# 타이드 피어서 # 타이드 피어서
# imperialknight/infighter/9cb6f9ed646fa87f9b7680a42ce83d1a # imperialknight/breaker/9cb6f9ed646fa87f9b7680a42ce83d1a
# 8d4d2001cdb357e41633c234eb7501b5/9cb6f9ed646fa87f9b7680a42ce83d1a # 8d4d2001cdb357e41633c234eb7501b5/9cb6f9ed646fa87f9b7680a42ce83d1a
class Skill22(ActiveSkill): class Skill22(ActiveSkill):
""" """
@@ -670,7 +670,7 @@ class Skill22(ActiveSkill):
data1 = get_data(f'{prefix}/{uuid}', 1) data1 = get_data(f'{prefix}/{uuid}', 1)
# 딥 워터 콜 # 딥 워터 콜
# imperialknight/infighter/3829c15bf5f520c13998a3479ba0ce7b # imperialknight/breaker/3829c15bf5f520c13998a3479ba0ce7b
# 8d4d2001cdb357e41633c234eb7501b5/3829c15bf5f520c13998a3479ba0ce7b # 8d4d2001cdb357e41633c234eb7501b5/3829c15bf5f520c13998a3479ba0ce7b
class Skill23(ActiveSkill): class Skill23(ActiveSkill):
""" """
@@ -717,7 +717,7 @@ class Skill23(ActiveSkill):
data6 = get_data(f'{prefix}/{uuid}', 6) data6 = get_data(f'{prefix}/{uuid}', 6)
# 狂风可以在空中使用机枪和导弹攻击敌人。 持续时间结束、 召唤出新的狂风机器人, 或机械师死亡后, 狂风会爆炸并给予周围敌人一定伤害。 # 狂风可以在空中使用机枪和导弹攻击敌人。 持续时间结束、 召唤出新的狂风机器人, 或机械师死亡后, 狂风会爆炸并给予周围敌人一定伤害。
# imperialknight/infighter/4224f9b0b8c7c903e9a1e0f9d9f6d04d # imperialknight/breaker/4224f9b0b8c7c903e9a1e0f9d9f6d04d
# 8d4d2001cdb357e41633c234eb7501b5/4224f9b0b8c7c903e9a1e0f9d9f6d04d # 8d4d2001cdb357e41633c234eb7501b5/4224f9b0b8c7c903e9a1e0f9d9f6d04d
class Skill24(ActiveSkill): class Skill24(ActiveSkill):
""" """
@@ -771,7 +771,7 @@ class Skill24(ActiveSkill):
... ...
# 타이드 바운드 # 타이드 바운드
# imperialknight/infighter/78bd107acd474518b606be1e4fd38239 # imperialknight/breaker/78bd107acd474518b606be1e4fd38239
# 8d4d2001cdb357e41633c234eb7501b5/78bd107acd474518b606be1e4fd38239 # 8d4d2001cdb357e41633c234eb7501b5/78bd107acd474518b606be1e4fd38239
class Skill25(ActiveSkill): class Skill25(ActiveSkill):
""" """
@@ -850,7 +850,7 @@ class Skill25(ActiveSkill):
... ...
# 역조연격 # 역조연격
# imperialknight/infighter/d085127b0edd719782bd618d5688f4a1 # imperialknight/breaker/d085127b0edd719782bd618d5688f4a1
# 8d4d2001cdb357e41633c234eb7501b5/d085127b0edd719782bd618d5688f4a1 # 8d4d2001cdb357e41633c234eb7501b5/d085127b0edd719782bd618d5688f4a1
class Skill26(ActiveSkill): class Skill26(ActiveSkill):
""" """
@@ -906,7 +906,7 @@ class Skill26(ActiveSkill):
... ...
# 黑暗明王阵 # 黑暗明王阵
# imperialknight/infighter/42c82812f86ff6704ae9952a2e6093a4 # imperialknight/breaker/42c82812f86ff6704ae9952a2e6093a4
# 8d4d2001cdb357e41633c234eb7501b5/42c82812f86ff6704ae9952a2e6093a4 # 8d4d2001cdb357e41633c234eb7501b5/42c82812f86ff6704ae9952a2e6093a4
class Skill27(ActiveSkill): class Skill27(ActiveSkill):
""" """
@@ -968,7 +968,7 @@ class Skill27(ActiveSkill):
... ...
# 골칫거리 # 골칫거리
# imperialknight/infighter/3d8f3d438405d79f8d3ed68072674d1e # imperialknight/breaker/3d8f3d438405d79f8d3ed68072674d1e
# 8d4d2001cdb357e41633c234eb7501b5/3d8f3d438405d79f8d3ed68072674d1e # 8d4d2001cdb357e41633c234eb7501b5/3d8f3d438405d79f8d3ed68072674d1e
class Skill28(PassiveSkill): class Skill28(PassiveSkill):
""" """
@@ -999,7 +999,7 @@ class Skill28(PassiveSkill):
data1 = get_data(f'{prefix}/{uuid}', 1) data1 = get_data(f'{prefix}/{uuid}', 1)
# 개전의 팡파르 # 개전의 팡파르
# imperialknight/infighter/ade01c1d6afc8a05055225045e89fe49 # imperialknight/breaker/ade01c1d6afc8a05055225045e89fe49
# 8d4d2001cdb357e41633c234eb7501b5/ade01c1d6afc8a05055225045e89fe49 # 8d4d2001cdb357e41633c234eb7501b5/ade01c1d6afc8a05055225045e89fe49
class Skill29(ActiveSkill): class Skill29(ActiveSkill):
""" """
@@ -1036,7 +1036,7 @@ class Skill29(ActiveSkill):
data4 = get_data(f'{prefix}/{uuid}', 4) data4 = get_data(f'{prefix}/{uuid}', 4)
# 破冰飞刃 # 破冰飞刃
# imperialknight/infighter/ff171dc487807bb9aa28900ca9a46b41 # imperialknight/breaker/ff171dc487807bb9aa28900ca9a46b41
# 8d4d2001cdb357e41633c234eb7501b5/ff171dc487807bb9aa28900ca9a46b41 # 8d4d2001cdb357e41633c234eb7501b5/ff171dc487807bb9aa28900ca9a46b41
class Skill30(ActiveSkill): class Skill30(ActiveSkill):
""" """
@@ -1093,7 +1093,7 @@ class Skill30(ActiveSkill):
... ...
# 마엘스트롬 # 마엘스트롬
# imperialknight/infighter/547ab2b2bd860d3e37355a9cfbc1077c # imperialknight/breaker/547ab2b2bd860d3e37355a9cfbc1077c
# 8d4d2001cdb357e41633c234eb7501b5/547ab2b2bd860d3e37355a9cfbc1077c # 8d4d2001cdb357e41633c234eb7501b5/547ab2b2bd860d3e37355a9cfbc1077c
class Skill31(ActiveSkill): class Skill31(ActiveSkill):
""" """
@@ -1156,7 +1156,7 @@ class Skill31(ActiveSkill):
... ...
# 철의 노래 # 철의 노래
# imperialknight/infighter/506e7ed77d517419a6e1c437a2cedb17 # imperialknight/breaker/506e7ed77d517419a6e1c437a2cedb17
# 8d4d2001cdb357e41633c234eb7501b5/506e7ed77d517419a6e1c437a2cedb17 # 8d4d2001cdb357e41633c234eb7501b5/506e7ed77d517419a6e1c437a2cedb17
class Skill32(PassiveSkill): class Skill32(PassiveSkill):
""" """
@@ -1180,7 +1180,7 @@ class Skill32(PassiveSkill):
data0 = get_data(f'{prefix}/{uuid}', 0) data0 = get_data(f'{prefix}/{uuid}', 0)
# 冷血突刺 # 冷血突刺
# imperialknight/infighter/a5fa08f5d509e6ff2ebc68856a470b5a # imperialknight/breaker/a5fa08f5d509e6ff2ebc68856a470b5a
# 8d4d2001cdb357e41633c234eb7501b5/a5fa08f5d509e6ff2ebc68856a470b5a # 8d4d2001cdb357e41633c234eb7501b5/a5fa08f5d509e6ff2ebc68856a470b5a
class Skill33(ActiveSkill): class Skill33(ActiveSkill):
""" """
@@ -1246,7 +1246,7 @@ class Skill33(ActiveSkill):
... ...
# 교룡아 # 교룡아
# imperialknight/infighter/5dc7008b12a459325b548b0715c6b73c # imperialknight/breaker/5dc7008b12a459325b548b0715c6b73c
# 8d4d2001cdb357e41633c234eb7501b5/5dc7008b12a459325b548b0715c6b73c # 8d4d2001cdb357e41633c234eb7501b5/5dc7008b12a459325b548b0715c6b73c
class Skill34(ActiveSkill): class Skill34(ActiveSkill):
""" """
@@ -1315,7 +1315,7 @@ class Skill34(ActiveSkill):
... ...
# 씨 베인 # 씨 베인
# imperialknight/infighter/8ee0099656df08a0b39225f8a21d514b # imperialknight/breaker/8ee0099656df08a0b39225f8a21d514b
# 8d4d2001cdb357e41633c234eb7501b5/8ee0099656df08a0b39225f8a21d514b # 8d4d2001cdb357e41633c234eb7501b5/8ee0099656df08a0b39225f8a21d514b
class Skill35(ActiveSkill): class Skill35(ActiveSkill):
""" """
@@ -1356,7 +1356,7 @@ class Skill35(ActiveSkill):
data4 = get_data(f'{prefix}/{uuid}', 4) data4 = get_data(f'{prefix}/{uuid}', 4)
# 역조 # 역조
# imperialknight/infighter/9bff7f2559e003766fee2853dca00631 # imperialknight/breaker/9bff7f2559e003766fee2853dca00631
# 8d4d2001cdb357e41633c234eb7501b5/9bff7f2559e003766fee2853dca00631 # 8d4d2001cdb357e41633c234eb7501b5/9bff7f2559e003766fee2853dca00631
class Skill36(PassiveSkill): class Skill36(PassiveSkill):
""" """
@@ -1389,7 +1389,7 @@ class Skill36(PassiveSkill):
# 타이드 피어서 받는 데미지 감소율 : 30% # 타이드 피어서 받는 데미지 감소율 : 30%
# 언바운드 템페스트 # 언바운드 템페스트
# imperialknight/infighter/da6e37c1e3f0e8867f70007d89c239ff # imperialknight/breaker/da6e37c1e3f0e8867f70007d89c239ff
# 8d4d2001cdb357e41633c234eb7501b5/da6e37c1e3f0e8867f70007d89c239ff # 8d4d2001cdb357e41633c234eb7501b5/da6e37c1e3f0e8867f70007d89c239ff
class Skill37(ActiveSkill): class Skill37(ActiveSkill):
""" """
@@ -1427,7 +1427,7 @@ class Skill37(ActiveSkill):
data3 = get_data(f'{prefix}/{uuid}', 3) data3 = get_data(f'{prefix}/{uuid}', 3)
# 해신낙연 # 해신낙연
# imperialknight/infighter/a6c8f69107f8c4f5d1a0c7a57d000290 # imperialknight/breaker/a6c8f69107f8c4f5d1a0c7a57d000290
# 8d4d2001cdb357e41633c234eb7501b5/a6c8f69107f8c4f5d1a0c7a57d000290 # 8d4d2001cdb357e41633c234eb7501b5/a6c8f69107f8c4f5d1a0c7a57d000290
class Skill38(ActiveSkill): class Skill38(ActiveSkill):
""" """
@@ -1466,7 +1466,7 @@ class Skill38(ActiveSkill):
class classChange(Character): class classChange(Character):
def __init__(self, equVersion): def __init__(self, equVersion):
self.name = 'infighter' self.name = 'breaker'
self.nameCN = '鸣锋·破浪者' self.nameCN = '鸣锋·破浪者'
self.role = 'imperialknight' self.role = 'imperialknight'
self.角色 = '帝国骑士' self.角色 = '帝国骑士'
+1
View File
@@ -15,5 +15,6 @@ dependencies = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.4.0",
"ruff>=0.15.15", "ruff>=0.15.15",
] ]
+1
View File
@@ -0,0 +1 @@
"""Application services for backend API orchestration."""
+27
View File
@@ -0,0 +1,27 @@
import logging
import time
from typing import Any
from api.core.Auth import AlterState
from core.basic.character import createCharacter
logger = logging.getLogger('dcalc.calc')
def calculate(state: AlterState, config: dict[str, Any] | None) -> dict[str, Any]:
started_at = time.perf_counter()
safe_config = config or {}
character = createCharacter(state.alter, state.equVersion)
created_at = time.perf_counter()
result = character.calc(safe_config)
finished_at = time.perf_counter()
logger.info(
'calc completed alter=%s equVersion=%s create_ms=%.2f calc_ms=%.2f total_ms=%.2f',
state.alter,
state.equVersion,
(created_at - started_at) * 1000,
(finished_at - created_at) * 1000,
(finished_at - started_at) * 1000,
)
return result
+69
View File
@@ -0,0 +1,69 @@
import base64
import json
import sys
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from main import application
@pytest.fixture
def client():
return TestClient(application)
@pytest.fixture
def alter_token():
payload = {
'alter': 'MDAwMDAw',
'equVersion': '0',
'time': int(time.time() * 1000),
}
return base64.b64encode(json.dumps(payload).encode('utf-8')).decode('utf-8')
@pytest.fixture
def minimal_calc_config():
parts = [
'武器',
'副武器',
'称号',
'上衣',
'头肩',
'下装',
'腰带',
'鞋',
'手镯',
'项链',
'戒指',
'辅助装备',
'魔法石',
'耳环',
]
return {
'equips': {
part: {
'id': '',
'reinforce': 0,
'reinforceType': 0,
'enchant': '0',
'refine': 0,
'adaptation': 0,
}
for part in parts
},
'oaths': {},
'emblems': {},
'skills': {},
'avatar': {},
'sundry': {},
'jades': {},
'options': {},
'DSB': False,
'BUFF': False,
}
@@ -0,0 +1,19 @@
def test_calc_response_contract(client, alter_token, minimal_calc_config):
response = client.post(
'/api/calc',
headers={'alter-token': alter_token},
json=minimal_calc_config,
)
assert response.status_code == 200
payload = response.json()
assert payload['code'] == 200
assert payload['message'] == 'Success'
assert isinstance(payload['data'], dict)
data = payload['data']
assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(data)
assert isinstance(data['uuid'], str)
assert isinstance(data['skills'], list)
assert isinstance(data['info'], list)
assert isinstance(data['buffer'], bool)
+13
View File
@@ -0,0 +1,13 @@
from core.basic.character import createCharacter
def test_character_calc_returns_result_shape(minimal_calc_config):
character = createCharacter('GF.swordman_male.weapon_master', '0')
result = character.calc(minimal_calc_config)
assert isinstance(result, dict)
assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(result)
assert isinstance(result['skills'], list)
assert isinstance(result['info'], list)
assert result['buffer'] is False
@@ -0,0 +1,27 @@
from core.basic.character.config_view import CalcConfigView
def test_config_view_provides_defaults():
view = CalcConfigView(None)
assert view.equips == {}
assert view.oaths == {}
assert view.emblems == {}
assert view.skills == {}
assert view.avatar == {}
assert view.sundry == {}
assert view.jades == {}
assert view.options == {}
assert view.dsb is False
assert view.buff is False
def test_config_view_preserves_legacy_keys():
view = CalcConfigView({'skills': {'100': {'lv': 1}}, 'BUFF': True})
data = view.as_legacy_dict()
assert data['skills'] == {'100': {'lv': 1}}
assert data['BUFF'] is True
assert data['DSB'] is False
assert data['equips'] == {}
+22
View File
@@ -0,0 +1,22 @@
import logging
from api.core.Auth import AlterState
from services.calc_service import calculate
def test_calculate_returns_character_calc_result(minimal_calc_config):
state = AlterState('GF.swordman_male.weapon_master', 'token', {}, '0', 0)
result = calculate(state, minimal_calc_config)
assert isinstance(result, dict)
assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(result)
def test_calculate_logs_timing(caplog, minimal_calc_config):
state = AlterState('GF.swordman_male.weapon_master', 'token', {}, '0', 0)
with caplog.at_level(logging.INFO, logger='dcalc.calc'):
calculate(state, minimal_calc_config)
assert any('calc completed alter=GF.swordman_male.weapon_master' in record.message for record in caplog.records)
+5
View File
@@ -0,0 +1,5 @@
from config.main import Config
def test_config_ignores_extra_environment_fields():
assert Config.model_config.get('extra') == 'ignore'
+35
View File
@@ -0,0 +1,35 @@
from core.basic.character.effect_apply import execute_effect, uppercase_attrs
class Source:
STR = 10
def __init__(self):
self.INT = 20
self.name = 'ignored'
class Effects:
def execture(self, key):
if key != 'known':
return None
def effect(target):
target.append(key)
return effect
def test_uppercase_attrs_returns_only_uppercase_instance_keys():
assert uppercase_attrs(Source()) == {'INT': 20}
def test_execute_effect_returns_false_for_missing_effect():
assert execute_effect(Effects(), 'missing', []) is False
def test_execute_effect_calls_existing_effect():
target = []
assert execute_effect(Effects(), 'known', target) is True
assert target == ['known']
@@ -0,0 +1,30 @@
from core.basic.equipment import get_equipment
from core.basic.equipment_registry import EquipmentRegistry
def test_get_equipment_returns_cached_default_version():
first = get_equipment('0')
second = get_equipment('0')
assert first is second
assert first.version == '0'
def test_get_equipment_falls_back_to_default_version():
equipment = get_equipment('missing-version')
assert equipment.version == '0'
def test_equipment_registry_caches_by_version():
calls = []
class Equipment:
def __init__(self, version):
calls.append(version)
self.version = version
registry = EquipmentRegistry(Equipment)
assert registry.get('0') is registry.get('0')
assert calls == ['0']
+41 -1
View File
@@ -546,6 +546,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -560,7 +561,10 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.15" }] dev = [
{ name = "pytest", specifier = ">=8.4.0" },
{ name = "ruff", specifier = ">=0.15.15" },
]
[[package]] [[package]]
name = "detect-installer" name = "detect-installer"
@@ -1151,6 +1155,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "itsdangerous" name = "itsdangerous"
version = "2.2.0" version = "2.2.0"
@@ -1600,6 +1613,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
] ]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "propcache" name = "propcache"
version = "0.4.1" version = "0.4.1"
@@ -1944,6 +1966,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
] ]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.1.0" version = "1.1.0"
@@ -0,0 +1,237 @@
# Backend 渐进式优化设计
> 本文面向 dcalc backend 的渐进式架构优化。目标是在不改变 `/calc` API 契约、不破坏 frontendNew 计算链路的前提下,逐步提升后端可维护性、扩展性、计算效率与开发规范。
## 背景
当前仓库前端迁移主线是 `frontend -> frontendNew`,backend 必须优先服务 Dcalc Character 模块的稳定计算链路:equipment、skill、forge、result。backend 现有结构已经完成过一次 `core/basic/character_old.py` 到 `core/basic/character/` 多文件 mixin 的拆分,说明当前更适合继续做小步收口,而不是推倒重写。
关键入口:
- `/calc` 路由:`backend/api/routes/calc.py`
- 应用入口:`backend/main.py`
- 角色计算核心:`backend/core/basic/character/`
- 装备数据装载:`backend/core/basic/equipment.py`
- 数据库连接:`backend/database/connect.py`
- Redis 预热:`backend/api/core/RedisPreheat.py`
## 非目标
- 不改 `/calc` 的请求语义、响应包装、`data` 主体字段结构。
- 不改 frontendNew 依赖的 IResult 数据消费方式。
- 不重写 Skill、Forge、装备效果、职业技能的核心业务逻辑。
- 不在 backend 优化中引入新的 UI、前端状态管理或前端接口契约调整。
- 不一次性格式化全仓 Python 文件,避免制造大规模无关 diff。
## 现状问题
### API 与计算耦合
`backend/api/routes/calc.py` 直接 `createCharacter(...).calc(config)`。这让鉴权、请求适配、计算编排、错误处理和未来耗时统计都挤在 API 调用路径上,不利于扩展。
### config 读取分散
计算过程中大量直接访问 `setInfo.get(...)`、`info['equips']`、`info['oaths']`。默认值、兼容逻辑和输入容错散落在 mixin 中,后续 Skill/Forge 配置演进时容易漏字段。
### 装备数据全局缓存边界不清
`backend/core/basic/equipment.py` 启动时构造 `equ0 = Equipments('0')`,`get_equipment()` 通过动态变量和 `eval` 获取版本。该方式对当前性能有帮助,但多版本扩展、测试隔离、热更新和错误定位较弱。
### 计算对象状态可变
`Character` 实例在 `calc_init -> calc_* -> calc_*_skills` 过程中持续变异属性、技能等级、装备效果和输出类型。该模式符合当前业务写法,但缺少明确的一次性计算上下文,测试和复用成本偏高。
### 性能缺少量化
已有内存化装备数据、技能类缓存、Redis 预热等优化点,但 `/calc` 的耗时瓶颈没有分段统计。后续不应凭感觉优化。
### 测试安全网不足
当前仅看到 `backend/test.py`,没有正式 `tests/` 体系。后续重构计算链路时,缺少 golden case 来确认字段与数值没有回退。
## 优化原则
1. 接口冻结:先用测试固定 `/calc` 外部行为,再改内部结构。
2. 小步可回滚:每个阶段能单独合并、单独验证。
3. 先边界后算法:优先整理 service、config view、repository/registry,再考虑公式与缓存。
4. 只搬运不改义:拆文件、封 helper 时不改变公式含义。
5. 先量化后性能:先加耗时指标,再按热点优化。
## 目标架构
```text
backend/
api/
routes/
calc.py # 只负责 HTTP 层:依赖注入、Body、response
services/
calc_service.py # /calc 应用服务:创建角色、执行计算、计时、错误上下文
schemas/
calc.py # 宽松 config schema 与响应类型别名
response.py # 通用响应类型,保持旧格式兼容
core/
basic/
character/
config_view.py # CalcConfigView:统一默认值、兼容旧 config
effect_apply.py # 统一属性提取与 effect 执行 helper
equipment.py # 保留现有模型,逐步委托给 registry
equipment_registry.py # EquipmentRegistry:装备版本懒加载与缓存
tests/
contract/
test_calc_contract.py
golden/
test_calc_golden.py
unit/
test_equipment_registry.py
test_calc_config_view.py
```
## 数据流
```text
HTTP POST /api/calc
-> AltersDep 解析 alter/equVersion
-> calc_service.calculate(state, raw_config)
-> CalcConfigView.from_raw(raw_config)
-> createCharacter(state.alter, state.equVersion)
-> character.calc(config_view 或兼容 dict)
-> calc_service 返回原始计算 data
-> response(data=data)
```
短期内 `Character.calc()` 仍接收原 `dict`,`CalcConfigView` 先作为内部读取辅助逐步接入。等测试稳定后,再考虑让 `Character.calc()` 接收 `CalcConfigView`。
## 分阶段设计
### Phase 0:测试安全网
建立 pytest 测试结构,固定 `/calc` 契约和典型职业结果结构。
重点:
- 增加 `pytest` 开发依赖。
- 增加可复用的最小 config fixture。
- 增加 `/api/calc` HTTP contract 测试。
- 增加 `createCharacter(...).calc(...)` golden smoke 测试。
成功标准:
- `uv run pytest` 可执行。
- `/calc` 响应仍包含 `code/message/data`。
- `data` 至少包含 `uuid/skills/info/suits/buffer`。
### Phase 1:API service 边界
新增 `backend/services/calc_service.py`,API route 不再直接编排计算。
重点:
- route 保持原路径、请求体和响应格式。
- service 负责 `createCharacter`、空 config 兜底、计算执行。
- 为后续日志、耗时、缓存预留 service 层。
成功标准:
- `/api/calc` 行为不变。
- route 文件只保留 HTTP 层职责。
- Phase 0 测试全部通过。
### Phase 2:config view 与计算输入收口
新增 `CalcConfigView`,集中处理 config 的默认值与字段读取。
重点:
- `equips/oaths/emblems/skills/avatar/sundry/jades/options` 都有稳定默认值。
- 先在 `calc_init()`、`SetDetail()`、`SetSkills()` 等入口使用,不一次性替换所有 mixin。
- 保留向后兼容,仍支持原始 dict。
成功标准:
- 缺少可选字段时不抛出 KeyError。
- Skill/Forge 配置链路不变:config 变化 -> result.execute -> saveConfig。
- golden case 结果字段不变。
### Phase 3:装备数据 registry
新增 `EquipmentRegistry`,替换 `get_equipment()` 内部的 `eval` 与模块全局变量访问。
重点:
- `get_equipment(version)` 作为兼容门面继续存在。
- registry 内部使用 `functools.cache` 或显式 dict 缓存。
- 版本不存在时保持回退 `0` 的现有行为,但记录原因。
成功标准:
- 现有调用方无需一次性改动。
- 多版本装备数据加载路径清晰。
- 单元测试覆盖缓存、回退和不存在版本。
### Phase 4:计算核心 helper 化
抽取重复且无业务争议的 helper,降低 mixin 复杂度。
重点:
- `apply_uppercase_attrs(character, source)` 统一处理首字母大写属性应用。
- `execute_effect(effects, key, *args)` 统一处理效果函数不存在时的行为。
- 优先处理 `calc_suits/calc_equs/calc_oaths/calc_emblems` 中重复逻辑。
成功标准:
- helper 有单元测试。
- 业务公式位置不变或仅做等价搬迁。
- `calc_equip.py` 可读性提升,结果不变。
### Phase 5:性能观测与热点优化
在 service 层引入轻量计时,再针对热点做优化。
重点:
- 记录 `createCharacter`、`calc` 总耗时。
- 可选记录 `calc_*` 分段耗时,默认不影响响应结构。
- 优化重复 `获取基础属性()` 调用。
- 评估技能实例模板缓存,但必须避免可变技能状态污染请求。
成功标准:
- 能拿到典型配置的平均耗时和 p95。
- 优化前后 golden case 一致。
- 性能优化有数据支撑。
### Phase 6:规范与文档
清理运行缓存、废弃文件和文档入口。
重点:
- 确认 `.venv/.ruff_cache/__pycache__/.DS_Store` 不进入 git。
- 评估 `character_old.py` 移到归档或删除。
- 补充 backend README:运行、测试、质量检查、目录职责。
- 不在本阶段大规模格式化老文件。
成功标准:
- 新人可通过文档理解 backend 入口和测试方式。
- 废弃代码不会继续误导维护。
- 规范变更不产生大规模业务 diff。
## 风险与缓解
| 风险 | 缓解 |
| --- | --- |
| 重构导致 `/calc` 字段变化 | Phase 0 先做 contract/golden 测试 |
| 技能对象可变状态污染缓存 | 技能模板缓存必须后置,且只缓存类或不可变模板 |
| 装备版本回退行为改变 | `get_equipment()` 保留兼容门面,并测试回退 |
| config 字段默认值与前端不一致 | `CalcConfigView` 先只集中现有默认值,不新增语义 |
| 性能统计污染响应 | 默认只写日志,不改变 API data |
## 验收总标准
- `uv run pytest` 通过。
- `/api/calc` 响应契约不变。
- frontendNew 的 result.execute 与 saveConfig 链路无需调整。
- 典型职业 golden case 在重构前后字段一致,关键数值在允许误差内。
- backend 文档说明清晰,后续任务可以分阶段执行。
@@ -0,0 +1,908 @@
# Backend 渐进式优化执行清单
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在保持 `/calc` API 契约和 frontendNew 计算链路不变的前提下,逐步提升 backend 的可维护性、扩展性、计算效率与开发规范。
**Architecture:** 先建立测试安全网,再把 HTTP route 与计算编排拆到 service 层,随后用 config view 和 equipment registry 收口隐式边界。最后基于耗时观测做性能优化,并补齐 backend 文档与规范。
**Tech Stack:** Python 3.10+、FastAPI、Pydantic、SQLAlchemy、Redis、SQLite、pytest、ruff、uv。
---
## 文件结构规划
- Create: `backend/tests/conftest.py`
提供测试用 FastAPI client、alter token、最小 calc config fixture。
- Create: `backend/tests/contract/test_calc_contract.py`
固定 `/api/calc` HTTP 响应契约。
- Create: `backend/tests/golden/test_calc_golden.py`
固定典型职业计算结果结构,后续可扩展数值快照。
- Create: `backend/services/__init__.py`
标记 service 包。
- Create: `backend/services/calc_service.py`
承接 `/calc` 应用服务编排。
- Create: `backend/core/basic/character/config_view.py`
集中处理 calc config 默认值与读取。
- Create: `backend/core/basic/equipment_registry.py`
装备版本 registry 与缓存。
- Modify: `backend/api/routes/calc.py`
改为调用 `calc_service.calculate()`,保持 route 契约不变。
- Modify: `backend/core/basic/character/calc_equip.py`
逐步使用 `CalcConfigView` 与 helper,不改计算语义。
- Modify: `backend/core/basic/equipment.py`
`get_equipment()` 委托 `EquipmentRegistry`,保留兼容函数。
- Modify: `backend/pyproject.toml`
增加 `pytest`、必要时增加测试配置。
- Create or Modify: `backend/README.md`
说明运行、测试、质量检查、目录职责。
## Task 1:建立 pytest 基础与最小 contract 测试
**Files:**
- Modify: `backend/pyproject.toml`
- Create: `backend/tests/conftest.py`
- Create: `backend/tests/contract/test_calc_contract.py`
- [ ] **Step 1: 添加 pytest 开发依赖**
在 `backend/pyproject.toml` 的 `[dependency-groups].dev` 中加入:
```toml
[dependency-groups]
dev = [
"pytest>=8.4.0",
"ruff>=0.15.15",
]
```
- [ ] **Step 2: 同步依赖**
Run:
```bash
cd backend && uv sync
```
Expected: `pytest` 安装完成,`uv.lock` 更新。
- [ ] **Step 3: 创建测试 fixture**
Create `backend/tests/conftest.py`:
```python
import base64
import json
import time
import pytest
from fastapi.testclient import TestClient
from main import application
@pytest.fixture
def client():
return TestClient(application)
@pytest.fixture
def alter_token():
payload = {
'alter': '1.1.1',
'equVersion': '0',
'time': int(time.time() * 1000),
}
return base64.b64encode(json.dumps(payload).encode('utf-8')).decode('utf-8')
@pytest.fixture
def minimal_calc_config():
parts = [
'武器',
'副武器',
'称号',
'上衣',
'头肩',
'下装',
'腰带',
'鞋',
'手镯',
'项链',
'戒指',
'辅助装备',
'魔法石',
'耳环',
]
return {
'equips': {
part: {
'id': '',
'reinforce': 0,
'reinforceType': 0,
'enchant': '0',
'refine': 0,
'adaptation': 0,
}
for part in parts
},
'oaths': {},
'emblems': {},
'skills': {},
'avatar': {},
'sundry': {},
'jades': {},
'options': {},
'DSB': False,
'BUFF': False,
}
```
- [ ] **Step 4: 创建 contract 测试**
Create `backend/tests/contract/test_calc_contract.py`:
```python
def test_calc_response_contract(client, alter_token, minimal_calc_config):
response = client.post(
'/api/calc',
headers={'alter-token': alter_token},
json=minimal_calc_config,
)
assert response.status_code == 200
payload = response.json()
assert payload['code'] == 200
assert payload['message'] == 'Success'
assert isinstance(payload['data'], dict)
data = payload['data']
assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(data)
assert isinstance(data['uuid'], str)
assert isinstance(data['skills'], list)
assert isinstance(data['info'], list)
assert isinstance(data['buffer'], bool)
```
- [ ] **Step 5: 运行测试,记录真实失败**
Run:
```bash
cd backend && uv run pytest backend/tests/contract/test_calc_contract.py -v
```
Expected: 如果 Redis 依赖导致失败,记录失败原因;不要绕过 `/calc` 鉴权契约。后续可在 fixture 中 monkeypatch `api.dp.get_redis_info` 或提供本地 Redis。
- [ ] **Step 6: 提交本任务**
Run:
```bash
git add backend/pyproject.toml backend/uv.lock backend/tests/conftest.py backend/tests/contract/test_calc_contract.py
git commit -m "test: add backend calc contract coverage"
```
## Task 2:增加 core golden smoke 测试
**Files:**
- Create: `backend/tests/golden/test_calc_golden.py`
- [ ] **Step 1: 创建核心计算 smoke 测试**
Create `backend/tests/golden/test_calc_golden.py`:
```python
from core.basic.character import createCharacter
def test_character_calc_returns_result_shape(minimal_calc_config):
character = createCharacter('1.1.1', '0')
result = character.calc(minimal_calc_config)
assert isinstance(result, dict)
assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(result)
assert isinstance(result['skills'], list)
assert isinstance(result['info'], list)
assert result['buffer'] is False
```
- [ ] **Step 2: 运行 golden smoke**
Run:
```bash
cd backend && uv run pytest backend/tests/golden/test_calc_golden.py -v
```
Expected: PASS。若职业 `1.1.1` 不存在,改用 `core.character.adventure` 中第一个 `open=True` 的职业,并同步更新 Task 1 fixture。
- [ ] **Step 3: 运行全部测试**
Run:
```bash
cd backend && uv run pytest -v
```
Expected: PASS,或仅 contract 测试因外部 Redis 环境失败且原因明确。
- [ ] **Step 4: 提交本任务**
Run:
```bash
git add backend/tests/golden/test_calc_golden.py
git commit -m "test: add backend calc golden smoke"
```
## Task 3:引入 calc service,瘦身 API route
**Files:**
- Create: `backend/services/__init__.py`
- Create: `backend/services/calc_service.py`
- Modify: `backend/api/routes/calc.py`
- Test: `backend/tests/contract/test_calc_contract.py`
- Test: `backend/tests/golden/test_calc_golden.py`
- [ ] **Step 1: 创建 service 包**
Create `backend/services/__init__.py`:
```python
"""Application services for backend API orchestration."""
```
- [ ] **Step 2: 创建 calc service**
Create `backend/services/calc_service.py`:
```python
from typing import Any
from api.core.Auth import AlterState
from core.basic.character import createCharacter
def calculate(state: AlterState, config: dict[str, Any] | None) -> dict[str, Any]:
safe_config = config or {}
character = createCharacter(state.alter, state.equVersion)
return character.calc(safe_config)
```
- [ ] **Step 3: 修改 route 调用 service**
Modify `backend/api/routes/calc.py`:
```python
from api.core.Gzip import register_gzip_request
from fastapi import APIRouter, Body
from api.core.Response import response
from api.dp import AltersDep
from services.calc_service import calculate
router = APIRouter()
register_gzip_request(router)
@router.post('/calc')
async def calc(state: AltersDep, config=Body(None)):
return response(data=calculate(state, config))
```
- [ ] **Step 4: 运行测试**
Run:
```bash
cd backend && uv run pytest backend/tests/contract/test_calc_contract.py backend/tests/golden/test_calc_golden.py -v
```
Expected: PASS。
- [ ] **Step 5: 提交本任务**
Run:
```bash
git add backend/services/__init__.py backend/services/calc_service.py backend/api/routes/calc.py
git commit -m "refactor: route calc through service layer"
```
## Task 4:增加 CalcConfigView,集中 config 默认值
**Files:**
- Create: `backend/core/basic/character/config_view.py`
- Modify: `backend/core/basic/character/calc_equip.py`
- Test: `backend/tests/unit/test_calc_config_view.py`
- [ ] **Step 1: 创建 config view**
Create `backend/core/basic/character/config_view.py`:
```python
from collections.abc import Mapping
from typing import Any
class CalcConfigView:
def __init__(self, raw: Mapping[str, Any] | None):
self.raw = dict(raw or {})
@property
def equips(self) -> dict[str, dict[str, Any]]:
return dict(self.raw.get('equips') or {})
@property
def oaths(self) -> dict[str, dict[str, Any]]:
return dict(self.raw.get('oaths') or {})
@property
def emblems(self) -> dict[str, dict[str, Any]]:
return dict(self.raw.get('emblems') or {})
@property
def skills(self) -> dict[str, dict[str, Any]]:
return dict(self.raw.get('skills') or {})
@property
def avatar(self) -> dict[str, Any]:
return dict(self.raw.get('avatar') or {})
@property
def sundry(self) -> dict[str, Any]:
return dict(self.raw.get('sundry') or {})
@property
def jades(self) -> dict[str, Any]:
return dict(self.raw.get('jades') or {})
@property
def options(self) -> dict[str, Any]:
return dict(self.raw.get('options') or {})
@property
def dsb(self) -> bool:
return bool(self.raw.get('DSB', False))
@property
def buff(self) -> bool:
return bool(self.raw.get('BUFF', False))
def as_legacy_dict(self) -> dict[str, Any]:
data = dict(self.raw)
data.setdefault('equips', {})
data.setdefault('oaths', {})
data.setdefault('emblems', {})
data.setdefault('skills', {})
data.setdefault('avatar', {})
data.setdefault('sundry', {})
data.setdefault('jades', {})
data.setdefault('options', {})
data.setdefault('DSB', False)
data.setdefault('BUFF', False)
return data
```
- [ ] **Step 2: 添加单元测试**
Create `backend/tests/unit/test_calc_config_view.py`:
```python
from core.basic.character.config_view import CalcConfigView
def test_config_view_provides_defaults():
view = CalcConfigView(None)
assert view.equips == {}
assert view.oaths == {}
assert view.emblems == {}
assert view.skills == {}
assert view.avatar == {}
assert view.sundry == {}
assert view.jades == {}
assert view.options == {}
assert view.dsb is False
assert view.buff is False
def test_config_view_preserves_legacy_keys():
view = CalcConfigView({'skills': {'100': {'lv': 1}}, 'BUFF': True})
data = view.as_legacy_dict()
assert data['skills'] == {'100': {'lv': 1}}
assert data['BUFF'] is True
assert data['DSB'] is False
assert data['equips'] == {}
```
- [ ] **Step 3: 在 calc_init 入口使用兼容 dict**
Modify `backend/core/basic/character/calc_equip.py`:
```python
from .config_view import CalcConfigView
```
在 `calc()` 起始处改为:
```python
def calc(self, setInfo: dict[str, dict]):
config = CalcConfigView(setInfo).as_legacy_dict()
self.calc_init(config)
self.SetBaseStatus()
self.calc_jade(config.get('jades', []))
self.calc_avatar(config.get('avatar', {}))
self.calc_basic()
self.calc_sundry(config.get('sundry', {}))
self.calc_emblems()
suit = self.calc_suits()
self.calc_equs()
self.calc_oaths()
skillInfos = []
if self.buffer:
skillInfos = self.calc_buffer_skills()
info = self.get_buffer_info()
else:
if len(self.输出类型选项) > 1:
temp = self.输出类型选项[0]
if '固伤' in temp:
if self.INT > self.STR:
self.输出类型 = '魔法固伤'
else:
self.输出类型 = '物理固伤'
else:
if self.INT > self.STR and self.AtkM > self.AtkP:
self.输出类型 = '魔法百分比'
else:
self.输出类型 = '物理百分比'
skillInfos = self.calc_carry_skills(config.get('DSB', False), config.get('BUFF', False))
info = self.get_carry_info()
return {'uuid': uuid1().hex, 'skills': skillInfos, 'info': info, 'suits': suit, 'buffer': self.buffer}
```
- [ ] **Step 4: 运行测试**
Run:
```bash
cd backend && uv run pytest backend/tests/unit/test_calc_config_view.py backend/tests/golden/test_calc_golden.py -v
```
Expected: PASS。
- [ ] **Step 5: 提交本任务**
Run:
```bash
git add backend/core/basic/character/config_view.py backend/core/basic/character/calc_equip.py backend/tests/unit/test_calc_config_view.py
git commit -m "refactor: centralize calc config defaults"
```
## Task 5:引入 EquipmentRegistry,替换 eval 获取装备数据
**Files:**
- Create: `backend/core/basic/equipment_registry.py`
- Modify: `backend/core/basic/equipment.py`
- Test: `backend/tests/unit/test_equipment_registry.py`
- [ ] **Step 1: 创建 registry**
Create `backend/core/basic/equipment_registry.py`:
```python
from functools import cache
class EquipmentRegistry:
def __init__(self, equipment_cls):
self.equipment_cls = equipment_cls
@cache
def get(self, version: str = '0'):
normalized = version or '0'
try:
return self.equipment_cls(normalized)
except Exception:
if normalized == '0':
raise
return self.get('0')
```
- [ ] **Step 2: 修改 equipment.py 兼容门面**
Modify `backend/core/basic/equipment.py` 底部:
```python
from core.basic.equipment_registry import EquipmentRegistry
_equipment_registry = EquipmentRegistry(Equipments)
def get_equipment(version: str = '0') -> Equipments:
return _equipment_registry.get(version)
```
删除或停止使用:
```python
global equ0
equ0 = Equipments('0')
```
以及 `eval(f'equ{version}'.format(version))` 相关逻辑。
- [ ] **Step 3: 添加 registry 测试**
Create `backend/tests/unit/test_equipment_registry.py`:
```python
from core.basic.equipment import get_equipment
def test_get_equipment_returns_cached_default_version():
first = get_equipment('0')
second = get_equipment('0')
assert first is second
assert first.version == '0'
def test_get_equipment_falls_back_to_default_version():
equipment = get_equipment('missing-version')
assert equipment.version == '0'
```
- [ ] **Step 4: 运行测试**
Run:
```bash
cd backend && uv run pytest backend/tests/unit/test_equipment_registry.py backend/tests/golden/test_calc_golden.py -v
```
Expected: PASS。
- [ ] **Step 5: 提交本任务**
Run:
```bash
git add backend/core/basic/equipment_registry.py backend/core/basic/equipment.py backend/tests/unit/test_equipment_registry.py
git commit -m "refactor: add equipment registry"
```
## Task 6:增加 calc service 耗时观测
**Files:**
- Modify: `backend/services/calc_service.py`
- Test: `backend/tests/contract/test_calc_contract.py`
- [ ] **Step 1: 添加轻量日志计时**
Modify `backend/services/calc_service.py`:
```python
import logging
import time
from typing import Any
from api.core.Auth import AlterState
from core.basic.character import createCharacter
logger = logging.getLogger('dcalc.calc')
def calculate(state: AlterState, config: dict[str, Any] | None) -> dict[str, Any]:
started_at = time.perf_counter()
safe_config = config or {}
character = createCharacter(state.alter, state.equVersion)
created_at = time.perf_counter()
result = character.calc(safe_config)
finished_at = time.perf_counter()
logger.info(
'calc completed alter=%s equVersion=%s create_ms=%.2f calc_ms=%.2f total_ms=%.2f',
state.alter,
state.equVersion,
(created_at - started_at) * 1000,
(finished_at - created_at) * 1000,
(finished_at - started_at) * 1000,
)
return result
```
- [ ] **Step 2: 运行 contract 测试**
Run:
```bash
cd backend && uv run pytest backend/tests/contract/test_calc_contract.py -v
```
Expected: PASS。响应 data 不新增耗时字段。
- [ ] **Step 3: 手动观察日志**
Run:
```bash
cd backend && uv run python main.py
```
Expected: 服务正常启动。发起 `/api/calc` 后可在日志处理器中接入 `dcalc.calc` logger;本任务不要求改变线上日志格式。
- [ ] **Step 4: 提交本任务**
Run:
```bash
git add backend/services/calc_service.py
git commit -m "chore: add calc timing logs"
```
## Task 7:抽取 effect 应用 helper
**Files:**
- Create: `backend/core/basic/character/effect_apply.py`
- Modify: `backend/core/basic/character/calc_equip.py`
- Test: `backend/tests/unit/test_effect_apply.py`
- Test: `backend/tests/golden/test_calc_golden.py`
- [ ] **Step 1: 创建 helper**
Create `backend/core/basic/character/effect_apply.py`:
```python
from typing import Any
def uppercase_attrs(source: Any) -> dict[str, Any]:
return {
key: value
for key, value in source.__dict__.items()
if key and key[0].isupper()
}
def apply_uppercase_attrs(character: Any, source: Any) -> None:
character.SetStatus(**uppercase_attrs(source))
def execute_effect(effects: Any, key: str, *args: Any) -> bool:
fun = effects.execture(key)
if fun is None:
return False
fun(*args)
return True
```
- [ ] **Step 2: 添加 helper 测试**
Create `backend/tests/unit/test_effect_apply.py`:
```python
from core.basic.character.effect_apply import uppercase_attrs
class Source:
STR = 10
def __init__(self):
self.INT = 20
self.name = 'ignored'
def test_uppercase_attrs_returns_only_uppercase_keys():
assert uppercase_attrs(Source()) == {'INT': 20}
```
- [ ] **Step 3: 在 calc_equip.py 中逐步替换重复代码**
Modify selected occurrences in `backend/core/basic/character/calc_equip.py`:
```python
from .effect_apply import apply_uppercase_attrs, execute_effect, uppercase_attrs
```
将简单重复片段:
```python
filtered_dict = {k: v for k, v in suit.__dict__.items() if k[0].isupper()}
self.SetStatus(**filtered_dict)
```
替换为:
```python
apply_uppercase_attrs(self, suit)
```
将:
```python
fun = effects.execture(f'equ_{equ.id}')
if fun is None:
continue
filtered_dict = {k: v for k, v in equ.__dict__.items() if k[0].isupper()}
self.SetStatus(**filtered_dict)
fun(self)
```
替换为:
```python
fun = effects.execture(f'equ_{equ.id}')
if fun is None:
continue
apply_uppercase_attrs(self, equ)
fun(self)
```
- [ ] **Step 4: 运行测试**
Run:
```bash
cd backend && uv run pytest backend/tests/unit/test_effect_apply.py backend/tests/golden/test_calc_golden.py -v
```
Expected: PASS。
- [ ] **Step 5: 提交本任务**
Run:
```bash
git add backend/core/basic/character/effect_apply.py backend/core/basic/character/calc_equip.py backend/tests/unit/test_effect_apply.py
git commit -m "refactor: extract character effect helpers"
```
## Task 8:补充 backend README
**Files:**
- Create or Modify: `backend/README.md`
- [ ] **Step 1: 编写 backend README**
Create or Modify `backend/README.md`:
````markdown
# dcalc backend
## Run
```bash
uv sync
python main.py
```
## Test
```bash
uv run pytest -v
```
## Quality
```bash
uv run ruff check .
```
## Runtime Entry
- `main.py`: FastAPI application and uvicorn entry.
- `api/routes/calc.py`: `/api/calc` HTTP route.
- `services/calc_service.py`: calculation orchestration.
- `core/basic/character/`: character calculation implementation.
- `core/basic/equipment.py`: equipment data models and compatibility access.
- `core/basic/equipment_registry.py`: equipment version registry and cache.
- `database/connect.py`: SQLite and Redis connections.
## Compatibility Rules
- Do not change `/calc` request and response contract without frontend migration approval.
- Do not change frontendNew result data shape during backend refactors.
- Keep Skill and Forge business formulas behavior-compatible unless a dedicated formula task says otherwise.
````
- [ ] **Step 2: 运行文档相关检查**
Run:
```bash
cd backend && uv run ruff check .
```
Expected: PASS,或只出现既有遗留问题。若有既有问题,不在本任务做大范围修复。
- [ ] **Step 3: 提交本任务**
Run:
```bash
git add backend/README.md
git commit -m "docs: document backend runtime structure"
```
## Task 9:阶段性全量验证
**Files:**
- No source changes expected.
- [ ] **Step 1: 运行测试**
Run:
```bash
cd backend && uv run pytest -v
```
Expected: PASS。
- [ ] **Step 2: 运行 lint**
Run:
```bash
cd backend && uv run ruff check .
```
Expected: PASS,或输出仅包含已登记的遗留问题。
- [ ] **Step 3: 启动服务**
Run:
```bash
cd backend && uv run python main.py
```
Expected: 输出 `Application startup complete.`,服务监听 `config.PORT`。
- [ ] **Step 4: 手动验证 frontendNew 链路**
Run:
```bash
cd frontendNew && pnpm dev
```
Expected: 在 Character 模块修改 Skill/Forge 配置后,仍满足 `config 变化 -> result.execute -> saveConfig`。
- [ ] **Step 5: 提交验证记录**
Run:
```bash
git status --short
```
Expected: 工作区干净,所有阶段任务已独立提交。
+1 -1
Submodule images updated: 0991b6efa4...051a7cde33