From 8d7e11b021e0f4181bd96658e48e1a2ed60cb90a Mon Sep 17 00:00:00 2001 From: bridge Date: Mon, 5 Jan 2026 23:04:55 +0800 Subject: [PATCH] add elixir --- EN_README.md | 1 + README.md | 1 + src/classes/avatar/core.py | 31 +++- src/classes/effect/mixin.py | 89 +++------ src/classes/elixir.py | 51 +++++- src/classes/world.py | 3 +- src/sim/simulator.py | 4 + static/game_configs/elixir.csv | 8 +- tests/conftest.py | 37 ++++ tests/test_death.py | 25 +-- tests/test_elixir.py | 324 +++++++++++++++++++++++++++++++++ tests/test_simulator.py | 31 +--- 12 files changed, 502 insertions(+), 103 deletions(-) create mode 100644 tests/test_elixir.py diff --git a/EN_README.md b/EN_README.md index b0b6ffa..aa5bc8a 100644 --- a/EN_README.md +++ b/EN_README.md @@ -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 diff --git a/README.md b/README.md index 451a962..3a41f06 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ - ✅ 角色Effects系统:增益/减益效果 - ✅ 角色功法 - ✅ 角色兵器 & 辅助装备 +- ✅ 丹药 - ✅ 角色长短期记忆 - ✅ 角色的长短期目标,支持玩家主动设定 - ✅ 角色绰号 diff --git a/src/classes/avatar/core.py b/src/classes/avatar/core.py index c540de1..0bbae49 100644 --- a/src/classes/avatar/core.py +++ b/src/classes/avatar/core.py @@ -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: diff --git a/src/classes/effect/mixin.py b/src/classes/effect/mixin.py index ce16772..08b710f 100644 --- a/src/classes/effect/mixin.py +++ b/src/classes/effect/mixin.py @@ -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() - diff --git a/src/classes/elixir.py b/src/classes/elixir.py index 6cd065f..beef6e6 100644 --- a/src/classes/elixir.py +++ b/src/classes/elixir.py @@ -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]]]: diff --git a/src/classes/world.py b/src/classes/world.py index 16551f0..22e966e 100644 --- a/src/classes/world.py +++ b/src/classes/world.py @@ -60,6 +60,7 @@ class World(): "灵石": "修仙界的通用货币。可用于购买法宝丹药,通过采集、交易或掠夺获取。", "宗门": "修士的庇护所。加入宗门可习得独门功法、获同门庇护;散修自由但资源匮乏。", "战斗": "弱肉强食。境界压制极大,高境界者对低境界者有绝对优势。若对方死亡,胜者可掠夺败者财物。", - "动作": "你有一系列可以执行的动作。要注意动作的效果、限制条件、区域和时间。" + "动作": "你有一系列可以执行的动作。要注意动作的效果、限制条件、区域和时间。", + "装备与丹药": "通过兵器、辅助装备、丹药等装备,可以获得额外的属性加成,获得或小或大的增益。拥有好的装备或者服用好的丹药,能获得很大好处。", } return desc \ No newline at end of file diff --git a/src/sim/simulator.py b/src/sim/simulator.py index 94dd8c2..09a623e 100644 --- a/src/sim/simulator.py +++ b/src/sim/simulator.py @@ -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 并行触发奇遇和霉运 diff --git a/static/game_configs/elixir.csv b/static/game_configs/elixir.csv index b2f6604..06201f6 100644 --- a/static/game_configs/elixir.csv +++ b/static/game_configs/elixir.csv @@ -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}]" diff --git a/tests/conftest.py b/tests/conftest.py index 46f7339..9aa5227 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 + } diff --git a/tests/test_death.py b/tests/test_death.py index 7faf56a..c792a40 100644 --- a/tests/test_death.py +++ b/tests/test_death.py @@ -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() diff --git a/tests/test_elixir.py b/tests/test_elixir.py new file mode 100644 index 0000000..a6e0094 --- /dev/null +++ b/tests/test_elixir.py @@ -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 + diff --git a/tests/test_simulator.py b/tests/test_simulator.py index 71a6cac..a00f4cb 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -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