Feat/hidden domain (#113)

Summary
新增秘境探索,属于多人活动,每N年触发一次
Closes #105
This commit is contained in:
4thfever
2026-01-31 20:43:42 +08:00
committed by GitHub
parent efa663febe
commit 0315dca6e6
34 changed files with 843 additions and 29 deletions

View File

@@ -0,0 +1,83 @@
# 国际化资源文件 (messages.po) 维护与增补指南
## 1. 核心问题警示 (CRITICAL WARNING)
**严禁在 Windows PowerShell 环境下直接使用重定向符号 (`>>`) 追加内容到 PO 文件!**
### 现象
`messages.po` 文件末尾出现大量 `NULL` (`\x00`) 字符或乱码导致文件损坏IDE 打开后显示为空白或包含红色方块。
### 原因
* 项目中的 PO 文件使用标准的 **UTF-8** 编码。
* Windows PowerShell 的默认输出编码(尤其是在使用 `>>` 时)通常是 **UTF-16LE** (Windows Unicode)。
* 当 UTF-16LE 的内容被追加到 UTF-8 文件末尾时,文件会包含两种不兼容的编码,且 UTF-16 中的 `\x00` 字节会被视为乱码。
---
## 2. 正确的增补方式
### 方法 A直接使用 IDE 编辑 (推荐)
最安全、最简单的方法。直接在 Cursor / VSCode 中打开 `src/i18n/locales/xx_XX/LC_MESSAGES/messages.po`,在文件末尾手动粘贴或输入新的翻译条目。
### 方法 B使用 Python 脚本追加
如果必须通过脚本自动化,请务必使用 Python 并显式指定 UTF-8 编码。
```python
# correct_append.py
content = """
msgid "new_key"
msgstr "Translation"
"""
with open("path/to/messages.po", "a", encoding="utf-8") as f:
f.write(content)
```
### 方法 CLinux/Bash 环境
在 Git Bash 或 WSL 中使用 `cat >>` 是安全的,因为它们默认处理 UTF-8 流。
```bash
# Git Bash / WSL only
cat temp.po >> messages.po
```
---
## 3. 文件格式规范
在增补 PO 文件时,请遵守以下格式规则,否则可能导致解析错误:
1. **不要重复 Header**
* PO 文件的开头已经包含了元数据(`Project-Id-Version`, `Content-Type` 等)。
* **不要**在追加的内容中再次包含这些 Header 信息。追加内容应仅包含 `msgid``msgstr`
2. **保持空行分隔**
* 每个 `msgid`/`msgstr` 对之间应保留一个空行,以提高可读性。
3. **UTF-8 无 BOM**
* 始终确保文件保存为 UTF-8 编码且不带 BOM 头。
## 4. 紧急修复指南
如果不慎损坏了文件(出现了 NULL 字节),请按以下步骤修复:
1. **立即停止写入**
2. 使用支持二进制查看的编辑器(或 Python读取文件。
3. 去除所有的 `\x00` 字节。
4. 检查并删除文件中段出现的重复 Header。
5. 重新保存为 UTF-8。
**Python 修复脚本示例:**
```python
def fix_po_file(path):
with open(path, 'rb') as f:
content = f.read()
# 替换掉 null 字节
content = content.replace(b'\x00', b'')
# 重新写入为 UTF-8
with open(path, 'w', encoding='utf-8') as f:
f.write(content.decode('utf-8')) # 假设剩余内容是合法的 utf-8
```

View File

@@ -38,11 +38,11 @@ class Animal:
"""
from src.i18n import t
# 使用格式化字符串 msgid
base_info = t("[{name}] ({realm})", name=t(self.name), realm=str(self.realm))
info_parts = [base_info, t(self.desc)]
base_info = t("[{name}] ({realm})", name=self.name, realm=str(self.realm))
info_parts = [base_info, self.desc]
if self.materials:
material_names = [t(material.name) for material in self.materials]
material_names = [material.name for material in self.materials]
materials_str = t("comma_separator").join(material_names)
info_parts.append(t("Drops: {materials}", materials=materials_str))

View File

@@ -7,6 +7,7 @@ class DeathType(Enum):
OLD_AGE = "old_age"
BATTLE = "battle"
SERIOUS_INJURY = "serious_injury"
HIDDEN_DOMAIN = "hidden_domain"
@dataclass
class DeathReason:
@@ -20,6 +21,8 @@ class DeathReason:
return t("Killed by {killer}", killer=killer)
elif self.death_type == DeathType.SERIOUS_INJURY:
return t("Died from severe injuries")
elif self.death_type == DeathType.HIDDEN_DOMAIN:
return t("Perished in a Hidden Domain")
elif self.death_type == DeathType.OLD_AGE:
return t("Died of old age")
return t(self.death_type.value)

View File

@@ -364,6 +364,30 @@ EXTRA_PLUNDER_MULTIPLIER = "extra_plunder_multiplier"
- 大量: 2
"""
# 秘境相关
EXTRA_HIDDEN_DOMAIN_DROP_PROB = "extra_hidden_domain_drop_prob"
"""
额外秘境掉落概率
类型: float
结算: src/classes/gathering/hidden_domain.py
说明: 增加在秘境中获得宝物的概率。
数值参考:
- 微量: 0.05
- 中量: 0.1
- 大量: 0.2
"""
EXTRA_HIDDEN_DOMAIN_DANGER_PROB = "extra_hidden_domain_danger_prob"
"""
额外秘境危险概率
类型: float
结算: src/classes/gathering/hidden_domain.py
说明: 增加(或减少,负值)在秘境中遇到危险的概率。
数值参考:
- 降低危险: -0.1 (降低10%危险率)
- 增加危险: 0.1
"""
# --- 特殊权限 ---
LEGAL_ACTIONS = "legal_actions"
"""
@@ -479,6 +503,10 @@ ALL_EFFECTS = [
"shop_buy_price_reduction", # float - 商铺购买价格倍率减免
"extra_plunder_multiplier", # float - 额外搜刮收益倍率
# 秘境相关
"extra_hidden_domain_drop_prob", # float - 额外秘境掉落概率
"extra_hidden_domain_danger_prob", # float - 额外秘境危险概率
# 特殊权限
"legal_actions", # list[str] - 合法动作列表
]

View File

@@ -37,6 +37,8 @@ def get_effect_desc(effect_key: str) -> str:
"cultivate_duration_reduction": "effect_cultivate_duration_reduction",
"extra_cast_success_rate": "effect_extra_cast_success_rate",
"extra_refine_success_rate": "effect_extra_refine_success_rate",
"extra_hidden_domain_drop_prob": "effect_extra_hidden_domain_drop_prob",
"extra_hidden_domain_danger_prob": "effect_extra_hidden_domain_danger_prob",
}
msgid = msgid_map.get(effect_key, effect_key)
@@ -189,7 +191,7 @@ def format_effects_to_text(effects: dict[str, Any] | list[dict[str, Any]]) -> st
# 如果有条件,添加条件描述
if effects.get("when"):
cond = translate_condition(str(effects["when"]))
return t("[{condition}] {effects}", condition=cond, effects=text)
return f"[{cond}] {text}"
return text

View File

@@ -1,2 +1,3 @@
from .gathering import Gathering, GatheringManager
from .auction import Auction
from .hidden_domain import HiddenDomain

View File

@@ -0,0 +1,328 @@
from typing import List, Dict, Optional, Any, TYPE_CHECKING
import random
import asyncio
from dataclasses import dataclass
from src.classes.gathering.gathering import Gathering, register_gathering
from src.classes.event import Event
if TYPE_CHECKING:
from src.classes.world import World
from src.classes.avatar import Avatar
from src.classes.item import Item
from src.utils.df import game_configs, get_str, get_float, get_int
from src.classes.cultivation import Realm, REALM_ORDER, REALM_RANK
from src.classes.death_reason import DeathReason, DeathType
from src.classes.death import handle_death
from src.classes.weapon import get_random_weapon_by_realm
from src.classes.auxiliary import get_random_auxiliary_by_realm
from src.classes.technique import get_random_technique_for_avatar
from src.i18n import t
from src.run.log import get_logger
logger = get_logger().logger
@dataclass
class DomainConfig:
id: str
name: str
desc: str
max_realm: Realm
danger_prob: float
hp_loss_percent: float
drop_prob: float
cd_years: int
open_prob: float
@register_gathering
class HiddenDomain(Gathering):
"""
秘境系统 (Hidden Domain)
定期开启,符合境界条件的修士可进入探索,面临凶险或获得机缘。
"""
# 记录每个秘境上次开启的年份 {domain_id: last_open_year}
_domain_states: Dict[str, int] = {}
# 临时存储本轮开启的秘境
_active_domains: List[DomainConfig] = []
# LLM Prompt ID
STORY_PROMPT_ID = "hidden_domain_story_prompt"
@classmethod
def get_story_prompt(cls) -> str:
return t(cls.STORY_PROMPT_ID)
def _load_configs(self) -> List[DomainConfig]:
"""从配置表加载秘境配置"""
configs = []
df = game_configs.get("hidden_domain")
if df is None:
return []
for row in df:
try:
# 必须字段
conf = DomainConfig(
id=get_str(row, "id"),
name=get_str(row, "name"),
desc=get_str(row, "desc"),
max_realm=Realm.from_str(get_str(row, "max_realm")),
danger_prob=get_float(row, "danger_prob"),
hp_loss_percent=get_float(row, "hp_loss_percent"),
drop_prob=get_float(row, "drop_prob"),
cd_years=get_int(row, "cd_years"),
open_prob=get_float(row, "open_prob"),
)
configs.append(conf)
except Exception as e:
logger.error(f"Failed to load hidden domain config: {e}")
continue
return configs
def is_start(self, world: "World") -> bool:
"""
判断是否有秘境开启
"""
self._active_domains = []
current_year = world.month_stamp.get_year()
configs = self._load_configs()
for conf in configs:
last_open = self._domain_states.get(conf.id, -999)
# 只有 CD 转好才进行概率判定
if current_year - last_open >= conf.cd_years:
if random.random() < conf.open_prob:
self._active_domains.append(conf)
# 立即更新状态防止同一step多次调用导致状态不一致虽然后续execute才算正式执行
# 但GatheringManager是 is_start -> execute 顺序执行,所以这里更新没问题
# 如果需要execute失败回滚可以把更新移到execute里。这里简单起见放在这里。
self._domain_states[conf.id] = current_year
return len(self._active_domains) > 0
def get_related_avatars(self, world: "World") -> List[int]:
"""
获取所有可能参与的角色(即所有存活角色,具体筛选在 execute 中按秘境条件进行)
"""
return [av.id for av in world.avatar_manager.get_living_avatars()]
def get_info(self, world: "World") -> str:
details = []
for conf in self._active_domains:
detail = t("Hidden Domain {name} opened! Entry restricted to {realm} and below.",
name=conf.name,
realm=str(conf.max_realm))
details.append(detail)
return t("Hidden Domains opened: {names}", names="\n".join(details))
def _get_next_realm(self, realm: Realm) -> Optional[Realm]:
"""获取下一个大境界"""
current_idx = REALM_RANK.get(realm)
if current_idx is not None and current_idx + 1 < len(REALM_ORDER):
return REALM_ORDER[current_idx + 1]
return None
def _generate_loot(self, avatar: "Avatar", next_realm: Realm) -> Optional[Item]:
"""生成掉落物:优先给予高一阶的物品"""
# 掉落类型权重:兵器(40%), 防具(40%), 功法(20%)
roll = random.random()
loot = None
if roll < 0.4:
# 兵器
loot = get_random_weapon_by_realm(next_realm)
elif roll < 0.8:
# 防具
loot = get_random_auxiliary_by_realm(next_realm)
else:
# 功法:尝试获取更高级的功法
# get_random_technique_for_avatar 根据灵根匹配,但这里我们希望给一点“机缘”
# 复用该函数,但可能获取到同阶的。为了体现“机缘”,我们允许多试几次取最好的,或者直接给
# 这里简单调用现有接口
loot = get_random_technique_for_avatar(avatar)
return loot
async def execute(self, world: "World") -> List[Event]:
events = []
for domain in self._active_domains:
domain_events = await self._process_single_domain(world, domain)
events.extend(domain_events)
return events
async def _process_single_domain(self, world: "World", domain: DomainConfig) -> List[Event]:
"""处理单个秘境的逻辑"""
events = []
month_stamp = world.month_stamp
# 1. 筛选进入秘境的角色
entrants: List["Avatar"] = []
for av in world.avatar_manager.get_living_avatars():
# 境界判定realm <= max (取消最低限制,允许越阶挑战)
if av.cultivation_progress.realm <= domain.max_realm:
entrants.append(av)
# 添加开启事件
entrants_names = [av.name for av in entrants]
if entrants_names:
entrants_str = ", ".join(entrants_names)
open_event_content = t("Hidden Domain {name} opened! Entry restricted to {realm} and below. Entrants: {entrants}",
name=domain.name,
realm=str(domain.max_realm),
entrants=entrants_str)
else:
open_event_content = t("Hidden Domain {name} opened! Entry restricted to {realm} and below. No one entered.",
name=domain.name,
realm=str(domain.max_realm))
events.append(Event(month_stamp, open_event_content))
if not entrants:
return events
# 记录本次秘境的事件文本和相关角色
event_texts: List[str] = [open_event_content]
related_avatars_set: set["Avatar"] = set()
# 2. 遍历角色执行逻辑
for av in entrants:
# --- 效果结算 ---
extra_drop = float(av.effects.get("extra_hidden_domain_drop_prob", 0.0))
extra_danger = float(av.effects.get("extra_hidden_domain_danger_prob", 0.0))
drop_prob = domain.drop_prob + extra_drop
danger_prob = domain.danger_prob + extra_danger
# 确保概率合理
danger_prob = max(0.0, danger_prob)
# --- 凶险判定 ---
if random.random() < danger_prob:
loss_percent = domain.hp_loss_percent
damage = int(av.hp.max * loss_percent)
av.hp.cur -= damage
if av.hp.cur <= 0:
# 死亡结算
reason = DeathReason(DeathType.HIDDEN_DOMAIN)
handle_death(world, av, reason)
event_content = t("{name} perished in the hidden domain {domain}.", name=av.name, domain=domain.name)
event = Event(
month_stamp,
event_content,
related_avatars=[av.id]
)
events.append(event)
event_texts.append(event_content)
related_avatars_set.add(av)
continue # 死了就不能拿奖励了
# --- 机缘判定 ---
if random.random() < drop_prob:
# 获取奖励阶位(高一阶)
target_realm = self._get_next_realm(av.cultivation_progress.realm)
# 如果已经是最高阶,则维持当前阶位
if not target_realm:
target_realm = av.cultivation_progress.realm
loot = self._generate_loot(av, target_realm)
if loot:
# 发放奖励
from src.classes.weapon import Weapon
from src.classes.auxiliary import Auxiliary
from src.classes.technique import Technique
from src.classes.prices import prices
loot_name = loot.name
if isinstance(loot, Weapon):
old = av.weapon
av.change_weapon(loot)
if old: # 回收旧物
av.magic_stone += prices.get_selling_price(old, av)
elif isinstance(loot, Auxiliary):
old = av.auxiliary
av.change_auxiliary(loot)
if old:
av.magic_stone += prices.get_selling_price(old, av)
elif isinstance(loot, Technique):
# 只有当比当前功法好,或者还没功法时才更换?
# 或者直接放入背包(如果有)?目前 Avatar 没有通用背包,通常直接修习
# 简化逻辑:直接修习
av.technique = loot
# 记录事件
event_content = t("{name} found a treasure {loot} in {domain}!", name=av.name, loot=loot_name, domain=domain.name)
event = Event(
month_stamp,
event_content,
related_avatars=[av.id]
)
events.append(event)
event_texts.append(event_content)
related_avatars_set.add(av)
# 3. 生成故事 (StoryTeller)
# 只有当发生了一些值得记录的事情(死人、或者有人获得重宝)才生成故事,避免刷屏
if event_texts:
story_event = await self._generate_story(world, domain, event_texts, list(related_avatars_set))
if story_event:
events.append(story_event)
return events
async def _generate_story(
self,
world: "World",
domain: DomainConfig,
event_texts: List[str],
related_avatars: List["Avatar"]
) -> Optional[Event]:
"""调用 LLM 生成秘境探索故事"""
if not related_avatars:
return None
# 1. 场景描述
gathering_info = t(
"Event: Hidden Domain Opening\nName: {name}\nDescription: {desc}",
name=domain.name, desc=domain.desc
)
# 2. 事件列表
events_str = "\n".join(event_texts)
# 3. 角色信息 (可选,增加故事细节)
details_list = []
details_list.append(t("【Related Avatars Information】"))
for av in related_avatars:
info = av.get_info(detailed=True)
details_list.append(f"- {av.name}: {info}")
details_text = "\n".join(details_list)
# 4. 调用 StoryTeller
from src.classes.story_teller import StoryTeller
story = await StoryTeller.tell_gathering_story(
gathering_info=gathering_info,
events_text=events_str,
details_text=details_text,
related_avatars=related_avatars,
prompt=self.get_story_prompt()
)
return Event(
month_stamp=world.month_stamp,
content=story,
related_avatars=[av.id for av in related_avatars],
is_major=True
)

View File

@@ -37,11 +37,11 @@ class Lode:
"""
from src.i18n import t
# 使用格式化字符串 msgid
base_info = t("[{name}] ({realm})", name=t(self.name), realm=str(self.realm))
info_parts = [base_info, t(self.desc)]
base_info = t("[{name}] ({realm})", name=self.name, realm=str(self.realm))
info_parts = [base_info, self.desc]
if self.materials:
material_names = [t(material.name) for material in self.materials]
material_names = [material.name for material in self.materials]
materials_str = t("comma_separator").join(material_names)
info_parts.append(t("Drops: {materials}", materials=materials_str))

View File

@@ -25,11 +25,11 @@ class Material(Item):
def get_info(self) -> str:
from src.i18n import t
return t("{name} ({realm})", name=t(self.name), realm=str(self.realm))
return t("{name} ({realm})", name=self.name, realm=str(self.realm))
def get_detailed_info(self) -> str:
from src.i18n import t
return t("{name}: {desc} ({realm})", name=t(self.name), desc=t(self.desc), realm=str(self.realm))
return t("{name}: {desc} ({realm})", name=self.name, desc=self.desc, realm=str(self.realm))
def get_structured_info(self) -> dict:
return {

View File

@@ -38,11 +38,11 @@ class Plant:
"""
from src.i18n import t
# 使用格式化字符串 msgid
base_info = t("[{name}] ({realm})", name=t(self.name), realm=str(self.realm))
info_parts = [base_info, t(self.desc)]
base_info = t("[{name}] ({realm})", name=self.name, realm=str(self.realm))
info_parts = [base_info, self.desc]
if self.materials:
material_names = [t(material.name) for material in self.materials]
material_names = [material.name for material in self.materials]
materials_str = t("comma_separator").join(material_names)
info_parts.append(t("Drops: {materials}", materials=materials_str))

View File

@@ -327,8 +327,8 @@ def get_sect_info_with_rank(avatar: "Avatar", detailed: bool = False) -> str:
# 构造详细信息,使用标准空格和括号
detail_content = t("(Alignment: {alignment}, Style: {style}, Headquarters: {hq_name}){effect}",
alignment=avatar.sect.alignment,
style=t(avatar.sect.member_act_style),
hq_name=t(hq.name),
style=avatar.sect.member_act_style,
hq_name=hq.name,
effect=effect_part)
return f"{sect_rank_str} {detail_content}"

View File

@@ -116,7 +116,7 @@ def get_rank_display_name(rank: SectRank, sect: Optional["Sect"] = None) -> str:
if sect is not None:
custom_name = sect.get_rank_name(rank)
if custom_name:
return t(custom_name)
return custom_name
val = DEFAULT_RANK_NAMES.get(rank, "弟子")
return t(val)

View File

@@ -106,21 +106,21 @@ class Technique:
if detailed:
return self.get_detailed_info()
from src.i18n import t
return t("{name} ({attribute}) {grade}", name=t(self.name), attribute=str(self.attribute), grade=str(self.grade))
return t("{name} ({attribute}) {grade}", name=self.name, attribute=str(self.attribute), grade=str(self.grade))
def get_detailed_info(self) -> str:
from src.i18n import t
effect_part = t(" Effect: {effect_desc}", effect_desc=self.effect_desc) if self.effect_desc else ""
return t("{name} ({attribute}) {grade} {desc}{effect}",
name=t(self.name), attribute=str(self.attribute), grade=str(self.grade),
desc=t(self.desc), effect=effect_part)
name=self.name, attribute=str(self.attribute), grade=str(self.grade),
desc=self.desc, effect=effect_part)
def get_colored_info(self) -> str:
"""获取带颜色标记的信息,供前端渲染使用"""
from src.i18n import t
r, g, b = self.grade.color_rgb
# 使用与 get_info 相同的格式,但带有颜色标签
info = t("{name} ({attribute}·{grade})", name=t(self.name), attribute=str(self.attribute), grade=str(self.grade))
info = t("{name} ({attribute}·{grade})", name=self.name, attribute=str(self.attribute), grade=str(self.grade))
return f"<color:{r},{g},{b}>{info}</color>"
def get_structured_info(self) -> dict:

View File

@@ -44,8 +44,8 @@ class Weapon(Item):
from src.i18n import t
effect_part = t(" Effect: {effect_desc}", effect_desc=self.effect_desc) if self.effect_desc else ""
return t("{name} ({type}·{realm}, {desc}){effect}",
name=t(self.name), type=str(self.weapon_type), realm=str(self.realm),
desc=t(self.desc), effect=effect_part)
name=self.name, type=str(self.weapon_type), realm=str(self.realm),
desc=self.desc, effect=effect_part)
def get_colored_info(self) -> str:
"""获取带颜色标记的信息,供前端渲染使用"""

View File

@@ -35,6 +35,8 @@ class World():
gathering_manager: GatheringManager = field(default_factory=GatheringManager)
# 世界历史
history: "History" = field(default_factory=lambda: History())
# 世界开始年份
start_year: int = 0
def get_info(self, detailed: bool = False, avatar: Optional["Avatar"] = None) -> dict:
"""
@@ -106,6 +108,7 @@ class World():
map: "Map",
month_stamp: MonthStamp,
events_db_path: Path,
start_year: int = 0,
) -> "World":
"""
工厂方法:创建使用 SQLite 持久化事件的 World 实例。
@@ -114,6 +117,7 @@ class World():
map: 地图对象。
month_stamp: 时间戳。
events_db_path: 事件数据库文件路径。
start_year: 世界开始年份。
Returns:
配置好的 World 实例。
@@ -123,4 +127,5 @@ class World():
map=map,
month_stamp=month_stamp,
event_manager=event_manager,
start_year=start_year,
)

View File

@@ -1626,6 +1626,18 @@ msgstr "Refine Success Rate"
msgid "effect_extra_hidden_domain_drop_prob"
msgstr "Hidden Domain Drop Rate"
msgid "effect_extra_hidden_domain_danger_prob"
msgstr "Hidden Domain Danger Rate"
# Action Short Names (for legal_actions)
msgid "action_dualcultivation_short_name"
@@ -2086,7 +2098,7 @@ msgstr "\n【Related Avatars Information】"
msgid "auction_story_prompt"
msgstr "Select the most interesting aspect or bidding moment to describe, no need to cover everything."
msgstr "Select the most interesting aspect or a single bid to describe, without needing to cover everything. Focus on describing the tense atmosphere during the bidding process."
@@ -3376,3 +3388,37 @@ msgstr "Two people are holding an elegant tea party."
msgid "action_chess_story_prompt"
msgstr "Two people are playing chess."
# ============================================================================
# Hidden Domain
# ============================================================================
msgid "hidden_domain_story_prompt"
msgstr "Describe the exploration of the hidden domain, focusing on the dangers encountered or the opportunities seized, creating a mysterious and tense atmosphere."
msgid "Hidden Domains opened: {names}"
msgstr "{names}"
msgid "Hidden Domain {name} opened! Entry restricted to {realm} and below."
msgstr "Hidden Domain {name} opened! Entry restricted to {realm} and below."
msgid "{name} perished in the hidden domain {domain}."
msgstr "{name} perished in the hidden domain {domain}."
msgid "{name} found a treasure {loot} in {domain}!"
msgstr "{name} found a treasure {loot} in {domain}!"
msgid "Perished in a Hidden Domain"
msgstr "Perished in a Hidden Domain"
msgid "Event: Hidden Domain Opening\nName: {name}\nDescription: {desc}"
msgstr "Event: Hidden Domain Opening\nName: {name}\nDescription: {desc}"
msgid "Hidden Domain {name} opened! Entry restricted to {realm} and below. Entrants: {entrants}"
msgstr "Hidden Domain {name} opened! Entry restricted to {realm} and below. Entrants: {entrants}"
msgid "Hidden Domain {name} opened! Entry restricted to {realm} and below. No one entered."
msgstr "Hidden Domain {name} opened! Entry restricted to {realm} and below. No one entered."
msgid "【Related Avatars Information】"
msgstr "【Related Avatars Information】"

View File

@@ -1191,6 +1191,12 @@ msgstr "铸造成功率"
msgid "effect_extra_refine_success_rate"
msgstr "炼丹成功率"
msgid "effect_extra_hidden_domain_drop_prob"
msgstr "秘境掉落概率"
msgid "effect_extra_hidden_domain_danger_prob"
msgstr "秘境凶险概率"
# Action 简短名称(用于 legal_actions
msgid "action_dualcultivation_short_name"
msgstr "双修"
@@ -1422,7 +1428,7 @@ msgstr "\n【相关角色信息】"
# LLM Prompt
msgid "auction_story_prompt"
msgstr "选取其中最有趣的一个侧面或一次竞价进行描写,无需面面俱到。"
msgstr "选取其中最有趣的一个侧面或一次竞价进行描写,无需面面俱到。重点描写竞价过程中的紧张气氛。"
# ============================================================================
# Fortune 系统 - 奇遇
@@ -2222,3 +2228,38 @@ msgstr "两人正在举行一场雅致的茶会。"
msgid "action_chess_story_prompt"
msgstr "两人正在对弈下棋。"
# ============================================================================
# Hidden Domain (秘境)
# ============================================================================
msgid "hidden_domain_story_prompt"
msgstr "描述探索秘境的过程,侧重于遭遇的凶险或获得的机缘,营造神秘和紧张的氛围。"
msgid "Hidden Domains opened: {names}"
msgstr "{names}"
msgid "Hidden Domain {name} opened! Entry restricted to {realm} and below."
msgstr "秘境【{name}】现世!限制{realm}及以下修士进入。"
msgid "{name} perished in the hidden domain {domain}."
msgstr "{name} 葬身于秘境【{domain}】。"
msgid "{name} found a treasure {loot} in {domain}!"
msgstr "{name} 在秘境【{domain}】中觅得宝物【{loot}】!"
msgid "Perished in a Hidden Domain"
msgstr "在秘境中陨落"
msgid "Event: Hidden Domain Opening\nName: {name}\nDescription: {desc}"
msgstr "事件:秘境开启\n名称{name}\n描述{desc}"
msgid "Hidden Domain {name} opened! Entry restricted to {realm} and below. Entrants: {entrants}"
msgstr "秘境【{name}】现世!限制{realm}及以下修士进入。进入者:{entrants}。"
msgid "Hidden Domain {name} opened! Entry restricted to {realm} and below. No one entered."
msgstr "秘境【{name}】现世!限制{realm}及以下修士进入。无修士进入。"
msgid "【Related Avatars Information】"
msgstr "【相关角色信息】"

View File

@@ -342,10 +342,12 @@ async def init_game_async():
game_instance["current_save_path"] = save_path
print(f"事件数据库: {events_db_path}")
start_year = getattr(CONFIG.game, "start_year", 100)
world = World.create_with_db(
map=game_map,
month_stamp=create_month_stamp(Year(100), Month.JANUARY),
month_stamp=create_month_stamp(Year(start_year), Month.JANUARY),
events_db_path=events_db_path,
start_year=start_year,
)
sim = Simulator(world)

View File

@@ -194,6 +194,7 @@ def load_game(save_path: Optional[Path] = None) -> Tuple["World", "Simulator", L
# 读取世界数据
world_data = save_data.get("world", {})
month_stamp = MonthStamp(world_data["month_stamp"])
start_year = world_data.get("start_year", 100)
# 计算事件数据库路径。
events_db_path = get_events_db_path(save_path)
@@ -203,6 +204,7 @@ def load_game(save_path: Optional[Path] = None) -> Tuple["World", "Simulator", L
map=game_map,
month_stamp=month_stamp,
events_db_path=events_db_path,
start_year=start_year,
)
# 恢复世界历史

View File

@@ -117,6 +117,7 @@ def save_game(
world_data = {
"month_stamp": int(world.month_stamp),
"start_year": world.start_year,
"existed_sect_ids": [sect.id for sect in existed_sects],
# 天地灵机
"current_phenomenon_id": world.current_phenomenon.id if world.current_phenomenon else None,

View File

@@ -267,6 +267,10 @@ class Simulator:
Gathering 结算阶段:
检查并执行注册的多人聚集事件(如拍卖会、大比等)。
"""
# 第一年不触发聚集事件,给予发育缓冲
if self.world.month_stamp.get_year() <= self.world.start_year:
return []
return await self.world.gathering_manager.check_and_run_all(self.world)
def _phase_update_celestial_phenomenon(self):

View File

@@ -26,6 +26,7 @@ ai:
game:
init_npc_num: 9
sect_num: 3 # init_npc_num大于sect_num时会随机选择sect_num个宗门
start_year: 100 # 世界开始年份
npc_awakening_rate_per_month: 0.01
fortune_probability: 0.005
misfortune_probability: 0.005

View File

@@ -0,0 +1,5 @@
id,name,desc,max_realm,danger_prob,hp_loss_percent,drop_prob,cd_years,open_prob
,Name,Description,Max Realm,Danger Probability,HP Loss %,Drop Probability,CD (Years),Open Probability (Monthly)
hidden_domain_wood,Remnant of Myriad Woods,"A fragment of the ancient Azure Emperor's Garden that shattered eons ago, now perpetually shrouded in multicolored, toxic miasma. The broken laws within cause spirit plants to grow madly, with shapeshifting demon vines and man-eating flowers lurking in the shadows. Rumor has it that if one can traverse the outer ""Dead Silence Forest"" and find the ""Azure Wood Spirit Spring"" at the core, even mortal grass can become immortal medicine. However, one must beware of the guardian beasts, twisted by millennia of consuming medicinal dregs, who thirst for fresh blood.",QI_REFINEMENT,0.3,0.3,0.2,5,0.05
hidden_domain_fire,Valley of Fallen Immortals,"The site where a human mighty figure perished together with the Flame Demon Emperor ten thousand years ago. To this day, the earth veins still spew unextinguishable Earthly Baleful Fire, dyeing the sky blood-red. Buried beneath the scorched earth are countless broken swords and halberds, and at midnight, battle souls roar, reenacting the tragic slaughter of the past. Training here requires enduring the constant pain of fire poison eroding the heart, but it is excellent for comprehending ancient battle intent. Those with deep destiny might even pick up fragment artifacts left by ancient cultivators from the magma depths or glimpse a trace of the Great Dao.",FOUNDATION_ESTABLISHMENT,0.4,0.4,0.15,10,0.03
hidden_domain_palace,Nine Heavens Void Palace,"A magnificent complex of palaces floating above the Nine Heavens Wind Layer. Only those with great perseverance can pass through the spatial rifts on the periphery that tear at the flesh. The interior is filled with ancient restrictions and layered illusion arrays; one wrong step means body and soul destruction, lost forever in the void. Although the Scripture Pavilion has collapsed over time, every page of the golden books and jade scrolls scattering in the void turbulence records supreme supernatural powers capable of causing a bloodbath in the cultivation world. Only peak Core Formation cultivators can barely withstand the pressure from the ancient times to spy a glimpse of immortal opportunity.",CORE_FORMATION,0.5,0.5,0.1,20,0.01
1 id name desc max_realm danger_prob hp_loss_percent drop_prob cd_years open_prob
2 Name Description Max Realm Danger Probability HP Loss % Drop Probability CD (Years) Open Probability (Monthly)
3 hidden_domain_wood Remnant of Myriad Woods A fragment of the ancient Azure Emperor's Garden that shattered eons ago, now perpetually shrouded in multicolored, toxic miasma. The broken laws within cause spirit plants to grow madly, with shapeshifting demon vines and man-eating flowers lurking in the shadows. Rumor has it that if one can traverse the outer "Dead Silence Forest" and find the "Azure Wood Spirit Spring" at the core, even mortal grass can become immortal medicine. However, one must beware of the guardian beasts, twisted by millennia of consuming medicinal dregs, who thirst for fresh blood. QI_REFINEMENT 0.3 0.3 0.2 5 0.05
4 hidden_domain_fire Valley of Fallen Immortals The site where a human mighty figure perished together with the Flame Demon Emperor ten thousand years ago. To this day, the earth veins still spew unextinguishable Earthly Baleful Fire, dyeing the sky blood-red. Buried beneath the scorched earth are countless broken swords and halberds, and at midnight, battle souls roar, reenacting the tragic slaughter of the past. Training here requires enduring the constant pain of fire poison eroding the heart, but it is excellent for comprehending ancient battle intent. Those with deep destiny might even pick up fragment artifacts left by ancient cultivators from the magma depths or glimpse a trace of the Great Dao. FOUNDATION_ESTABLISHMENT 0.4 0.4 0.15 10 0.03
5 hidden_domain_palace Nine Heavens Void Palace A magnificent complex of palaces floating above the Nine Heavens Wind Layer. Only those with great perseverance can pass through the spatial rifts on the periphery that tear at the flesh. The interior is filled with ancient restrictions and layered illusion arrays; one wrong step means body and soul destruction, lost forever in the void. Although the Scripture Pavilion has collapsed over time, every page of the golden books and jade scrolls scattering in the void turbulence records supreme supernatural powers capable of causing a bloodbath in the cultivation world. Only peak Core Formation cultivators can barely withstand the pressure from the ancient times to spy a glimpse of immortal opportunity. CORE_FORMATION 0.5 0.5 0.1 20 0.01

View File

@@ -30,7 +30,7 @@ id,key,name,exclusion_keys,desc,rarity,condition,effects
28,SIMP,Simp,ALOOF;INDIFFERENT;MEAN;SHY,"You are exceptionally friendly to those of the opposite sex with outstanding appearance.",N,,
29,ENVIOUS,Envious,FRIENDLY;ENTHUSIASTIC,"You easily harbor hostility towards those who far exceed you.",N,,
30,TRANSMIGRATOR,Transmigrator,,"You come from modern society and miss everything about it.",SR,,
31,CHILD_OF_FORTUNE,Child of Fortune,,"Naturally blessed with fortune, more likely to encounter lucky breaks.",SSR,,"{extra_fortune_probability: 0.02, extra_battle_strength_points: 2, extra_breakthrough_success_rate: 0.05}"
31,CHILD_OF_FORTUNE,Child of Fortune,,"Naturally blessed with fortune, more likely to encounter lucky breaks.",SSR,,"{extra_fortune_probability: 0.02, extra_battle_strength_points: 2, extra_breakthrough_success_rate: 0.05, extra_hidden_domain_drop_prob: 0.2, extra_hidden_domain_danger_prob: -0.1}"
32,SWORD_FANATIC,Sword Fanatic,LAZY;TIMID,"Entering the Dao through the sword, the sword is your life.",SR,,"[{when: 'avatar.weapon.type == ""SWORD""', extra_weapon_proficiency_gain: 1.0, extra_battle_strength_points: 3}]"
33,SCHEMING,Scheming,RASH;ENTHUSIASTIC;EXTROVERTED,"Hiding your true self, acting cautiously, and good at calculating.",R,,
34,LOYAL,Loyal,ALOOF;INDIFFERENT;MEAN,"Values promises and integrity, never betrays the person you've acknowledged.",R,,
1 id key name exclusion_keys desc rarity condition effects
30 28 SIMP Simp ALOOF;INDIFFERENT;MEAN;SHY You are exceptionally friendly to those of the opposite sex with outstanding appearance. N
31 29 ENVIOUS Envious FRIENDLY;ENTHUSIASTIC You easily harbor hostility towards those who far exceed you. N
32 30 TRANSMIGRATOR Transmigrator You come from modern society and miss everything about it. SR
33 31 CHILD_OF_FORTUNE Child of Fortune Naturally blessed with fortune, more likely to encounter lucky breaks. SSR {extra_fortune_probability: 0.02, extra_battle_strength_points: 2, extra_breakthrough_success_rate: 0.05} {extra_fortune_probability: 0.02, extra_battle_strength_points: 2, extra_breakthrough_success_rate: 0.05, extra_hidden_domain_drop_prob: 0.2, extra_hidden_domain_danger_prob: -0.1}
34 32 SWORD_FANATIC Sword Fanatic LAZY;TIMID Entering the Dao through the sword, the sword is your life. SR [{when: 'avatar.weapon.type == "SWORD"', extra_weapon_proficiency_gain: 1.0, extra_battle_strength_points: 3}]
35 33 SCHEMING Scheming RASH;ENTHUSIASTIC;EXTROVERTED Hiding your true self, acting cautiously, and good at calculating. R
36 34 LOYAL Loyal ALOOF;INDIFFERENT;MEAN Values promises and integrity, never betrays the person you've acknowledged. R

View File

@@ -15,3 +15,4 @@ Action,You have a series of actionable commands. Pay attention to effects, restr
Equipment & Elixir,Weapons, auxiliary equipment, and elixirs provide stat bonuses. Owning good gear or taking high-quality elixirs brings great benefits.
Shopping,You can buy Qi Refinement level elixirs and weapons in City regions. Elixirs are consumed immediately for buffs. Buying weapons switches your weapon type.
Auction,Mysterious auctions occur at uncertain intervals, potentially offering rare goods.
Hidden Domain,Hidden domains open every few years. Exploring them entails risks but may yield high-ranking artifacts and techniques.
1 title desc
15 Equipment & Elixir Weapons
16 Shopping You can buy Qi Refinement level elixirs and weapons in City regions. Elixirs are consumed immediately for buffs. Buying weapons switches your weapon type.
17 Auction Mysterious auctions occur at uncertain intervals
18 Hidden Domain Hidden domains open every few years. Exploring them entails risks but may yield high-ranking artifacts and techniques.

View File

@@ -0,0 +1,5 @@
id,name,desc,max_realm,danger_prob,hp_loss_percent,drop_prob,cd_years,open_prob
,名称,描述,最高境界,遇险概率,扣血比例,掉落概率,CD(年),开启概率(月)
hidden_domain_wood,万木残界,"此乃上古丹宗“青帝苑”崩碎后遗落的一角碎片,终年笼罩在七彩斑斓的剧毒瘴气之中。界内法则破碎,导致灵植疯狂生长,更有化形的妖藤食人花潜伏于阴影伺机而动。传闻若能穿过外围的“死寂林”,寻得核心处尚未枯竭的‘乙木灵泉’,凡草亦可化仙药,洗髓伐骨不在话下。但也需时刻提防那些被药渣滋养万年、早已畸变扭曲的守护妖兽,它们对鲜血有着病态的渴望。",QI_REFINEMENT,0.3,0.3,0.2,5,0.05
hidden_domain_fire,陨仙焚谷,"万年前人族大能与炎魔皇同归于尽之地,至今地脉仍喷涌着不灭的地煞真火,将苍穹染成血红。焦土之下掩埋着无数断剑残戟,夜半时分更有战魂嘶吼,重演当年的惨烈厮杀。在此历练虽需时刻忍受火毒噬心之苦,甚至可能被狂暴的火灵力焚为灰烬,却极易感悟上古战意。若机缘深厚,甚至能从岩浆深处拾得古修遗落的法宝残片,或窥见一丝大道法则。",FOUNDATION_ESTABLISHMENT,0.4,0.4,0.15,10,0.03
hidden_domain_palace,九天虚空殿,"漂浮于九天罡风层之上的宏伟宫殿群,非有大毅力者不可通过外围撕裂肉身的空间裂缝。殿内布满上古禁制与层叠幻阵,一步踏错便是形神俱灭,永坠虚空。虽藏经阁已在岁月中坍塌,但那些飘散在虚空乱流中的每一页金书玉册,都记载着足以引发修真界血雨腥风的无上神通。唯有金丹巅峰修士,方能勉强抵御那股来自远古的威压,窥探一丝仙机。",CORE_FORMATION,0.5,0.5,0.1,20,0.01
1 id name desc max_realm danger_prob hp_loss_percent drop_prob cd_years open_prob
2 名称 描述 最高境界 遇险概率 扣血比例 掉落概率 CD(年) 开启概率(月)
3 hidden_domain_wood 万木残界 此乃上古丹宗“青帝苑”崩碎后遗落的一角碎片,终年笼罩在七彩斑斓的剧毒瘴气之中。界内法则破碎,导致灵植疯狂生长,更有化形的妖藤食人花潜伏于阴影伺机而动。传闻若能穿过外围的“死寂林”,寻得核心处尚未枯竭的‘乙木灵泉’,凡草亦可化仙药,洗髓伐骨不在话下。但也需时刻提防那些被药渣滋养万年、早已畸变扭曲的守护妖兽,它们对鲜血有着病态的渴望。 QI_REFINEMENT 0.3 0.3 0.2 5 0.05
4 hidden_domain_fire 陨仙焚谷 万年前人族大能与炎魔皇同归于尽之地,至今地脉仍喷涌着不灭的地煞真火,将苍穹染成血红。焦土之下掩埋着无数断剑残戟,夜半时分更有战魂嘶吼,重演当年的惨烈厮杀。在此历练虽需时刻忍受火毒噬心之苦,甚至可能被狂暴的火灵力焚为灰烬,却极易感悟上古战意。若机缘深厚,甚至能从岩浆深处拾得古修遗落的法宝残片,或窥见一丝大道法则。 FOUNDATION_ESTABLISHMENT 0.4 0.4 0.15 10 0.03
5 hidden_domain_palace 九天虚空殿 漂浮于九天罡风层之上的宏伟宫殿群,非有大毅力者不可通过外围撕裂肉身的空间裂缝。殿内布满上古禁制与层叠幻阵,一步踏错便是形神俱灭,永坠虚空。虽藏经阁已在岁月中坍塌,但那些飘散在虚空乱流中的每一页金书玉册,都记载着足以引发修真界血雨腥风的无上神通。唯有金丹巅峰修士,方能勉强抵御那股来自远古的威压,窥探一丝仙机。 CORE_FORMATION 0.5 0.5 0.1 20 0.01

View File

@@ -30,7 +30,7 @@ id,key,name,exclusion_keys,desc,rarity,condition,effects
28,SIMP,舔狗,ALOOF;INDIFFERENT;MEAN;SHY,你对异性中外貌出众者格外友善。,N,,
29,ENVIOUS,嫉妒,FRIENDLY;ENTHUSIASTIC,你对远超于你的人容易产生敌意。,N,,
30,TRANSMIGRATOR,穿越者,,你来自现代社会,怀念现代社会的一切。,SR,,
31,CHILD_OF_FORTUNE,气运之子,,天生气运加身,更易遇到奇遇。,SSR,,"{extra_fortune_probability: 0.02, extra_battle_strength_points: 2, extra_breakthrough_success_rate: 0.05}"
31,CHILD_OF_FORTUNE,气运之子,,天生气运加身,更易遇到奇遇。,SSR,,"{extra_fortune_probability: 0.02, extra_battle_strength_points: 2, extra_breakthrough_success_rate: 0.05, extra_hidden_domain_drop_prob: 0.2, extra_hidden_domain_danger_prob: -0.1}"
32,SWORD_FANATIC,剑痴,LAZY;TIMID,以剑入道,剑即是命。你认为剑道至上。,SR,,"[{when: 'avatar.weapon.type == ""SWORD""', extra_weapon_proficiency_gain: 1.0, extra_battle_strength_points: 3}]"
33,SCHEMING,心机深沉,RASH;ENTHUSIASTIC;EXTROVERTED,深藏不露,行事谨慎,善于算计。,R,,
34,LOYAL,忠诚,ALOOF;INDIFFERENT;MEAN,重承诺守信义,对认定的人绝不背叛。,R,,
1 id key name exclusion_keys desc rarity condition effects
30 28 SIMP 舔狗 ALOOF;INDIFFERENT;MEAN;SHY 你对异性中外貌出众者格外友善。 N
31 29 ENVIOUS 嫉妒 FRIENDLY;ENTHUSIASTIC 你对远超于你的人容易产生敌意。 N
32 30 TRANSMIGRATOR 穿越者 你来自现代社会,怀念现代社会的一切。 SR
33 31 CHILD_OF_FORTUNE 气运之子 天生气运加身,更易遇到奇遇。 SSR {extra_fortune_probability: 0.02, extra_battle_strength_points: 2, extra_breakthrough_success_rate: 0.05} {extra_fortune_probability: 0.02, extra_battle_strength_points: 2, extra_breakthrough_success_rate: 0.05, extra_hidden_domain_drop_prob: 0.2, extra_hidden_domain_danger_prob: -0.1}
34 32 SWORD_FANATIC 剑痴 LAZY;TIMID 以剑入道,剑即是命。你认为剑道至上。 SR [{when: 'avatar.weapon.type == "SWORD"', extra_weapon_proficiency_gain: 1.0, extra_battle_strength_points: 3}]
35 33 SCHEMING 心机深沉 RASH;ENTHUSIASTIC;EXTROVERTED 深藏不露,行事谨慎,善于算计。 R
36 34 LOYAL 忠诚 ALOOF;INDIFFERENT;MEAN 重承诺守信义,对认定的人绝不背叛。 R

View File

@@ -15,3 +15,4 @@ title,desc
装备与丹药,通过兵器、辅助装备、丹药等装备,可以获得额外的属性加成,获得或小或大的增益。拥有好的装备或者服用好的丹药,能获得很大好处。
购物,在城市区域可以购买练气级别丹药、兵器。购买丹药后会立刻服用强化自身。购买兵器可以帮自己切换兵器类型为顺手的类型。
拍卖会,每隔一段不确定的时间会有神秘人组织的拍卖会,或许有好货出售。
秘境,每隔数年会有秘境开启。进入秘境可能遭遇凶险,也可能获得高阶法宝和功法。
1 title desc
15 装备与丹药 通过兵器、辅助装备、丹药等装备,可以获得额外的属性加成,获得或小或大的增益。拥有好的装备或者服用好的丹药,能获得很大好处。
16 购物 在城市区域可以购买练气级别丹药、兵器。购买丹药后会立刻服用强化自身。购买兵器可以帮自己切换兵器类型为顺手的类型。
17 拍卖会 每隔一段不确定的时间会有神秘人组织的拍卖会,或许有好货出售。
18 秘境 每隔数年会有秘境开启。进入秘境可能遭遇凶险,也可能获得高阶法宝和功法。

View File

@@ -18,7 +18,6 @@ events
注意:
1. 不要试图面面俱到地描写所有事件,请**自行筛选**其中最有趣、最有戏剧性的部分进行扩写。
2. 侧重描写角色之间的互动、心理博弈、竞争或合作。
3. 重点描写竞价过程中的紧张气氛。
只返回json格式的结果格式为
{{

View File

@@ -117,7 +117,7 @@ def dummy_avatar(base_world):
return av
@pytest.fixture
@pytest.fixture(autouse=True)
def mock_llm_managers():
"""
Mock 所有涉及 LLM 调用的管理器和函数,防止测试中意外调用 LLM。
@@ -134,6 +134,7 @@ def mock_llm_managers():
patch("src.classes.relation_resolver.RelationResolver.run_batch", new_callable=AsyncMock) as mock_rr, \
patch("src.classes.history.HistoryManager.apply_history_influence", new_callable=AsyncMock) as mock_hist, \
patch("src.classes.story_teller.StoryTeller.tell_story", new_callable=AsyncMock) as mock_story, \
patch("src.classes.story_teller.StoryTeller.tell_gathering_story", new_callable=AsyncMock) as mock_gathering_story, \
patch("src.utils.llm.config.LLMConfig.from_mode", return_value=mock_llm_config) as mock_config:
mock_ai.decide = AsyncMock(return_value={})
@@ -142,6 +143,7 @@ def mock_llm_managers():
mock_rr.return_value = []
mock_hist.return_value = None
mock_story.return_value = "测试故事"
mock_gathering_story.return_value = "秘境测试故事"
yield {
"ai": mock_ai,
@@ -150,6 +152,7 @@ def mock_llm_managers():
"rr": mock_rr,
"hist": mock_hist,
"story": mock_story,
"gathering_story": mock_gathering_story,
"config": mock_config
}

246
tests/test_hidden_domain.py Normal file
View File

@@ -0,0 +1,246 @@
import pytest
import random
from unittest.mock import MagicMock, patch, AsyncMock
from src.classes.gathering.hidden_domain import HiddenDomain
from src.classes.cultivation import Realm
from src.classes.death_reason import DeathReason, DeathType
from src.classes.item import Item
@pytest.fixture
def mock_domain_config():
"""Mock configuration for hidden domains."""
return [
{
"id": "domain_low",
"name": "Low Realm Domain",
"desc": "For weaklings",
"max_realm": "Qi Refinement",
"danger_prob": 0.5,
"hp_loss_percent": 0.5,
"drop_prob": 0.5,
"cd_years": 1,
"open_prob": 1.0,
},
{
"id": "domain_high",
"name": "High Realm Domain",
"desc": "For experts",
"max_realm": "Core Formation",
"danger_prob": 0.0, # Safe
"hp_loss_percent": 0.0,
"drop_prob": 0.0,
"cd_years": 1,
"open_prob": 0.0, # Disabled by prob
}
]
@pytest.fixture
def hidden_domain(mock_domain_config):
"""Instance of HiddenDomain with mocked config."""
# Patch game_configs dict directly
with patch.dict("src.utils.df.game_configs", {"hidden_domain": mock_domain_config}):
domain = HiddenDomain()
# Clear static state
HiddenDomain._domain_states.clear()
yield domain
HiddenDomain._domain_states.clear()
def test_load_configs(hidden_domain):
"""Test that configs are loaded correctly from df."""
configs = hidden_domain._load_configs()
assert len(configs) == 2
c1 = configs[0]
assert c1.id == "domain_low"
assert c1.max_realm == Realm.Qi_Refinement
assert c1.danger_prob == 0.5
c2 = configs[1]
assert c2.id == "domain_high"
assert c2.max_realm == Realm.Core_Formation
def test_is_start_basic(hidden_domain, base_world):
"""Test start condition logic."""
# Initial state: Year 1. CD is 1 year.
# domain_low: open_prob 1.0
# domain_high: open_prob 0.0
# By default, last_open is -999. Year 1 - (-999) >= 1.
# Mock random to ensure domain_low opens (though prob is 1.0, good to be safe)
# and domain_high stays closed (prob 0.0)
with patch("random.random", return_value=0.5):
is_started = hidden_domain.is_start(base_world)
assert is_started is True
assert len(hidden_domain._active_domains) == 1
assert hidden_domain._active_domains[0].id == "domain_low"
# Check that state was updated
assert HiddenDomain._domain_states["domain_low"] == 1
def test_is_start_cd_check(hidden_domain, base_world):
"""Test that CD prevents opening."""
# Mark domain_low as just opened in Year 1
HiddenDomain._domain_states["domain_low"] = 1
# Current world year is 1. Diff is 0. CD is 1. 0 < 1, so shouldn't open.
is_started = hidden_domain.is_start(base_world)
assert is_started is False
assert len(hidden_domain._active_domains) == 0
def test_get_info_formatting(hidden_domain, base_world):
"""Test the formatted info string matches the new multi-line format."""
# Force activate domain_low
configs = hidden_domain._load_configs()
hidden_domain._active_domains = [configs[0]] # Low Realm Domain
info = hidden_domain.get_info(base_world)
# Expected: "Hidden Domain Low Realm Domain opened! Entry restricted to Qi Refinement and below."
# Note: Using 'in' because the exact localized string might vary slightly in tests,
# but we look for key parts.
assert "Low Realm Domain" in info
assert str(Realm.Qi_Refinement) in info
# Ensure no "Hidden Domains opened:" prefix if possible, but difficult to test "absence" strictly
# without exact string match.
# Let's rely on checking the content is roughly correct.
@pytest.mark.asyncio
async def test_execute_entry_restriction(hidden_domain, base_world, dummy_avatar):
"""Test that only eligible avatars enter."""
# domain_low limits to Qi Refinement.
# Setup domains
configs = hidden_domain._load_configs()
hidden_domain._active_domains = [configs[0]]
# Avatar 1: Qi Refinement (Eligible)
dummy_avatar.cultivation_progress.realm = Realm.Qi_Refinement
# Avatar 2: Foundation Establishment (Too high)
av2 = MagicMock(spec=dummy_avatar)
av2.id = 1002
av2.name = "StrongGuy"
av2.cultivation_progress.realm = Realm.Foundation_Establishment
av2.personas = []
# Mock avatar manager
base_world.avatar_manager.get_living_avatars = MagicMock(return_value=[dummy_avatar, av2])
# Execute
# Mock process_single_domain internals or random to avoid side effects?
# Actually, let's just inspect who gets processed.
# Since _process_single_domain does the filtering, we can test that method or spy on it.
# But integration testing execute() is better.
# We need to mock random to avoid death or loot for now
with patch("random.random", return_value=0.9): # > danger(0.5) and > drop(0.5) -> Nothing happens
events = await hidden_domain.execute(base_world)
# We expect 1 event for opening
# And maybe 0 events for interaction if nothing happened.
# But we want to ensure only dummy_avatar was considered.
# Let's check logic by spying on logic inside loop?
# Hard to spy local var.
# Alternative: Set danger to 0, drop to 1.0.
# Eligible avatar gets loot => Event generated.
# Ineligible avatar gets nothing => No event.
configs[0].drop_prob = 1.0
configs[0].danger_prob = 0.0
# Mock _generate_loot to return a dummy item
mock_item = MagicMock(spec=Item)
mock_item.name = "TestTreasure"
hidden_domain._generate_loot = MagicMock(return_value=mock_item)
# Mock story generation to return nothing
hidden_domain._generate_story = AsyncMock(return_value=None)
events = await hidden_domain.execute(base_world)
# Events should include:
# 1. Opening event
# 2. Loot event for dummy_avatar
# 3. (No event for StrongGuy)
event_texts = [e.content for e in events]
# Check opening event
assert any("Low Realm Domain" in t for t in event_texts)
# Check loot event for eligible avatar
# Since tests run in zh-CN (forced by fixture), we check for Chinese text
# "found a treasure" -> "觅得宝物"
assert any("觅得宝物" in t for t in event_texts)
assert any(dummy_avatar.name in t for t in event_texts)
# Check NO event for ineligible avatar
assert not any(av2.name in t for t in event_texts)
@pytest.mark.asyncio
async def test_execute_danger_death(hidden_domain, base_world, dummy_avatar):
"""Test death logic in hidden domain."""
# Setup domain
configs = hidden_domain._load_configs()
domain = configs[0] # Low Realm
domain.danger_prob = 1.0 # Certain danger
domain.hp_loss_percent = 2.0 # Instant kill (>100% HP)
hidden_domain._active_domains = [domain]
dummy_avatar.cultivation_progress.realm = Realm.Qi_Refinement
base_world.avatar_manager.get_living_avatars = MagicMock(return_value=[dummy_avatar])
# Mock handle_death to avoid complex world logic
with patch("src.classes.gathering.hidden_domain.handle_death") as mock_death:
events = await hidden_domain.execute(base_world)
# Verify death handler called
mock_death.assert_called_once()
args, _ = mock_death.call_args
# args[0] is world, args[1] is avatar, args[2] is reason
assert args[1] == dummy_avatar
assert args[2].death_type == DeathType.HIDDEN_DOMAIN
# Verify event log
event_texts = [e.content for e in events]
# "perished" -> "葬身于"
assert any("葬身于" in t for t in event_texts)
@pytest.mark.asyncio
async def test_execute_loot_drop(hidden_domain, base_world, dummy_avatar):
"""Test loot drop logic."""
# Setup domain
configs = hidden_domain._load_configs()
domain = configs[0]
domain.danger_prob = 0.0
domain.drop_prob = 1.0
hidden_domain._active_domains = [domain]
dummy_avatar.cultivation_progress.realm = Realm.Qi_Refinement
base_world.avatar_manager.get_living_avatars = MagicMock(return_value=[dummy_avatar])
# Mock generation to return a specific item type to trigger specific logic
# Let's use Weapon
from src.classes.weapon import Weapon
mock_weapon = MagicMock(spec=Weapon)
mock_weapon.name = "GodSlayer"
hidden_domain._generate_loot = MagicMock(return_value=mock_weapon)
dummy_avatar.change_weapon = MagicMock()
# Execute
events = await hidden_domain.execute(base_world)
# Check loot generation called with next realm
# Current: Qi Refinement -> Next: Foundation Establishment
hidden_domain._generate_loot.assert_called_with(dummy_avatar, Realm.Foundation_Establishment)
# Check weapon equipped
dummy_avatar.change_weapon.assert_called_with(mock_weapon)
# Check event
event_texts = [e.content for e in events]
assert any("GodSlayer" in t for t in event_texts)

View File

@@ -15,6 +15,10 @@ from src.classes.sect import Sect, SectHeadQuarter
from src.classes.alignment import Alignment
from src.sim.load.load_game import apply_history_modifications
# 保存原始方法,因为 conftest 中全局 mock 了它,导致部分集成测试失效
# 我们需要在这个变量中保存引用,以便在特定测试中恢复它
_real_apply_history_influence = HistoryManager.apply_history_influence
# 假设这些全局字典在模块层级
from src.classes import technique as technique_module
from src.classes import weapon as weapon_module
@@ -379,7 +383,10 @@ async def test_move_to_region_after_history_rename(base_world, dummy_avatar):
with patch("src.classes.history.call_llm_with_task_name", new_callable=AsyncMock) as mock_llm:
mock_llm.side_effect = side_effect
await manager.apply_history_influence("测试历史")
# 临时恢复真实的 apply_history_influence 方法,因为 conftest 把它 mock 掉了
with patch.object(HistoryManager, 'apply_history_influence', new=_real_apply_history_influence):
await manager.apply_history_influence("测试历史")
# 验证名称已修改
assert city_region.name == "沧澜潮汐城"