add elixir

This commit is contained in:
bridge
2026-01-05 23:04:55 +08:00
parent 2a68f352bc
commit 8d7e11b021
12 changed files with 502 additions and 103 deletions

View File

@@ -126,6 +126,7 @@ You can also join the QQ group for discussion: 1071821688. Verification answer i
- ✅ Effect system: buffs/debuffs
- ✅ Techniques
- ✅ Combat equipment & auxiliary equipment
- ✅ Elixir
- ✅ Short/Long term memory
- ✅ Character's short and long term objectives, supporting player active setting
- ✅ Avatar nicknames

View File

@@ -126,6 +126,7 @@
- ✅ 角色Effects系统增益/减益效果
- ✅ 角色功法
- ✅ 角色兵器 & 辅助装备
- ✅ 丹药
- ✅ 角色长短期记忆
- ✅ 角色的长短期目标,支持玩家主动设定
- ✅ 角色绰号

View File

@@ -135,14 +135,41 @@ class Avatar(
if elixir.realm > self.cultivation_progress.realm:
return False
# 2. 记录服用状态
# 2. 重复服用校验:若已服用过同种且未失效的丹药,则无效
# 因为延寿丹药都是无限持久的,所以所有延寿丹药都只能服用一次。
for consumed in self.elixirs:
if consumed.elixir.id == elixir.id:
if not consumed.is_completely_expired(int(self.world.month_stamp)):
return False
# 3. 记录服用状态
self.elixirs.append(ConsumedElixir(elixir, int(self.world.month_stamp)))
# 3. 立即触发属性重算因为可能有立即生效的数值变化或者MaxHP/Lifespan改变
# 4. 立即触发属性重算因为可能有立即生效的数值变化或者MaxHP/Lifespan改变
self.recalc_effects()
return True
def process_elixir_expiration(self, current_month: int) -> None:
"""
处理丹药过期:
1. 移除已完全过期的丹药
2. 如果有移除,触发属性重算
"""
if not self.elixirs:
return
original_count = len(self.elixirs)
# 过滤掉完全过期的
self.elixirs = [
e for e in self.elixirs
if not e.is_completely_expired(current_month)
]
# 如果数量减少说明有过期重算属性主要是寿命、MaxHP
if len(self.elixirs) < original_count:
self.recalc_effects()
def join_sect(self, sect: Sect, rank: "SectRank") -> None:
"""加入宗门"""
if self.is_dead:

View File

@@ -61,56 +61,14 @@ class EffectsMixin:
def effects(self: "Avatar") -> dict[str, object]:
"""
合并所有来源的效果:宗门、功法、灵根、特质、兵器、辅助装备、灵兽、天地灵机、丹药
直接复用 get_effect_breakdown 的逻辑,确保显示与实际效果一致。
"""
merged: dict[str, object] = {}
def _process_source(source_obj):
if source_obj is None:
return
# 1. 评估条件 (when)
evaluated = _evaluate_conditional_effect(source_obj.effects, self)
# 2. 评估动态值 (expressions)
evaluated = self._evaluate_values(evaluated)
# 3. 合并到总效果
nonlocal merged
merged = _merge_effects(merged, evaluated)
# 来自宗门
if self.sect is not None:
_process_source(self.sect)
# 来自功法
if self.technique is not None:
_process_source(self.technique)
# 来自灵根
if self.root is not None:
_process_source(self.root)
# 来自特质persona
for persona in self.personas:
_process_source(persona)
# 来自兵器
if self.weapon is not None:
_process_source(self.weapon)
# 来自辅助装备
if self.auxiliary is not None:
_process_source(self.auxiliary)
# 来自灵兽
if self.spirit_animal is not None:
_process_source(self.spirit_animal)
# 来自天地灵机世界级buff/debuff
if self.world.current_phenomenon is not None:
_process_source(self.world.current_phenomenon)
# 来自已服用的丹药
# 简化逻辑:直接 merge 所有丹药的效果
for consumed in self.elixirs:
_process_source(consumed.elixir)
# get_effect_breakdown 已经完成了条件评估(when)和动态值计算(expressions)
# 我们只需要合并结果即可
for _, effect_dict in self.get_effect_breakdown():
merged = _merge_effects(merged, effect_dict)
return merged
@@ -121,11 +79,21 @@ class EffectsMixin:
"""
breakdown = []
def _collect(name: str, source_obj):
if source_obj is None:
def _collect(name: str, source_obj=None, explicit_effects=None):
"""
收集效果。
source_obj: 包含 .effects 的对象
explicit_effects: 直接传入的 effects (dict or list)
"""
raw_effects = explicit_effects
if raw_effects is None and source_obj is not None:
raw_effects = getattr(source_obj, "effects", {})
if not raw_effects:
return
# 1. 评估条件 (when)
evaluated = _evaluate_conditional_effect(source_obj.effects, self)
evaluated = _evaluate_conditional_effect(raw_effects, self)
# 2. 评估动态值 (expressions)
evaluated = self._evaluate_values(evaluated)
@@ -134,31 +102,33 @@ class EffectsMixin:
# 按照优先级或逻辑顺序收集
if self.sect:
_collect(f"宗门【{self.sect.name}", self.sect)
_collect(f"宗门【{self.sect.name}", source_obj=self.sect)
if self.technique:
_collect(f"功法【{self.technique.name}", self.technique)
_collect(f"功法【{self.technique.name}", source_obj=self.technique)
if self.root:
_collect("灵根", self.root)
_collect("灵根", source_obj=self.root)
for p in self.personas:
_collect(f"特质【{p.name}", p)
_collect(f"特质【{p.name}", source_obj=p)
if self.weapon:
_collect(f"兵器【{self.weapon.name}", self.weapon)
_collect(f"兵器【{self.weapon.name}", source_obj=self.weapon)
if self.auxiliary:
_collect(f"辅助【{self.auxiliary.name}", self.auxiliary)
_collect(f"辅助【{self.auxiliary.name}", source_obj=self.auxiliary)
if self.spirit_animal:
_collect(f"灵兽【{self.spirit_animal.name}", self.spirit_animal)
_collect(f"灵兽【{self.spirit_animal.name}", source_obj=self.spirit_animal)
if self.world.current_phenomenon:
_collect("天地灵机", self.world.current_phenomenon)
_collect("天地灵机", source_obj=self.world.current_phenomenon)
for consumed in self.elixirs:
_collect(f"丹药【{consumed.elixir.name}", consumed.elixir)
# 使用 get_active_effects 获取当前生效的效果
active = consumed.get_active_effects(int(self.world.month_stamp))
_collect(f"丹药【{consumed.elixir.name}", explicit_effects=active)
return breakdown
@@ -212,4 +182,3 @@ class EffectsMixin:
def move_step_length(self: "Avatar") -> int:
"""获取角色的移动步长"""
return self.cultivation_progress.get_move_step()

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List
from typing import Dict, List, Union
from src.utils.df import game_configs, get_str, get_int
from src.classes.effect import load_effect_from_str, format_effects_to_text
@@ -30,7 +30,7 @@ class Elixir:
type: ElixirType
desc: str
price: int
effects: dict[str, object] = field(default_factory=dict)
effects: Union[dict[str, object], list[dict[str, object]]] = field(default_factory=dict)
effect_desc: str = ""
def get_info(self, detailed: bool = False) -> str:
@@ -79,6 +79,53 @@ class ConsumedElixir:
"""
elixir: Elixir
consume_time: int # 服用时的 MonthStamp
_expire_time: Union[int, float] = field(init=False)
def __post_init__(self):
self._expire_time = self.consume_time + self._get_max_duration()
def _get_max_duration(self) -> Union[int, float]:
"""获取丹药的最长持续时间"""
effects = self.elixir.effects
if isinstance(effects, dict):
effects = [effects]
max_d = 0
for eff in effects:
# 如果没有 duration_month 字段,视为永久效果
if "duration_month" not in eff:
return float('inf')
max_d = max(max_d, int(eff.get("duration_month", 0)))
return max_d
def is_completely_expired(self, current_month: int) -> bool:
"""
判断丹药是否彻底失效(所有效果都过期)
"""
return current_month >= self._expire_time
def get_active_effects(self, current_month: int) -> List[dict[str, object]]:
"""
获取当前时间点仍然有效的 effects 列表
"""
active = []
effects = self.elixir.effects
if isinstance(effects, dict):
effects = [effects]
for eff in effects:
# 永久效果
if "duration_month" not in eff:
active.append(eff)
continue
# 有时限效果
duration = int(eff.get("duration_month", 0))
if duration > 0:
if current_month < self.consume_time + duration:
active.append(eff)
return active
def _load_elixirs() -> tuple[Dict[int, Elixir], Dict[str, List[Elixir]]]:

View File

@@ -60,6 +60,7 @@ class World():
"灵石": "修仙界的通用货币。可用于购买法宝丹药,通过采集、交易或掠夺获取。",
"宗门": "修士的庇护所。加入宗门可习得独门功法、获同门庇护;散修自由但资源匮乏。",
"战斗": "弱肉强食。境界压制极大,高境界者对低境界者有绝对优势。若对方死亡,胜者可掠夺败者财物。",
"动作": "你有一系列可以执行的动作。要注意动作的效果、限制条件、区域和时间。"
"动作": "你有一系列可以执行的动作。要注意动作的效果、限制条件、区域和时间。",
"装备与丹药": "通过兵器、辅助装备、丹药等装备,可以获得额外的属性加成,获得或小或大的增益。拥有好的装备或者服用好的丹药,能获得很大好处。",
}
return desc

View File

@@ -200,12 +200,16 @@ class Simulator:
async def _phase_passive_effects(self):
"""
被动结算阶段:
- 处理丹药过期
- 更新时间效果如HP回复
- 触发奇遇(非动作)
"""
events = []
living_avatars = self.world.avatar_manager.get_living_avatars()
for avatar in living_avatars:
# 1. 处理丹药过期
avatar.process_elixir_expiration(int(self.world.month_stamp))
# 2. 更新被动效果 (如HP回复)
avatar.update_time_effect()
# 使用 gather 并行触发奇遇和霉运

View File

@@ -3,10 +3,10 @@ id,name,realm,type,desc,price,effects
1,破境丹,练气,Breakthrough,凝聚灵气辅助练气期修士突破瓶颈的丹药药效5年,50,"{duration_month: 60, extra_breakthrough_success_rate: 0.1}"
2,破境丹,筑基,Breakthrough,蕴含筑基真意辅助筑基期修士突破瓶颈的灵丹药效5年,200,"{duration_month: 60, extra_breakthrough_success_rate: 0.1}"
3,破境丹,金丹,Breakthrough,凝结金丹之气辅助金丹期修士碎丹成婴药效5年,500,"{duration_month: 60, extra_breakthrough_success_rate: 0.1}"
5,长生丹,练气,Lifespan,采用凡间珍草炼制,略微延缓衰老(药效50年)。,100,"{duration_month: 600, extra_max_lifespan: 5}"
6,长生丹,筑基,Lifespan,取天地灵草炼制,可延寿十载(药效50年)。,500,"{duration_month: 1200, extra_max_lifespan: 10}"
7,长生丹,金丹,Lifespan,夺天地造化,凡人服之立毙,金丹修士服之延寿半甲子(药效50年)。,200,"{duration_month: 2400, extra_max_lifespan: 30}"
8,长生丹,元婴,Lifespan,蕴含一丝长生之气,元婴老怪以此续命(药效50年)。,5000,"{duration_month: 6000, extra_max_lifespan: 100}"
5,长生丹,练气,Lifespan,采用凡间珍草炼制,略微延缓衰老(限服一颗)。,100,"{extra_max_lifespan: 5}"
6,长生丹,筑基,Lifespan,取天地灵草炼制,可延寿十载(限服一颗)。,500,"{extra_max_lifespan: 10}"
7,长生丹,金丹,Lifespan,夺天地造化,凡人服之立毙,金丹修士服之延寿半甲子(限服一颗)。,200,"{extra_max_lifespan: 30}"
8,长生丹,元婴,Lifespan,蕴含一丝长生之气,元婴老怪以此续命(限服一颗)。,5000,"{extra_max_lifespan: 100}"
9,燃血丹,练气,BurnBlood,燃烧精血换取短暂爆发。3年内战力提升但10年内经脉受损战力下降。,50,"[{duration_month: 36, extra_battle_strength_points: 3}, {duration_month: 120, extra_battle_strength_points: -1}]"
10,燃血丹,筑基,BurnBlood,激发潜能的猛药。3年内战力大增但10年内虚弱。,100,"[{duration_month: 36, extra_battle_strength_points: 5}, {duration_month: 120, extra_battle_strength_points: -2}]"
11,燃血丹,金丹,BurnBlood,金丹修士拼命时的选择。3年内战力暴涨但10年内重伤。,200,"[{duration_month: 36, extra_battle_strength_points: 7}, {duration_month: 120, extra_battle_strength_points: -3}]"
1 id name realm type desc price effects
3 1 破境丹 练气 Breakthrough 凝聚灵气,辅助练气期修士突破瓶颈的丹药(药效5年)。 50 {duration_month: 60, extra_breakthrough_success_rate: 0.1}
4 2 破境丹 筑基 Breakthrough 蕴含筑基真意,辅助筑基期修士突破瓶颈的灵丹(药效5年)。 200 {duration_month: 60, extra_breakthrough_success_rate: 0.1}
5 3 破境丹 金丹 Breakthrough 凝结金丹之气,辅助金丹期修士碎丹成婴(药效5年)。 500 {duration_month: 60, extra_breakthrough_success_rate: 0.1}
6 5 长生丹 练气 Lifespan 采用凡间珍草炼制,略微延缓衰老(药效50年)。 采用凡间珍草炼制,略微延缓衰老(限服一颗)。 100 {duration_month: 600, extra_max_lifespan: 5} {extra_max_lifespan: 5}
7 6 长生丹 筑基 Lifespan 取天地灵草炼制,可延寿十载(药效50年)。 取天地灵草炼制,可延寿十载(限服一颗)。 500 {duration_month: 1200, extra_max_lifespan: 10} {extra_max_lifespan: 10}
8 7 长生丹 金丹 Lifespan 夺天地造化,凡人服之立毙,金丹修士服之延寿半甲子(药效50年)。 夺天地造化,凡人服之立毙,金丹修士服之延寿半甲子(限服一颗)。 200 {duration_month: 2400, extra_max_lifespan: 30} {extra_max_lifespan: 30}
9 8 长生丹 元婴 Lifespan 蕴含一丝长生之气,元婴老怪以此续命(药效50年)。 蕴含一丝长生之气,元婴老怪以此续命(限服一颗)。 5000 {duration_month: 6000, extra_max_lifespan: 100} {extra_max_lifespan: 100}
10 9 燃血丹 练气 BurnBlood 燃烧精血换取短暂爆发。3年内战力提升,但10年内经脉受损战力下降。 50 [{duration_month: 36, extra_battle_strength_points: 3}, {duration_month: 120, extra_battle_strength_points: -1}]
11 10 燃血丹 筑基 BurnBlood 激发潜能的猛药。3年内战力大增,但10年内虚弱。 100 [{duration_month: 36, extra_battle_strength_points: 5}, {duration_month: 120, extra_battle_strength_points: -2}]
12 11 燃血丹 金丹 BurnBlood 金丹修士拼命时的选择。3年内战力暴涨,但10年内重伤。 200 [{duration_month: 36, extra_battle_strength_points: 7}, {duration_month: 120, extra_battle_strength_points: -3}]

View File

@@ -58,3 +58,40 @@ def dummy_avatar(base_world):
return av
@pytest.fixture
def mock_llm_managers():
"""
Mock 所有涉及 LLM 调用的管理器和函数,防止测试中意外调用 LLM。
包括:
- llm_ai (decision making)
- process_avatar_long_term_objective (long term goal)
- process_avatar_nickname (nickname generation)
- RelationResolver.run_batch (relationship evolution)
"""
from unittest.mock import patch, MagicMock, AsyncMock
with patch("src.sim.simulator.llm_ai") as mock_ai, \
patch("src.sim.simulator.process_avatar_long_term_objective", new_callable=AsyncMock) as mock_lto, \
patch("src.classes.nickname.process_avatar_nickname", new_callable=AsyncMock) as mock_nick, \
patch("src.classes.relation_resolver.RelationResolver.run_batch", new_callable=AsyncMock) as mock_rr:
# 1. Mock AI Decision
# ai.decide is an async method
mock_ai.decide = AsyncMock(return_value={})
# 2. Mock Long Term Objective
# AsyncMock returns a coroutine when called
mock_lto.return_value = None
# 3. Mock Nickname
mock_nick.return_value = None
# 4. Mock Relation Resolver
mock_rr.return_value = []
yield {
"ai": mock_ai,
"lto": mock_lto,
"nick": mock_nick,
"rr": mock_rr
}

View File

@@ -98,7 +98,7 @@ async def test_simulator_resolve_death(base_world, dummy_avatar):
assert "重伤不治身亡" in str(events[0])
@pytest.mark.asyncio
async def test_simulator_evolve_relations_filter_dead(base_world, dummy_avatar):
async def test_simulator_evolve_relations_filter_dead(base_world, dummy_avatar, mock_llm_managers):
"""测试关系演化阶段过滤死者"""
from src.sim.simulator import Simulator
sim = Simulator(base_world)
@@ -133,18 +133,19 @@ async def test_simulator_evolve_relations_filter_dead(base_world, dummy_avatar):
# 让 Target 死亡
target.set_dead("测试死亡", base_world.month_stamp)
# Mock RelationResolver 防止真正调用 LLM
from unittest.mock import patch
with patch('src.classes.relation_resolver.RelationResolver.run_batch') as mock_run:
await sim._phase_evolve_relations()
# 验证:因为 target 已死,应该不会调用 run_batch
mock_run.assert_not_called()
# 获取 mock_rr 用于验证调用
mock_run = mock_llm_managers["rr"]
await sim._phase_evolve_relations()
# 验证:因为 target 已死,应该不会调用 run_batch
mock_run.assert_not_called()
# 如果 Target 活着,应该会调用
target.is_dead = False
with patch('src.classes.relation_resolver.RelationResolver.run_batch') as mock_run:
mock_run.return_value = []
await sim._phase_evolve_relations()
mock_run.assert_called_once()
mock_run.reset_mock() # 重置 mock 调用记录
mock_run.return_value = [] # AsyncMock 会自动将其 wrap 进 awaitable
await sim._phase_evolve_relations()
mock_run.assert_called_once()

324
tests/test_elixir.py Normal file
View File

@@ -0,0 +1,324 @@
import pytest
import asyncio
from typing import Dict
from src.classes.elixir import Elixir, ElixirType, ConsumedElixir, _load_elixirs
from src.classes.avatar import Avatar
from src.classes.cultivation import Realm
from src.classes.effect import EffectsMixin
from src.classes.calendar import create_month_stamp, Year, Month
# ==========================================
# Fixtures
# ==========================================
@pytest.fixture
def test_elixirs() -> Dict[str, Elixir]:
"""
手动构造一组测试用丹药避免依赖外部CSV配置
"""
# 1. 练气期破境丹(普通)
breakthrough = Elixir(
id=1001,
name="测试破境丹",
realm=Realm.Qi_Refinement,
type=ElixirType.Breakthrough,
desc="测试用",
price=100,
effects=[{"duration_month": 10, "extra_breakthrough_success_rate": 0.1}],
effect_desc="突破率+10%"
)
# 2. 练气期延寿丹(无限时长)
lifespan_qi = Elixir(
id=1002,
name="测试长生丹",
realm=Realm.Qi_Refinement,
type=ElixirType.Lifespan,
desc="测试用",
price=200,
effects=[{"extra_max_lifespan": 10}], # 无 duration_month 视为永久
effect_desc="寿元+10"
)
# 3. 筑基期延寿丹(境界更高)
lifespan_found = Elixir(
id=1003,
name="测试筑基长生丹",
realm=Realm.Foundation_Establishment,
type=ElixirType.Lifespan,
desc="测试用",
price=500,
effects=[{"extra_max_lifespan": 20}],
effect_desc="寿元+20"
)
# 4. 燃血丹混合时效前3个月加攻前10个月减防
# 注意模拟csv中的 [{"duration_month": 3, "val": 1}, {"duration_month": 10, "val": -1}]
burn_blood = Elixir(
id=1004,
name="测试燃血丹",
realm=Realm.Qi_Refinement,
type=ElixirType.BurnBlood,
desc="测试用",
price=50,
effects=[
{"duration_month": 3, "extra_battle_strength_points": 10},
{"duration_month": 10, "extra_defense_points": -5}
],
effect_desc="爆发"
)
return {
"breakthrough": breakthrough,
"lifespan_qi": lifespan_qi,
"lifespan_found": lifespan_found,
"burn_blood": burn_blood
}
# ==========================================
# Tests
# ==========================================
class TestElixirBasic:
"""测试基本加载和数据结构"""
def test_load_config_structure(self):
"""测试从实际CSV加载的机制是否正常工作不校验具体数值只校验结构"""
ids, names = _load_elixirs()
assert len(ids) > 0, "应该能加载到丹药数据"
first_elixir = list(ids.values())[0]
assert isinstance(first_elixir, Elixir)
assert isinstance(first_elixir.effects, (dict, list))
def test_elixir_info(self, test_elixirs):
"""测试信息显示"""
elixir = test_elixirs["breakthrough"]
info = elixir.get_detailed_info()
assert "测试破境丹" in info
assert "练气" in info
assert "破境" in info
class TestConsumption:
"""测试服用逻辑"""
def test_consume_success(self, dummy_avatar, test_elixirs):
"""测试正常服用"""
elixir = test_elixirs["breakthrough"]
dummy_avatar.consume_elixir(elixir)
assert len(dummy_avatar.elixirs) == 1
record = dummy_avatar.elixirs[0]
assert record.elixir.id == elixir.id
assert record.consume_time == int(dummy_avatar.world.month_stamp)
def test_consume_realm_restriction(self, dummy_avatar, test_elixirs):
"""测试境界限制:练气期不能吃筑基期丹药"""
# 确认当前是练气期
assert dummy_avatar.cultivation_progress.realm == Realm.Qi_Refinement
elixir = test_elixirs["lifespan_found"] # 筑基期丹药
success = dummy_avatar.consume_elixir(elixir)
assert not success
assert len(dummy_avatar.elixirs) == 0
def test_consume_duplicate_lifespan(self, dummy_avatar, test_elixirs):
"""
测试:同境界延寿丹再次服用,不会额外再次增加寿元。
"""
elixir = test_elixirs["lifespan_qi"]
# 1. 第一次服用
base_lifespan = dummy_avatar.age.max_lifespan
success1 = dummy_avatar.consume_elixir(elixir)
assert success1
# 检查属性变化
new_lifespan = dummy_avatar.age.max_lifespan
# 注意:这里假设 EffectsMixin 已经生效。如果有 extra_max_lifespan 效果,
# recalc_effects 应该会把 10 加到 max_lifespan 上
# 如果 EffectsMixin 的实现是将 effect 存在 self.effects_dict 中,
# 而 Age.max_lifespan 是通过 property 计算 base + extra那么这里应该能读到。
# 如果是直接修改数值,则需要看具体实现。
# 查看 avatar/core.pyAvatar 继承了 EffectsMixin。
# 延寿效果通常对应 extra_max_lifespan这应该在 Age 类或者 Avatar 属性计算中体现。
# 假设 EffectsMixin 提供了 get_effect_value("extra_max_lifespan")
# 我们通过 checking effect 值来验证
assert dummy_avatar.effects.get("extra_max_lifespan") == 10
# 2. 立即再次服用(应该失败)
success2 = dummy_avatar.consume_elixir(elixir)
assert not success2
assert len(dummy_avatar.elixirs) == 1
assert dummy_avatar.effects.get("extra_max_lifespan") == 10
# 3. 推进时间很久(延寿丹是永久的,依然不能服用)
dummy_avatar.world.month_stamp += 1000
success3 = dummy_avatar.consume_elixir(elixir)
assert not success3
assert len(dummy_avatar.elixirs) == 1
def test_consume_duplicate_expired(self, dummy_avatar, test_elixirs):
"""测试:过期后的丹药可以再次服用"""
elixir = test_elixirs["breakthrough"] # 持续10个月
# 1. 服用
dummy_avatar.consume_elixir(elixir)
assert len(dummy_avatar.elixirs) == 1
# 2. 推进时间到过期 (当前 + 11)
# consume_time 是服用时的 month_stamp
# expire_time = consume_time + 10
# current >= expire_time 时 is_completely_expired 为 True
# 先推进9个月还剩1个月
dummy_avatar.world.month_stamp += 9
# 清理逻辑通常是被动触发的,这里我们手动触发或依赖服用时的检查
# consume_elixir 内部会检查 is_completely_expired
success_fail = dummy_avatar.consume_elixir(elixir)
assert not success_fail # 还没过期,不能吃
# 再推进2个月共+11过期了
dummy_avatar.world.month_stamp += 2
# 3. 再次服用(应该成功)
# 注意consume_elixir 内部逻辑是 "若已服用过同种且【未失效】的丹药,则无效"
# 即使旧的记录还在 list 里(还没被清理),只要 is_completely_expired 返回 True就可以再吃
success_ok = dummy_avatar.consume_elixir(elixir)
assert success_ok
# 此时列表里可能有2个记录旧的没清理的话或者旧的被清理了取决于实现这里不强制要求清理只要求能吃
# 查看代码consume_elixir 没有清理逻辑,只是 append。清理逻辑在 process_elixir_expiration
assert len(dummy_avatar.elixirs) == 2
# 验证一个是过期的,一个是新的
expired_one = dummy_avatar.elixirs[0]
new_one = dummy_avatar.elixirs[1]
assert expired_one.is_completely_expired(int(dummy_avatar.world.month_stamp))
assert not new_one.is_completely_expired(int(dummy_avatar.world.month_stamp))
class TestExpirationAndEffects:
"""测试时效和效果计算"""
def test_mixed_duration_effects(self, dummy_avatar, test_elixirs):
"""测试燃血丹的多段效果"""
# 先计算基础值(排除丹药影响)
dummy_avatar.recalc_effects()
base_atk = dummy_avatar.effects.get("extra_battle_strength_points", 0)
base_def = dummy_avatar.effects.get("extra_defense_points", 0)
elixir = test_elixirs["burn_blood"]
# effects: duration 3 (atk+10), duration 10 (def-5)
dummy_avatar.consume_elixir(elixir)
start_time = int(dummy_avatar.world.month_stamp)
# 1. 刚服用 (Month 0)
# 效果全在
active = dummy_avatar.elixirs[0].get_active_effects(start_time)
assert len(active) == 2
dummy_avatar.recalc_effects()
# 断言应当是 基础值 + 丹药增量
assert dummy_avatar.effects.get("extra_battle_strength_points") == base_atk + 10
assert dummy_avatar.effects.get("extra_defense_points") == base_def - 5
# 2. 第 2 个月 (Month 0 + 2 < 3)
current = start_time + 2
active = dummy_avatar.elixirs[0].get_active_effects(current)
assert len(active) == 2
# 3. 第 5 个月 (Month 0 + 5)
# 攻击buff(3个月)消失防御debuff(10个月)还在
current = start_time + 5
dummy_avatar.world.month_stamp = create_month_stamp(Year(2000), Month.JANUARY) + 5 # Reset timestamp just to be safe
# update world time manually
dummy_avatar.world.month_stamp = start_time + 5
# 主动触发清理或重算
dummy_avatar.process_elixir_expiration(current) # 还没完全过期不会移除Elixir对象
dummy_avatar.recalc_effects() # 但 active effects 变了,重算属性
# 验证 active effects
active = dummy_avatar.elixirs[0].get_active_effects(current)
assert len(active) == 1
assert "extra_battle_strength_points" not in active[0]
assert active[0]["extra_defense_points"] == -5
# 验证属性面板
# 注意Avatar.recalc_effects() 会调用 self.get_all_effects()
# get_all_effects 会遍历 elixirs 调用 get_active_effects
assert dummy_avatar.effects.get("extra_battle_strength_points", 0) == base_atk
assert dummy_avatar.effects.get("extra_defense_points") == base_def - 5
# 4. 第 11 个月 (Month 0 + 11)
# 全部过期
current = start_time + 11
dummy_avatar.world.month_stamp = current
# 此时 is_completely_expired 应该是 True
assert dummy_avatar.elixirs[0].is_completely_expired(current)
# 触发清理
dummy_avatar.process_elixir_expiration(current)
assert len(dummy_avatar.elixirs) == 0
# 属性恢复
dummy_avatar.recalc_effects()
assert dummy_avatar.effects.get("extra_defense_points", 0) == base_def
def test_infinite_duration(self, dummy_avatar, test_elixirs):
"""测试无限时长(延寿丹)"""
elixir = test_elixirs["lifespan_qi"]
dummy_avatar.consume_elixir(elixir)
# 推进 100 年
dummy_avatar.world.month_stamp += 1200
current = int(dummy_avatar.world.month_stamp)
assert not dummy_avatar.elixirs[0].is_completely_expired(current)
dummy_avatar.process_elixir_expiration(current)
assert len(dummy_avatar.elixirs) == 1
# 效果依然在
dummy_avatar.recalc_effects()
assert dummy_avatar.effects.get("extra_max_lifespan") == 10
class TestSimulatorIntegration:
"""测试模拟器集成"""
@pytest.mark.asyncio
async def test_passive_update_loop(self, base_world, dummy_avatar, test_elixirs, mock_llm_managers):
"""测试在 Simulator step 中自动清理过期丹药"""
from src.sim.simulator import Simulator
# 准备环境
sim = Simulator(base_world)
# 确保 dummy_avatar 在世界管理器中(虽然 base_world 可能没有自动加进去)
base_world.avatar_manager.avatars[dummy_avatar.id] = dummy_avatar
# 构造一个超短效丹药 (持续1个月)
short_elixir = Elixir(
id=9999,
name="瞬时丹",
realm=Realm.Qi_Refinement,
type=ElixirType.Breakthrough,
desc="测试用",
price=1,
effects=[{"duration_month": 1, "test_val": 1}],
effect_desc="瞬时"
)
dummy_avatar.consume_elixir(short_elixir)
# 只需运行 2 个 step (month 0->1 还在, month 1->2 过期清理)
# month 0: consume. expires at 0+1=1.
# step 1: check expiration (time=0). not expired. time becomes 1.
# step 2: check expiration (time=1). expired! cleaned. time becomes 2.
for _ in range(2):
await sim.step()
# 检查是否自动清理
assert len(dummy_avatar.elixirs) == 0

View File

@@ -7,7 +7,7 @@ from src.classes.action.move_to_direction import MoveToDirection
from src.classes.tile import TileType
from src.classes.action_runtime import ActionInstance
def test_simulator_step_moves_avatar_and_sets_tile(base_world, dummy_avatar):
def test_simulator_step_moves_avatar_and_sets_tile(base_world, dummy_avatar, mock_llm_managers):
# Set initial position
dummy_avatar.pos_x = 1
dummy_avatar.pos_y = 1
@@ -26,27 +26,14 @@ def test_simulator_step_moves_avatar_and_sets_tile(base_world, dummy_avatar):
# Wrap in ActionInstance
dummy_avatar.current_action = ActionInstance(action=action, params={"direction": direction})
# Mock LLM to avoid external calls or errors
# We need to patch where it is imported or used.
# simulator.py imports process_avatar_long_term_objective, etc.
# The easiest way is to patch call_llm globally or specifically the phases.
with patch("src.sim.simulator.llm_ai") as mock_ai, \
patch("src.sim.simulator.process_avatar_long_term_objective", new_callable=AsyncMock) as mock_lto, \
patch("src.classes.nickname.process_avatar_nickname", new_callable=AsyncMock) as mock_nick, \
patch("src.classes.relation_resolver.RelationResolver.run_batch", new_callable=AsyncMock) as mock_rr:
mock_ai.decide = MagicMock(return_value={})
# Mock async returns for gathered tasks
mock_lto.return_value = None
mock_nick.return_value = None
mock_rr.return_value = []
print(f"DEBUG: Before step: pos_x={dummy_avatar.pos_x}")
# Run step synchronously
asyncio.run(sim.step())
print(f"DEBUG: After step: pos_x={dummy_avatar.pos_x}")
print(f"DEBUG: move_step_length={getattr(dummy_avatar, 'move_step_length', 'Not set')}")
print(f"DEBUG: effects={dummy_avatar.effects}")
# Mock LLM to avoid external calls or errors (Handled by mock_llm_managers fixture)
print(f"DEBUG: Before step: pos_x={dummy_avatar.pos_x}")
# Run step synchronously
asyncio.run(sim.step())
print(f"DEBUG: After step: pos_x={dummy_avatar.pos_x}")
print(f"DEBUG: move_step_length={getattr(dummy_avatar, 'move_step_length', 'Not set')}")
print(f"DEBUG: effects={dummy_avatar.effects}")
# Assert moved East (x increased by move_step_length)
# Current move step for Qi Refinement is 2