* fix: CSV column name mismatches in data loading - sect.py: Fix headquarter_name/headquarter_desc -> name/desc when reading sect_region.csv - sect.py: Move sid initialization before technique lookup to fix unbound variable bug - technique.py: Change sect (name) to sect_id (int) to match technique.csv column - elixir.py: Remove redundant get_int(row, "id") that reads non-existent column These fixes ensure: 1. Sect headquarters display correct location names (e.g., "大千光极城" instead of "不夜城") 2. Sect techniques are correctly associated and displayed 3. Technique sect restrictions work properly * fix: update main.py to use sect_id, add CSV loading tests - main.py: Change technique.sect to technique.sect_id in API response - Add tests/test_csv_loading.py to verify CSV column names match code * test: add API test for /api/meta/game_data endpoint Verify that techniques in API response use sect_id field (not sect) * fix: add None check for hq_region in AvatarFactory Remove redundant code that adds sect headquarters to known_regions. This logic is already handled by Avatar._init_known_regions() which uses sect_id matching (more reliable than name-based lookup). The removed code was causing a flaky test (~1% failure rate) because resolve_query returns None in test environments with simplified maps.
277 lines
9.8 KiB
Python
277 lines
9.8 KiB
Python
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from enum import Enum
|
||
from typing import Optional, Dict, List
|
||
import json
|
||
from src.classes.effect import load_effect_from_str
|
||
from src.classes.color import Color, TECHNIQUE_GRADE_COLORS
|
||
|
||
from src.utils.df import game_configs, get_str, get_float, get_int
|
||
from src.classes.alignment import Alignment
|
||
from src.classes.root import Root, RootElement
|
||
|
||
|
||
class TechniqueAttribute(Enum):
|
||
GOLD = "金"
|
||
WOOD = "木"
|
||
WATER = "水"
|
||
FIRE = "火"
|
||
EARTH = "土"
|
||
ICE = "冰"
|
||
WIND = "风"
|
||
DARK = "暗"
|
||
THUNDER = "雷"
|
||
EVIL = "邪"
|
||
|
||
def __str__(self) -> str:
|
||
return self.value
|
||
|
||
|
||
class TechniqueGrade(Enum):
|
||
LOWER = "下品"
|
||
MIDDLE = "中品"
|
||
UPPER = "上品"
|
||
|
||
@staticmethod
|
||
def from_str(s: str) -> "TechniqueGrade":
|
||
s = str(s).strip()
|
||
if s == "上品":
|
||
return TechniqueGrade.UPPER
|
||
if s == "中品":
|
||
return TechniqueGrade.MIDDLE
|
||
return TechniqueGrade.LOWER
|
||
|
||
@property
|
||
def color_rgb(self) -> tuple[int, int, int]:
|
||
"""返回功法品阶对应的RGB颜色值"""
|
||
color_map = {
|
||
TechniqueGrade.LOWER: TECHNIQUE_GRADE_COLORS["LOWER"],
|
||
TechniqueGrade.MIDDLE: TECHNIQUE_GRADE_COLORS["MIDDLE"],
|
||
TechniqueGrade.UPPER: TECHNIQUE_GRADE_COLORS["UPPER"],
|
||
}
|
||
return color_map.get(self, Color.COMMON_WHITE)
|
||
|
||
|
||
@dataclass
|
||
class Technique:
|
||
id: int
|
||
name: str
|
||
attribute: TechniqueAttribute
|
||
grade: TechniqueGrade
|
||
desc: str
|
||
weight: float
|
||
condition: str
|
||
# 归属宗门ID;None 表示无宗门要求(散修可修)
|
||
sect_id: Optional[int] = None
|
||
# 影响角色或系统的效果
|
||
effects: dict[str, object] = field(default_factory=dict)
|
||
effect_desc: str = ""
|
||
|
||
def is_allowed_for(self, avatar) -> bool:
|
||
if not self.condition:
|
||
return True
|
||
return bool(eval(self.condition, {"__builtins__": {}}, {"avatar": avatar, "Alignment": Alignment}))
|
||
|
||
def get_info(self, detailed: bool = False) -> str:
|
||
if detailed:
|
||
return self.get_detailed_info()
|
||
return f"{self.name}({self.attribute}){self.grade.value}"
|
||
|
||
def get_detailed_info(self) -> str:
|
||
effect_part = f" 效果:{self.effect_desc}" if self.effect_desc else ""
|
||
return f"{self.name}({self.attribute}){self.grade.value} {self.desc}{effect_part}"
|
||
|
||
def get_colored_info(self) -> str:
|
||
"""获取带颜色标记的信息,供前端渲染使用"""
|
||
r, g, b = self.grade.color_rgb
|
||
return f"<color:{r},{g},{b}>{self.name}({self.attribute}·{self.grade.value})</color>"
|
||
|
||
def get_structured_info(self) -> dict:
|
||
return {
|
||
"name": self.name,
|
||
"desc": self.desc,
|
||
"grade": self.grade.value,
|
||
"color": self.grade.color_rgb,
|
||
"attribute": self.attribute.value,
|
||
"effect_desc": self.effect_desc,
|
||
}
|
||
|
||
# 五行与扩展属性的克制关系
|
||
# - 五行:金克木,木克土,土克水,水克火,火克金
|
||
# - 雷克邪;邪、冰、风、暗不克任何人
|
||
SUPPRESSION: dict[TechniqueAttribute, set[TechniqueAttribute]] = {
|
||
TechniqueAttribute.GOLD: {TechniqueAttribute.WOOD},
|
||
TechniqueAttribute.WOOD: {TechniqueAttribute.EARTH},
|
||
TechniqueAttribute.EARTH: {TechniqueAttribute.WATER},
|
||
TechniqueAttribute.WATER: {TechniqueAttribute.FIRE},
|
||
TechniqueAttribute.FIRE: {TechniqueAttribute.GOLD},
|
||
TechniqueAttribute.THUNDER: {TechniqueAttribute.EVIL},
|
||
TechniqueAttribute.ICE: set(),
|
||
TechniqueAttribute.WIND: set(),
|
||
TechniqueAttribute.DARK: set(),
|
||
TechniqueAttribute.EVIL: set(),
|
||
}
|
||
|
||
|
||
def loads() -> tuple[dict[int, Technique], dict[str, Technique]]:
|
||
techniques_by_id: dict[int, Technique] = {}
|
||
techniques_by_name: dict[str, Technique] = {}
|
||
df = game_configs["technique"]
|
||
for row in df:
|
||
attr = TechniqueAttribute(get_str(row, "technique_root"))
|
||
name = get_str(row, "name")
|
||
grade = TechniqueGrade.from_str(get_str(row, "grade", "下品"))
|
||
condition = get_str(row, "condition")
|
||
weight = get_float(row, "weight", 1.0)
|
||
|
||
sect_id_raw = get_int(row, "sect_id", -1)
|
||
sect_id = sect_id_raw if sect_id_raw > 0 else None
|
||
|
||
effects = load_effect_from_str(get_str(row, "effects"))
|
||
from src.classes.effect import format_effects_to_text
|
||
effect_desc = format_effects_to_text(effects)
|
||
|
||
t = Technique(
|
||
id=get_int(row, "id"),
|
||
name=name,
|
||
attribute=attr,
|
||
grade=grade,
|
||
desc=get_str(row, "desc"),
|
||
weight=weight,
|
||
condition=condition,
|
||
sect_id=sect_id,
|
||
effects=effects,
|
||
effect_desc=effect_desc,
|
||
)
|
||
techniques_by_id[t.id] = t
|
||
techniques_by_name[t.name] = t
|
||
return techniques_by_id, techniques_by_name
|
||
|
||
|
||
techniques_by_id, techniques_by_name = loads()
|
||
|
||
|
||
def is_attribute_compatible_with_root(attr: TechniqueAttribute, root: Root) -> bool:
|
||
if attr == TechniqueAttribute.EVIL:
|
||
# 邪功法仅由阵营约束,这里视为与灵根无关
|
||
return True
|
||
|
||
# 天灵根:除邪外全系可修
|
||
if root == Root.HEAVEN:
|
||
return attr != TechniqueAttribute.EVIL
|
||
|
||
# 单属性灵根:只能修行对应属性
|
||
single_map = {
|
||
Root.GOLD: TechniqueAttribute.GOLD,
|
||
Root.WOOD: TechniqueAttribute.WOOD,
|
||
Root.WATER: TechniqueAttribute.WATER,
|
||
Root.FIRE: TechniqueAttribute.FIRE,
|
||
Root.EARTH: TechniqueAttribute.EARTH,
|
||
}
|
||
if root in single_map:
|
||
return attr == single_map[root]
|
||
|
||
# 复合/扩展灵根:根名属性 + 其元素列表中的属性
|
||
complex_map: dict[Root, set[TechniqueAttribute]] = {
|
||
Root.ICE: {TechniqueAttribute.ICE, TechniqueAttribute.GOLD, TechniqueAttribute.WATER},
|
||
Root.WIND: {TechniqueAttribute.WIND, TechniqueAttribute.WOOD, TechniqueAttribute.WATER},
|
||
Root.DARK: {TechniqueAttribute.DARK, TechniqueAttribute.FIRE, TechniqueAttribute.EARTH},
|
||
Root.THUNDER: {TechniqueAttribute.THUNDER, TechniqueAttribute.WATER, TechniqueAttribute.EARTH},
|
||
}
|
||
if root in complex_map:
|
||
return attr in complex_map[root]
|
||
|
||
return False
|
||
|
||
def get_random_technique_for_avatar(avatar) -> Technique:
|
||
import random
|
||
candidates: List[Technique] = []
|
||
for t in techniques_by_id.values():
|
||
if not t.is_allowed_for(avatar):
|
||
continue
|
||
if t.attribute == TechniqueAttribute.EVIL and avatar.alignment != Alignment.EVIL:
|
||
continue
|
||
if not is_attribute_compatible_with_root(t.attribute, avatar.root):
|
||
continue
|
||
candidates.append(t)
|
||
if not candidates:
|
||
# 回退:不考虑条件,仅按灵根兼容挑选(若仍为空,则全量)
|
||
fallback = [
|
||
t for t in techniques_by_id.values()
|
||
if (t.attribute != TechniqueAttribute.EVIL) and is_attribute_compatible_with_root(t.attribute, avatar.root)
|
||
]
|
||
candidates = fallback or list(techniques_by_id.values())
|
||
weights = [max(0.0, t.weight) for t in candidates]
|
||
return random.choices(candidates, weights=weights, k=1)[0]
|
||
|
||
|
||
def get_random_upper_technique_for_avatar(avatar) -> Technique | None:
|
||
"""
|
||
返回一个与 avatar 灵根/阵营/条件相容的上品功法;若无则返回 None。
|
||
仅用于奇遇奖励优先挑选上品功法。
|
||
"""
|
||
import random
|
||
candidates: List[Technique] = []
|
||
for t in techniques_by_id.values():
|
||
if t.grade is not TechniqueGrade.UPPER:
|
||
continue
|
||
if not t.is_allowed_for(avatar):
|
||
continue
|
||
if t.attribute == TechniqueAttribute.EVIL and avatar.alignment != Alignment.EVIL:
|
||
continue
|
||
if not is_attribute_compatible_with_root(t.attribute, avatar.root):
|
||
continue
|
||
candidates.append(t)
|
||
if not candidates:
|
||
return None
|
||
weights = [max(0.0, t.weight) for t in candidates]
|
||
return random.choices(candidates, weights=weights, k=1)[0]
|
||
|
||
def get_technique_by_sect(sect) -> Technique:
|
||
"""
|
||
简化版:仅按宗门筛选并按权重抽样,不考虑灵根与 condition。
|
||
- 散修(sect 为 None):只从无宗门要求(sect_id 为 None)的功法中抽样;
|
||
- 有宗门:从"无宗门 + 该宗门"的功法中抽样;
|
||
若集合为空,则退回全量功法。
|
||
"""
|
||
import random
|
||
|
||
target_sect_id: Optional[int] = None
|
||
if sect is not None:
|
||
target_sect_id = getattr(sect, "id", None)
|
||
|
||
allowed_sect_ids: set[Optional[int]] = {None}
|
||
if target_sect_id is not None:
|
||
allowed_sect_ids.add(target_sect_id)
|
||
|
||
def _in_allowed_sect(t: Technique) -> bool:
|
||
return t.sect_id in allowed_sect_ids
|
||
|
||
candidates: List[Technique] = [t for t in techniques_by_id.values() if _in_allowed_sect(t)]
|
||
if not candidates:
|
||
candidates = list(techniques_by_id.values())
|
||
weights = [max(0.0, t.weight) for t in candidates]
|
||
return random.choices(candidates, weights=weights, k=1)[0]
|
||
|
||
|
||
def get_suppression_bonus(att_attr: TechniqueAttribute, def_attr: TechniqueAttribute) -> float:
|
||
return 0.10 if def_attr in SUPPRESSION.get(att_attr, set()) else 0.0
|
||
|
||
|
||
|
||
# 将功法属性映射为默认的灵根(邪功法不返回)
|
||
def attribute_to_root(attr: TechniqueAttribute) -> Optional[Root]:
|
||
mapping: dict[TechniqueAttribute, Root] = {
|
||
TechniqueAttribute.GOLD: Root.GOLD,
|
||
TechniqueAttribute.WOOD: Root.WOOD,
|
||
TechniqueAttribute.WATER: Root.WATER,
|
||
TechniqueAttribute.FIRE: Root.FIRE,
|
||
TechniqueAttribute.EARTH: Root.EARTH,
|
||
TechniqueAttribute.THUNDER: Root.THUNDER,
|
||
TechniqueAttribute.ICE: Root.ICE,
|
||
TechniqueAttribute.WIND: Root.WIND,
|
||
TechniqueAttribute.DARK: Root.DARK,
|
||
}
|
||
return mapping.get(attr)
|