Files
dcalc/backend/core/character/refs/base.py
T

168 lines
8.3 KiB
Python

"""职业技能引用公共类型。
运行 scripts/generate_skill_refs.py 生成引用,追加 --check 只检查产物一致性。
公共技能在生成器的 COMMON_SKILLS、AWAKENING_SKILLS、COMBO_SKILLS 中维护,
统一生成到 common.py。职业类按实际技能树继承基础组、觉醒变体组、可选连招组,
只声明职业专属引用。基础组要求全部职业覆盖;觉醒组保留原始 code 的 1~5 后缀,
觉醒之心保留其名称差异;连招组排除创造者、黑暗武士,不暴露不存在的引用。
清单变动需校验所有职业的 code、名称及分组覆盖。
使用:from core.character.refs.extra.creator import CreatorSkillRef as Ref
Ref.BACKSTEP 为当前角色的公共技能;Ref.CREATESPACE 为创造者专属技能。
Ref.members() 包含继承成员;Ref.from_code(code) 按原始 code 查找,缺失抛 KeyError。
SkillRef 与引用命名空间均只读;公共引用的 profession 为 None,职业引用带职业范围。
技能类通过 ref = Ref.XXX 绑定身份,Skill.code 从 ref 派生。角色初始化建立当前角色的
引用/code 索引;SkillEffect、Read、level_source 及 GetSkill 已统一解析引用。
字符串兼容旧显示名称,按 code 查询必须显式调用 GetSkillByCode,避免含义混淆。
公共引用绑定当前角色实例,职业引用额外校验范围;名称只用于提示,不参与身份比较。
计算器独有技能的扩展声明应单独维护,不手改自动生成的职业文件。
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
import re
from types import MappingProxyType
from typing import ClassVar
@dataclass(frozen=True, slots=True)
class SkillRef:
"""不可变技能身份,由职业范围和原始 code 共同确定。
Args:
code: 技能树原始 code,保留连字符等原始字符。
name: 中文提示名称,仅展示,不参与身份绑定。
profession: 职业标识,例如 extra.creator;None 表示当前角色的公共技能。
"""
code: str
"""技能树原始 code,不受 Python 成员名规范化影响。"""
profession: str | None = None
"""所属职业;None 表示公共引用,须在解析时绑定当前角色。"""
name: str = field(default='', compare=False)
"""中文提示名称,仅用于编辑器及诊断展示,不参与身份比较和哈希。"""
def __post_init__(self):
if not isinstance(self.name, str):
raise TypeError('技能提示名称必须是字符串')
if not isinstance(self.code, str) or not re.fullmatch(r'[A-Za-z0-9_\-]+', self.code):
raise ValueError(f'无效技能 code: {self.code!r}')
if self.profession is not None and (
not isinstance(self.profession, str)
or not re.fullmatch(r'[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*', self.profession)
):
raise ValueError(f'无效职业标识: {self.profession!r}')
@property
def qualified_key(self) -> str:
"""完整引用标识,例如 extra.creator:createspace 或 common:backstep。"""
return f'{self.profession or "common"}:{self.code}'
class _ReferenceMeta(type):
"""构建时检查继承、作用域和重复项,构建后冻结命名空间。"""
def __new__(mcls, name, bases, namespace):
inherited = {}
for base in bases:
for member, ref in getattr(base, '__members__', {}).items():
if member in inherited and inherited[member] != ref:
raise ValueError(f'{name}: 继承成员冲突: {member}')
inherited[member] = ref
reserved = {'members', 'from_code', '__members__', '_by_code'}
if any(isinstance(base, _ReferenceMeta) for base in bases):
if reserved.intersection(namespace):
raise ValueError(f'{name}: 不能覆盖公共查询接口')
if inherited.keys() & namespace.keys():
raise ValueError(f'{name}: 不能覆盖继承的技能引用')
members = dict(inherited)
profession = namespace.get('__profession__', next((getattr(b, '__profession__', None) for b in bases), None))
for member, ref in namespace.items():
if isinstance(ref, SkillRef):
if member.startswith('_') or member != member.upper():
raise ValueError(f'{name}: 技能引用成员必须使用大写名称: {member}')
if ref.profession != profession:
raise ValueError(f'{name}: 技能引用职业范围不一致: {member}')
members[member] = ref
if any(ref.profession not in (None, profession) for ref in inherited.values()):
raise ValueError(f'{name}: 不能继承其他职业的专属引用')
by_code = {}
for member, ref in sorted(members.items()):
if ref.code in by_code:
raise ValueError(f'{name}: 重复技能 code: {ref.code} ({member})')
by_code[ref.code] = ref
namespace['__members__'] = MappingProxyType(dict(sorted(members.items())))
namespace['_by_code'] = MappingProxyType(by_code)
return super().__new__(mcls, name, bases, namespace)
def __setattr__(cls, name, value):
raise AttributeError('技能引用命名空间只读,不能重新赋值')
def __delattr__(cls, name):
raise AttributeError('技能引用命名空间只读,不能删除成员')
class SkillReferences(metaclass=_ReferenceMeta):
"""可继承的技能引用命名空间;通过类访问,无须创建实例。"""
__profession__: ClassVar[str | None] = None
"""职业范围,由职业类声明;公共类为 None。"""
__members__: ClassVar[Mapping[str, SkillRef]]
"""包含继承成员的只读名称映射,由公共构建过程生成。"""
_by_code: ClassVar[Mapping[str, SkillRef]]
def __new__(cls):
raise TypeError('技能引用通过类访问,无须实例化')
@classmethod
def members(cls) -> tuple[SkillRef, ...]:
"""返回包含公共技能的全部引用,按成员名排序。"""
return tuple(cls.__members__.values())
@classmethod
def from_code(cls, code: str) -> SkillRef:
"""按原始 code 查找包含公共技能的引用;不存在时抛出 KeyError。"""
return cls._by_code[code]
def bind_skill_refs(character, profession: str) -> None:
"""为当前角色建立引用及 code 索引;拒绝重复绑定、跨职业引用和无效类型。
旧技能可暂不声明 ref;引用索引只保存当前角色实例,公共引用不会跨角色共享实例。
profession 使用技能数据来源职业,输出圣骑士等变体复用其来源职业标识。
"""
by_ref = {}
by_code = {}
for skill in character.skills:
ref = skill.ref
if ref is None:
continue
if not isinstance(ref, SkillRef):
raise TypeError(f'技能 ref 必须是 SkillRef: {skill.name}')
if ref.profession not in (None, profession):
raise ValueError(f'技能引用职业不匹配: {skill.name}: {ref.qualified_key} -> {profession}')
if ref in by_ref or ref.code in by_code:
raise ValueError(f'重复技能引用绑定: {skill.name}: {ref.qualified_key}')
by_ref[ref] = skill
by_code[ref.code] = skill
character.skill_ref_profession = profession
character.skills_ref_dict = by_ref
character.skills_code_dict = by_code
def resolve_skill(character, reference: SkillRef | str):
"""统一解析技能引用;字符串沿用显示名称查询,绝不隐式当作 code。
错误职业范围直接报错;有效范围内缺失技能返回 None,由调用入口决定缺失策略。
公共引用同样必须存在于当前角色引用索引中,不按名称或 code 猜测回退。
"""
if isinstance(reference, str):
return character.GetSkillByName(reference)
if not isinstance(reference, SkillRef):
raise TypeError('技能引用必须是 SkillRef 或旧技能名称字符串')
profession = getattr(character, 'skill_ref_profession', None)
if reference.profession not in (None, profession):
raise ValueError(f'技能引用职业不匹配: {reference.qualified_key} -> {profession}')
return character.skills_ref_dict.get(reference)