add sect ranks

This commit is contained in:
bridge
2025-11-08 02:42:28 +08:00
parent eb0dde2e5c
commit d8cb9389fb
6 changed files with 536 additions and 20 deletions

View File

@@ -1,10 +1,13 @@
import random
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, List
from typing import Optional, List, TYPE_CHECKING
from collections import defaultdict
import json
if TYPE_CHECKING:
from src.classes.sect_ranks import SectRank
from src.classes.calendar import MonthStamp
from src.classes.action import Action
from src.classes.action_runtime import ActionStatus, ActionResult
@@ -89,6 +92,8 @@ class Avatar:
alignment: Alignment | None = None
# 所属宗门(可为空,表示散修/无门无派)
sect: Sect | None = None
# 宗门职位(仅当有宗门时有效)
sect_rank: "SectRank | None" = None
# 外貌1~10级创建时随机生成
appearance: Appearance = field(default_factory=get_random_appearance)
# 装备的法宝(仅一个)
@@ -185,9 +190,11 @@ class Avatar:
relations_info = "".join(relation_lines) if relation_lines else ""
magic_stone_info = str(self.magic_stone)
from src.classes.sect import get_sect_info_with_rank
if detailed:
treasure_info = self.treasure.get_detailed_info() if self.treasure is not None else ""
sect_info = self.sect.get_detailed_info() if self.sect is not None else "散修"
sect_info = get_sect_info_with_rank(self, detailed=True)
alignment_info = self.alignment.get_detailed_info() if self.alignment is not None else "未知"
region_info = region.get_detailed_info() if region is not None else ""
root_info = self.root.get_detailed_info()
@@ -199,8 +206,8 @@ class Avatar:
spirit_animal_info = self.spirit_animal.get_info() if self.spirit_animal is not None else ""
else:
treasure_info = self.treasure.get_info() if self.treasure is not None else ""
# personas和sect一致返回detailed因为这俩太重要了
sect_info = self.sect.get_detailed_info() if self.sect is not None else "散修"
# 宗门信息:非详细模式下只显示"宗门名+职位"
sect_info = get_sect_info_with_rank(self, detailed=False)
region_info = region.get_info() if region is not None else ""
alignment_info = self.alignment.get_info() if self.alignment is not None else "未知"
root_info = self.root.get_info()
@@ -316,7 +323,7 @@ class Avatar:
"""
if self.current_action is None:
return []
# 记录当前动作实例引用,用于检测执行过程中是否发生了抢占/切换
# 记录当前动作实例引用,用于检测执行过程中是否发生了"抢占/切换"
action_instance_before = self.current_action
action = action_instance_before.action
params = action_instance_before.params
@@ -326,9 +333,14 @@ class Avatar:
params_for_finish = filter_kwargs_for_callable(action.finish, params)
finish_events = action.finish(**params_for_finish)
# 仅当当前动作仍然是刚才执行的那个实例时才清空
# 若在 step() 内部通过抢占机制切换了动作(如 Escape 失败立即切到 Battle不要清空新动作
# 若在 step() 内部通过"抢占"机制切换了动作(如 Escape 失败立即切到 Battle不要清空新动作
if self.current_action is action_instance_before:
self.current_action = None
# 动作完成后,如果有待执行计划,立即提交下一个(支持同月链式执行)
if self.has_plans():
start_event = self.commit_next_plan()
if start_event is not None:
self._pending_events.append(start_event)
if finish_events:
# 允许 finish 直接返回事件(极少用),统一并入 pending
for e in finish_events:
@@ -338,13 +350,15 @@ class Avatar:
for e in result.events:
self._pending_events.append(e)
events, self._pending_events = self._pending_events, []
# 本轮已执行过,清除新设动作标记
self._new_action_set_this_step = False
# 本轮已执行过,清除"新设动作"标记但如果刚刚提交了新动作commit_next_plan会重新设置为True
if self.current_action is None:
# 当前无动作时才清除标记,避免清除新提交动作的标记
self._new_action_set_this_step = False
return events
def update_cultivation(self, new_level: int):
"""
更新修仙进度,并在境界提升时更新寿命
更新修仙进度,并在境界提升时更新寿命和宗门职位
"""
old_realm = self.cultivation_progress.realm
self.cultivation_progress.level = new_level
@@ -353,6 +367,9 @@ class Avatar:
# 如果境界提升了,更新寿命期望
if self.cultivation_progress.realm != old_realm:
self.age.update_realm(self.cultivation_progress.realm)
# 如果有宗门,检查是否需要晋升职位
from src.classes.sect_ranks import check_and_promote_sect_rank
check_and_promote_sect_rank(self, old_realm, self.cultivation_progress.realm)
def death_by_old_age(self) -> bool:
"""
@@ -595,9 +612,19 @@ class Avatar:
def get_sect_str(self) -> str:
"""
获取宗门显示名:有宗门则返回宗门名,否则返回"散修"
获取宗门显示名:有宗门则返回"宗门名+职位",否则返回"散修"
例如:"合欢宗长老""散修"
"""
return self.sect.name if self.sect is not None else "散修"
if self.sect is None:
return "散修"
# 有宗门但无职位(理论上不应该出现,兜底处理)
if self.sect_rank is None:
return self.sect.name
from src.classes.sect_ranks import get_rank_display_name
rank_name = get_rank_display_name(self.sect_rank, self.sect)
return f"{self.sect.name}{rank_name}"
def set_relation(self, other: "Avatar", relation: Relation) -> None:
"""

View File

@@ -53,12 +53,12 @@ class Talk(MutualAction):
)
EventHelper.push_pair(accept_event, initiator=self.avatar, target=target, to_sidebar_once=True)
# 立即启动 Conversation
from .conversation import Conversation
conv = Conversation(self.avatar, self.world)
conv.start(target_avatar=target)
# 直接执行一次 step启动异步调用
conv.step(target_avatar=target)
# Conversation 加入计划队列在Talk完成后立即执行
self.avatar.load_decide_result_chain(
[("Conversation", {"target_avatar": target.name})],
self.avatar.thinking,
self.avatar.objective
)
else:
# 拒绝攀谈
reject_event = Event(

View File

@@ -44,8 +44,8 @@ class Sect:
weight: float = 1.0
# 影响角色或系统的效果
effects: dict[str, object] = field(default_factory=dict)
# 功法在technique.csv中配置
# TODO宗内等级和称谓
# 宗门自定义职位名称可选SectRank -> 名称
rank_names: dict[str, str] = field(default_factory=dict)
def get_info(self) -> str:
hq = self.headquarter
@@ -55,6 +55,20 @@ class Sect:
# 详细描述:风格、阵营、驻地
hq = self.headquarter
return f"{self.name}(阵营:{self.alignment},风格:{self.member_act_style},驻地:{hq.name}"
def get_rank_name(self, rank: "SectRank") -> str:
"""
获取宗门的职位名称(支持自定义)
Args:
rank: 宗门职位枚举
Returns:
职位名称字符串
"""
from src.classes.sect_ranks import SectRank, DEFAULT_RANK_NAMES
# 优先使用自定义名称,否则使用默认名称
return self.rank_names.get(rank.value, DEFAULT_RANK_NAMES.get(rank, "弟子"))
def _split_names(value: object) -> list[str]:
raw = "" if value is None or str(value) == "nan" else str(value)
sep = CONFIG.df.ids_separator
@@ -136,4 +150,44 @@ def _load_sects() -> tuple[dict[int, Sect], dict[str, Sect]]:
# 导出:从配表加载 sect 数据
sects_by_id, sects_by_name = _load_sects()
sects_by_id, sects_by_name = _load_sects()
def get_sect_info_with_rank(avatar: "Avatar", detailed: bool = False) -> str:
"""
获取包含职位的宗门信息字符串
Args:
avatar: 角色对象
detailed: 是否包含宗门详细信息(阵营、风格、驻地等)
Returns:
- 散修:返回"散修"
- detailed=False返回"明心剑宗长老"
- detailed=True返回"明心剑宗长老(阵营:正,风格:...,驻地:..."
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.classes.avatar import Avatar
# 散修直接返回
if avatar.sect is None:
return "散修"
# 获取职位+宗门名(如"明心剑宗长老"
sect_rank_str = avatar.get_sect_str()
# 如果不需要详细信息,直接返回职位字符串
if not detailed:
return sect_rank_str
# 需要详细信息:拼接宗门的详细描述
sect_detail = avatar.sect.get_detailed_info() # "明心剑宗(阵营:正,..."
# 提取括号及其内容
if "" in sect_detail:
detail_part = sect_detail[sect_detail.index(""):]
return f"{sect_rank_str}{detail_part}"
# 如果没有括号(理论上不应该出现),直接返回职位字符串
return sect_rank_str

185
src/classes/sect_ranks.py Normal file
View File

@@ -0,0 +1,185 @@
"""
宗门等级系统
定义宗门内的职位等级及其与修仙境界的映射关系
"""
from enum import Enum
from functools import total_ordering
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from src.classes.cultivation import Realm
from src.classes.sect import Sect
@total_ordering
class SectRank(Enum):
"""
宗门职位等级
从高到低:掌门 > 长老 > 内门弟子 > 外门弟子
"""
Patriarch = "patriarch" # 掌门
Elder = "elder" # 长老
InnerDisciple = "inner" # 内门弟子
OuterDisciple = "outer" # 外门弟子
def __lt__(self, other):
if not isinstance(other, SectRank):
return NotImplemented
# 数字越小职位越高,所以比较时反过来
return RANK_ORDER[self] > RANK_ORDER[other]
def __le__(self, other):
if not isinstance(other, SectRank):
return NotImplemented
return RANK_ORDER[self] >= RANK_ORDER[other]
def __gt__(self, other):
if not isinstance(other, SectRank):
return NotImplemented
# 数字越小职位越高,所以比较时反过来
return RANK_ORDER[self] < RANK_ORDER[other]
def __ge__(self, other):
if not isinstance(other, SectRank):
return NotImplemented
return RANK_ORDER[self] <= RANK_ORDER[other]
# 宗门职位顺序(数字越小职位越高)
RANK_ORDER = {
SectRank.Patriarch: 0,
SectRank.Elder: 1,
SectRank.InnerDisciple: 2,
SectRank.OuterDisciple: 3,
}
# 默认职位名称(可被宗门自定义覆盖)
DEFAULT_RANK_NAMES = {
SectRank.Patriarch: "掌门",
SectRank.Elder: "长老",
SectRank.InnerDisciple: "内门弟子",
SectRank.OuterDisciple: "外门弟子",
}
def get_rank_from_realm(realm: "Realm") -> SectRank:
"""
根据修仙境界映射为宗门职位
映射规则:
- 练气 → 外门弟子
- 筑基 → 内门弟子
- 金丹 → 长老
- 元婴 → 掌门(需要额外检查唯一性)
Args:
realm: 修仙境界
Returns:
对应的宗门职位
"""
from src.classes.cultivation import Realm
mapping = {
Realm.Qi_Refinement: SectRank.OuterDisciple,
Realm.Foundation_Establishment: SectRank.InnerDisciple,
Realm.Core_Formation: SectRank.Elder,
Realm.Nascent_Soul: SectRank.Patriarch,
}
return mapping.get(realm, SectRank.OuterDisciple)
def get_rank_display_name(rank: SectRank, sect: Optional["Sect"] = None) -> str:
"""
获取职位的显示名称(支持宗门自定义)
Args:
rank: 宗门职位
sect: 宗门对象(可选,如果提供则使用其自定义名称)
Returns:
职位的显示名称
"""
if sect is not None:
custom_name = sect.get_rank_name(rank)
if custom_name:
return custom_name
return DEFAULT_RANK_NAMES.get(rank, "弟子")
def should_auto_promote(old_realm: "Realm", new_realm: "Realm") -> bool:
"""
判断境界突破后是否应该自动晋升宗门职位
Args:
old_realm: 旧境界
new_realm: 新境界
Returns:
是否应该晋升
"""
if old_realm == new_realm:
return False
from src.classes.cultivation import Realm
# 检查境界是否提升
old_rank = get_rank_from_realm(old_realm)
new_rank = get_rank_from_realm(new_realm)
# 只有当新境界对应的职位更高时才晋升(职位枚举中 > 表示更高)
return new_rank > old_rank
def check_and_promote_sect_rank(avatar: "Avatar", old_realm: "Realm", new_realm: "Realm") -> None:
"""
检查境界突破后是否需要晋升宗门职位,并执行晋升
Args:
avatar: 要检查的角色
old_realm: 旧境界
new_realm: 新境界
"""
# 无宗门或无职位,不需要晋升
if avatar.sect is None or avatar.sect_rank is None:
return
# 检查是否应该晋升
if not should_auto_promote(old_realm, new_realm):
return
new_rank = get_rank_from_realm(new_realm)
# 如果新职位是掌门,需要检查该宗门是否已有掌门
if new_rank == SectRank.Patriarch:
if sect_has_patriarch(avatar):
# 已有掌门,只能晋升为长老
new_rank = SectRank.Elder
# 执行晋升
avatar.sect_rank = new_rank
def sect_has_patriarch(avatar: "Avatar") -> bool:
"""
检查当前宗门是否已有掌门(不包括自己)
Args:
avatar: 要检查的角色
Returns:
是否已有其他掌门
"""
if avatar.sect is None:
return False
# 从world中查找同宗门的其他avatar
for other in avatar.world.avatar_manager.avatars.values():
if other is avatar:
continue
if other.sect == avatar.sect and other.sect_rank == SectRank.Patriarch:
return True
return False

View File

@@ -209,6 +209,9 @@ def build_mortal_from_plan(world: World, current_month_stamp: MonthStamp, *, nam
# 位置刷新
avatar.tile = world.map.get_tile(avatar.pos_x, avatar.pos_y)
# 分配宗门职位(根据境界)
_assign_sect_rank(avatar, world)
# 写关系(父母/师徒);不发放宗门法宝
if plan.parent_avatar is not None:
@@ -446,6 +449,9 @@ def build_avatars_from_plan(
avatars_by_index[i] = avatar
avatars_by_id[avatar.id] = avatar
# 批量分配宗门职位需要在所有avatar创建后统一处理以正确检查掌门唯一性
_assign_sect_ranks_batch(avatars_by_index, world)
for (a, b), relation in planned_relations.items():
av_a = avatars_by_index[a]
@@ -743,3 +749,82 @@ def get_new_avatar_with_config(
return avatar
def _assign_sect_rank(avatar: Avatar, world: World) -> None:
"""
为单个avatar分配宗门职位根据境界
处理掌门唯一性:如果该宗门已有掌门,元婴修士只能当长老
Args:
avatar: 要分配职位的角色
world: 世界对象
"""
# 散修无职位
if avatar.sect is None:
avatar.sect_rank = None
return
from src.classes.sect_ranks import get_rank_from_realm, sect_has_patriarch, SectRank
# 根据境界获取对应职位
rank = get_rank_from_realm(avatar.cultivation_progress.realm)
# 如果是掌门,检查该宗门是否已有掌门
if rank == SectRank.Patriarch:
if sect_has_patriarch(avatar):
# 已有掌门,降为长老
rank = SectRank.Elder
avatar.sect_rank = rank
def _assign_sect_ranks_batch(avatars: List[Avatar], world: World) -> None:
"""
批量为avatars分配宗门职位
确保每个宗门只有一个掌门(按境界等级优先,同境界随机)
Args:
avatars: 要分配职位的角色列表
world: 世界对象
"""
from src.classes.sect_ranks import get_rank_from_realm, SectRank
# 先为所有人分配基础职位
for avatar in avatars:
if avatar is None:
continue
if avatar.sect is None:
avatar.sect_rank = None
else:
avatar.sect_rank = get_rank_from_realm(avatar.cultivation_progress.realm)
# 收集每个宗门的元婴修士(应为掌门的候选人)
sect_nascent_souls: Dict[int, List[Avatar]] = {}
for avatar in avatars:
if avatar is None or avatar.sect is None:
continue
if avatar.sect_rank == SectRank.Patriarch:
sect_id = avatar.sect.id
if sect_id not in sect_nascent_souls:
sect_nascent_souls[sect_id] = []
sect_nascent_souls[sect_id].append(avatar)
# 检查world中已存在的掌门
existing_patriarchs: Dict[int, bool] = {}
for other in world.avatar_manager.avatars.values():
if other.sect is not None and other.sect_rank == SectRank.Patriarch:
existing_patriarchs[other.sect.id] = True
# 为每个宗门选择唯一掌门
for sect_id, candidates in sect_nascent_souls.items():
# 如果world中已有掌门所有候选人都降为长老
if existing_patriarchs.get(sect_id, False):
for avatar in candidates:
avatar.sect_rank = SectRank.Elder
else:
# 选择等级最高的作为掌门,其余降为长老
candidates.sort(key=lambda av: av.cultivation_progress.level, reverse=True)
# 第一个保持掌门
for avatar in candidates[1:]:
avatar.sect_rank = SectRank.Elder

165
tests/test_sect_ranks.py Normal file
View File

@@ -0,0 +1,165 @@
"""
测试宗门等级系统
"""
import pytest
from src.classes.cultivation import CultivationProgress, Realm
from src.classes.sect_ranks import (
SectRank,
get_rank_from_realm,
get_rank_display_name,
should_auto_promote,
)
from src.classes.sect import sects_by_name
from src.classes.world import World
from src.classes.map import Map
from src.classes.calendar import MonthStamp
from src.sim.new_avatar import make_avatars
def test_rank_from_realm():
"""测试境界到宗门职位的映射"""
assert get_rank_from_realm(Realm.Qi_Refinement) == SectRank.OuterDisciple
assert get_rank_from_realm(Realm.Foundation_Establishment) == SectRank.InnerDisciple
assert get_rank_from_realm(Realm.Core_Formation) == SectRank.Elder
assert get_rank_from_realm(Realm.Nascent_Soul) == SectRank.Patriarch
def test_rank_display_name():
"""测试职位显示名称"""
assert get_rank_display_name(SectRank.Patriarch) == "掌门"
assert get_rank_display_name(SectRank.Elder) == "长老"
assert get_rank_display_name(SectRank.InnerDisciple) == "内门弟子"
assert get_rank_display_name(SectRank.OuterDisciple) == "外门弟子"
def test_rank_comparison():
"""测试职位比较"""
assert SectRank.Patriarch > SectRank.Elder
assert SectRank.Elder > SectRank.InnerDisciple
assert SectRank.InnerDisciple > SectRank.OuterDisciple
assert SectRank.OuterDisciple < SectRank.Patriarch
def test_auto_promote():
"""测试自动晋升逻辑"""
assert should_auto_promote(Realm.Qi_Refinement, Realm.Foundation_Establishment) == True
assert should_auto_promote(Realm.Foundation_Establishment, Realm.Core_Formation) == True
assert should_auto_promote(Realm.Core_Formation, Realm.Nascent_Soul) == True
assert should_auto_promote(Realm.Qi_Refinement, Realm.Qi_Refinement) == False
def test_avatar_sect_rank_assignment():
"""测试avatar创建时宗门职位分配"""
from src.run.create_map import create_cultivation_world_map
game_map = create_cultivation_world_map()
world = World(
map=game_map,
month_stamp=MonthStamp(100 * 12),
)
# 创建多个avatar
avatars_dict = make_avatars(world, count=20, current_month_stamp=MonthStamp(100 * 12))
avatars = list(avatars_dict.values())
# 检查所有有宗门的avatar都有职位
for avatar in avatars:
if avatar.sect is not None:
assert avatar.sect_rank is not None, f"{avatar.name} 有宗门但没有职位"
# 职位应该匹配境界
expected_rank = get_rank_from_realm(avatar.cultivation_progress.realm)
# 如果不是预期职位,只可能是元婴修士被降为长老(因为掌门唯一性)
if avatar.sect_rank != expected_rank:
assert avatar.cultivation_progress.realm == Realm.Nascent_Soul
assert avatar.sect_rank == SectRank.Elder
else:
assert avatar.sect_rank is None, f"{avatar.name} 散修不应该有职位"
def test_patriarch_uniqueness():
"""测试每个宗门只有一个掌门"""
from src.run.create_map import create_cultivation_world_map
game_map = create_cultivation_world_map()
world = World(
map=game_map,
month_stamp=MonthStamp(100 * 12),
)
# 创建足够多的avatar
avatars_dict = make_avatars(world, count=50, current_month_stamp=MonthStamp(100 * 12))
avatars = list(avatars_dict.values())
# 统计每个宗门的掌门数量
sect_patriarchs = {}
for avatar in avatars:
if avatar.sect is not None and avatar.sect_rank == SectRank.Patriarch:
sect_id = avatar.sect.id
if sect_id not in sect_patriarchs:
sect_patriarchs[sect_id] = []
sect_patriarchs[sect_id].append(avatar.name)
# 确保每个宗门最多只有一个掌门
for sect_id, patriarchs in sect_patriarchs.items():
assert len(patriarchs) <= 1, f"宗门 {sect_id} 有多个掌门: {patriarchs}"
def test_sect_str_display():
"""测试宗门信息显示"""
from src.run.create_map import create_cultivation_world_map
game_map = create_cultivation_world_map()
world = World(
map=game_map,
month_stamp=MonthStamp(100 * 12),
)
avatars_dict = make_avatars(world, count=20, current_month_stamp=MonthStamp(100 * 12))
avatars = list(avatars_dict.values())
for avatar in avatars:
sect_str = avatar.get_sect_str()
if avatar.sect is None:
assert sect_str == "散修"
else:
# 应该包含宗门名
assert avatar.sect.name in sect_str
# 应该包含职位名
if avatar.sect_rank is not None:
rank_name = get_rank_display_name(avatar.sect_rank, avatar.sect)
assert rank_name in sect_str
def test_cultivation_breakthrough_promotion():
"""测试突破境界后自动晋升"""
from src.run.create_map import create_cultivation_world_map
game_map = create_cultivation_world_map()
world = World(
map=game_map,
month_stamp=MonthStamp(100 * 12),
)
avatars_dict = make_avatars(world, count=10, current_month_stamp=MonthStamp(100 * 12))
avatars = list(avatars_dict.values())
# 找一个练气期的宗门弟子
target_avatar = None
for avatar in avatars:
if avatar.sect is not None and avatar.cultivation_progress.realm == Realm.Qi_Refinement:
target_avatar = avatar
break
if target_avatar is not None:
# 记录原职位
old_rank = target_avatar.sect_rank
assert old_rank == SectRank.OuterDisciple
# 突破到筑基
target_avatar.cultivation_progress.level = 31 # 筑基初期
target_avatar.update_cultivation(31)
# 检查职位是否晋升
assert target_avatar.sect_rank == SectRank.InnerDisciple
assert target_avatar.sect_rank > old_rank
if __name__ == "__main__":
pytest.main([__file__, "-v"])