Files
cultivation-world-simulator/src/classes/technique.py
Zihao Xu 7edae9188b fix(misc): CSV column name mismatches in data loading (#32)
* 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.
2026-01-18 15:31:15 +08:00

277 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __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
# 归属宗门IDNone 表示无宗门要求(散修可修)
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)