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

@@ -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