add elixir
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
324
tests/test_elixir.py
Normal 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.py,Avatar 继承了 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user