From df8b1b94334b7d7ab88322d8c9a92c2825a34c43 Mon Sep 17 00:00:00 2001 From: 4thfever Date: Wed, 14 Jan 2026 16:58:50 +0800 Subject: [PATCH] Refactor/event (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构事件机制和部分拍卖会机制 --- src/classes/action/escape.py | 5 - src/classes/action/event_helper.py | 8 +- src/classes/avatar/action_mixin.py | 24 ++- src/classes/gathering/auction.py | 76 +++----- src/classes/mutual_action/dual_cultivation.py | 4 - src/classes/mutual_action/gift.py | 5 - src/classes/mutual_action/impart.py | 4 - src/classes/mutual_action/mutual_action.py | 5 - src/classes/mutual_action/occupy.py | 4 - src/classes/relation_resolver.py | 4 - src/classes/world.py | 1 + src/sim/simulator.py | 148 +++++++++------ static/config.yml | 4 +- tests/test_event.py | 178 ++++++++++++++++++ tests/test_simulator.py | 85 ++++++++- web/src/components/LoadingOverlay.vue | 25 +-- 16 files changed, 401 insertions(+), 179 deletions(-) create mode 100644 tests/test_event.py diff --git a/src/classes/action/escape.py b/src/classes/action/escape.py index b11cc00..8ee8505 100644 --- a/src/classes/action/escape.py +++ b/src/classes/action/escape.py @@ -35,11 +35,6 @@ class Escape(InstantAction): avatar.clear_plans() avatar.current_action = None - def _add_event_pair(self, event: Event, initiator: "Avatar", target: "Avatar|None") -> None: - initiator.add_event(event) - if target is not None: - target.add_event(event, to_sidebar=False) - def _execute(self, avatar_name: str) -> None: target = self._find_avatar_by_name(avatar_name) if target is None: diff --git a/src/classes/action/event_helper.py b/src/classes/action/event_helper.py index a5ca927..1312307 100644 --- a/src/classes/action/event_helper.py +++ b/src/classes/action/event_helper.py @@ -14,12 +14,16 @@ class EventHelper: """ @staticmethod def push_pair(event: Event, initiator: "Avatar", target: Optional["Avatar"], *, to_sidebar_once: bool = True) -> None: + """ + 向双方分发事件。 + 发起者侧会进入侧栏(to_sidebar=True),从而被 Simulator 收集并存入全局数据库。 + 由于 Simulator 已经统一处理了相关角色的交互计数,这里无需再手动调用 target.add_event。 + """ initiator.add_event(event, to_sidebar=True) - if target is not None: - target.add_event(event, to_sidebar=(not to_sidebar_once)) @staticmethod def push_self(event: Event, avatar: "Avatar", *, to_sidebar: bool = True) -> None: + """仅向自身分发事件。""" avatar.add_event(event, to_sidebar=to_sidebar) diff --git a/src/classes/avatar/action_mixin.py b/src/classes/avatar/action_mixin.py index 4265e82..becc2dd 100644 --- a/src/classes/avatar/action_mixin.py +++ b/src/classes/avatar/action_mixin.py @@ -168,15 +168,21 @@ class ActionMixin: if to_sidebar: self._pending_events.append(event) - # 增加关系交互计数 - if event.related_avatars: - for aid in event.related_avatars: - if str(aid) == str(self.id): - continue - - # self.id 与 aid 有交互 - # Avatar 核心类已定义 relation_interaction_states - self.relation_interaction_states[aid]["count"] += 1 + def process_interaction_from_event(self: "Avatar", event: "Event") -> None: + """ + 根据事件更新与其他角色的交互计数。 + 该方法由 Simulator 统一调用。 + """ + if not event.related_avatars: + return + + for aid in event.related_avatars: + if str(aid) == str(self.id): + continue + + # self.id 与 aid 有交互 + # relation_interaction_states 是 defaultdict,会自动初始化新条目 + self.relation_interaction_states[aid]["count"] += 1 def get_planned_actions_str(self: "Avatar") -> str: """ diff --git a/src/classes/gathering/auction.py b/src/classes/gathering/auction.py index 18d26df..a63bb65 100644 --- a/src/classes/gathering/auction.py +++ b/src/classes/gathering/auction.py @@ -225,65 +225,39 @@ class Auction(Gathering): return deal_results, unsold_items, all_willing_prices - def _generate_deal_events( + def _generate_auction_events( self, world: "World", - deal_results: Dict["Item", tuple["Avatar", int]] + deal_results: Dict["Item", tuple["Avatar", int]], + willing_prices: Dict["Item", Dict["Avatar", int]] ) -> List[Event]: """ - 生成成交事件 + 生成拍卖事件(合并成交与竞争信息) """ events = [] month_stamp = world.month_stamp for item, (winner, deal_price) in deal_results.items(): - deal_event = Event( - month_stamp=month_stamp, - content=f"在拍卖会上,{winner.name}以 {deal_price} 灵石拍下了{item.name}。", - related_avatars=[winner.id], - is_major=False - ) - events.append(deal_event) - # to_sidebar=False: 不加入 _pending_events,防止重复提交给 Simulator - # 但仍会执行关系计数的更新逻辑 - winner.add_event(deal_event, to_sidebar=False) - - return events - - def _generate_rivalry_events( - self, - world: "World", - willing_prices: Dict["Item", Dict["Avatar", int]], - deal_results: Dict["Item", tuple["Avatar", int]] - ) -> List[Event]: - """ - 生成竞争(压一头)事件 - """ - events = [] - month_stamp = world.month_stamp - - for item in deal_results.keys(): bids = willing_prices.get(item, {}) - if len(bids) < 2: - continue + # 检查是否有竞争者(出价人数 >= 2) + if len(bids) >= 2: + # 获取出价第二名 + sorted_bids = sorted(bids.items(), key=lambda x: x[1], reverse=True) + runner_up = sorted_bids[1][0] - # 获取出价前两名 - sorted_bids = sorted(bids.items(), key=lambda x: x[1], reverse=True) - winner_avatar = sorted_bids[0][0] - runner_up_avatar = sorted_bids[1][0] - - # 生成压一头事件 - rivalry_content = f"在{item.name}的竞拍中,{winner_avatar.name}力压{runner_up_avatar.name}一头,最终将其收入囊中。" - rivalry_event = Event( + content = f"在{item.name}的竞拍中,{winner.name}以 {deal_price} 灵石力压{runner_up.name}一头,将其收入囊中。" + related_avatars = [winner.id, runner_up.id] + else: + content = f"在拍卖会上,{winner.name}以 {deal_price} 灵石拍下了{item.name}。" + related_avatars = [winner.id] + + event = Event( month_stamp=month_stamp, - content=rivalry_content, - related_avatars=[winner_avatar.id, runner_up_avatar.id], + content=content, + related_avatars=related_avatars, is_major=False ) - events.append(rivalry_event) - # 给双方都添加这个互动事件 (to_sidebar=False 防止重复提交) - winner_avatar.add_event(rivalry_event, to_sidebar=False) - runner_up_avatar.add_event(rivalry_event, to_sidebar=False) + events.append(event) return events @@ -390,9 +364,6 @@ class Auction(Gathering): ) events.append(story_event) - for av in related_avatars: - av.add_event(story_event, to_sidebar=False) - return events async def execute(self, world: "World") -> List[Event]: @@ -468,12 +439,9 @@ class Auction(Gathering): for item in unsold_items: world.circulation.remove_item(item) - # 5. 生成基础事件 - deal_events = self._generate_deal_events(world, deal_results) - rivalry_events = self._generate_rivalry_events(world, willing_prices, deal_results) - - events.extend(deal_events) - events.extend(rivalry_events) + # 5. 生成基础事件(合并成交与竞争信息) + auction_events = self._generate_auction_events(world, deal_results, willing_prices) + events.extend(auction_events) # 6. 生成故事 (StoryTeller) story_events = await self._generate_story(world, deal_results, willing_prices) diff --git a/src/classes/mutual_action/dual_cultivation.py b/src/classes/mutual_action/dual_cultivation.py index fd73425..35352aa 100644 --- a/src/classes/mutual_action/dual_cultivation.py +++ b/src/classes/mutual_action/dual_cultivation.py @@ -55,10 +55,6 @@ class DualCultivation(MutualAction): event = Event(self.world.month_stamp, f"{self.avatar.name} 邀请 {target_name} 进行双修", related_avatars=rel_ids, is_major=True) - # 仅手动添加给 Target,Self的部分由ActionMixin通过返回值处理 - if target is not None: - target.add_event(event, to_sidebar=False) - # 记录开始文本用于故事生成 self._start_event_content = event.content # 初始化内部标记,避免后续 getattr diff --git a/src/classes/mutual_action/gift.py b/src/classes/mutual_action/gift.py index 8a13e02..12b9312 100644 --- a/src/classes/mutual_action/gift.py +++ b/src/classes/mutual_action/gift.py @@ -181,11 +181,6 @@ class Gift(MutualAction): related_avatars=rel_ids ) - # 写入历史 - self.avatar.add_event(event, to_sidebar=False) - if target is not None: - target.add_event(event, to_sidebar=False) - self._gift_success = False return event diff --git a/src/classes/mutual_action/impart.py b/src/classes/mutual_action/impart.py index 2a7df23..274f3f6 100644 --- a/src/classes/mutual_action/impart.py +++ b/src/classes/mutual_action/impart.py @@ -65,10 +65,6 @@ class Impart(MutualAction): f"{self.avatar.name} 向徒弟 {target_name} 传道授业", related_avatars=rel_ids ) - # 仅写入历史 - self.avatar.add_event(event, to_sidebar=False) - if target is not None: - target.add_event(event, to_sidebar=False) # 初始化内部标记 self._impart_success = False self._impart_exp_gain = 0 diff --git a/src/classes/mutual_action/mutual_action.py b/src/classes/mutual_action/mutual_action.py index c9ff534..858972d 100644 --- a/src/classes/mutual_action/mutual_action.py +++ b/src/classes/mutual_action/mutual_action.py @@ -179,11 +179,6 @@ class MutualAction(DefineAction, LLMAction, ActualActionMixin, TargetingMixin): is_major = self.__class__.IS_MAJOR if hasattr(self.__class__, 'IS_MAJOR') else False event = Event(self._start_month_stamp, f"{self.avatar.name} 对 {target_name} 发起 {action_name}", related_avatars=rel_ids, is_major=is_major) - # 仅手动添加给 Target,Self的部分由ActionMixin通过返回值处理 - # 默认不推Target侧边栏,因为发起事件通常只在发起者侧重要,或者作为"收到发起"的通知 - if target is not None: - target.add_event(event, to_sidebar=False) - return event def step(self, target_avatar: "Avatar|str") -> ActionResult: diff --git a/src/classes/mutual_action/occupy.py b/src/classes/mutual_action/occupy.py index 6355727..b4dc80f 100644 --- a/src/classes/mutual_action/occupy.py +++ b/src/classes/mutual_action/occupy.py @@ -81,10 +81,6 @@ class Occupy(MutualAction): related_avatars=rel_ids, is_major=self.IS_MAJOR ) - # 记录到历史,侧边栏推送由 ActionMixin.commit_next_plan 统一处理 - self.avatar.add_event(event, to_sidebar=False) - if host: - host.add_event(event, to_sidebar=False) return event diff --git a/src/classes/relation_resolver.py b/src/classes/relation_resolver.py index 65a14d8..8c283cc 100644 --- a/src/classes/relation_resolver.py +++ b/src/classes/relation_resolver.py @@ -121,10 +121,6 @@ class RelationResolver: event = Event(month_stamp, event_text, related_avatars=[avatar_a.id, avatar_b.id], is_major=True) if event: - # 手动调用 add_event(to_sidebar=False) 来更新统计数据,但不加入 pending_events - # 因为事件将由 Simulator 统一处理 - avatar_a.add_event(event, to_sidebar=False) - avatar_b.add_event(event, to_sidebar=False) return event return None diff --git a/src/classes/world.py b/src/classes/world.py index 1679e76..300c1ac 100644 --- a/src/classes/world.py +++ b/src/classes/world.py @@ -73,6 +73,7 @@ class World(): "动作": "你有一系列可以执行的动作。要注意动作的效果、限制条件、区域和时间。", "装备与丹药": "通过兵器、辅助装备、丹药等装备,可以获得额外的属性加成,获得或小或大的增益。拥有好的装备或者服用好的丹药,能获得很大好处。", "购物": "在城市区域可以购买练气级别丹药、兵器。购买丹药后会立刻服用强化自身。购买兵器可以帮自己切换兵器类型为顺手的类型。", + "拍卖会": "每隔一段不确定的时间会有神秘人组织的拍卖会,或许有好货出售。" } if self.history: desc["历史"] = self.history diff --git a/src/sim/simulator.py b/src/sim/simulator.py index 48253eb..7939c4c 100644 --- a/src/sim/simulator.py +++ b/src/sim/simulator.py @@ -33,11 +33,10 @@ class Simulator: from src.classes.observe import get_avatar_observation_radius from src.classes.region import CultivateRegion + events = [] # 1. 缓存当前有洞府的角色ID avatars_with_home = set() - # 注意:这里我们只关心 CultivateRegion 的 host - # map.cultivate_regions 可能需要确保被正确初始化,如果没有,可以回退到遍历所有 regions - # 为了稳妥,遍历所有 Region 筛选 + # ... cultivate_regions = [ r for r in self.world.map.regions.values() if isinstance(r, CultivateRegion) @@ -49,13 +48,11 @@ class Simulator: # 2. 遍历所有存活角色 for avatar in self.world.avatar_manager.get_living_avatars(): + # ... # 计算感知半径(曼哈顿距离) radius = get_avatar_observation_radius(avatar) - # 扫描范围内的坐标 - # 优化:只扫描半径内的坐标可能比遍历所有region快,也可能慢,取决于地图大小和半径 - # 地图可能很大,半径通常很小(<10),所以基于坐标扫描更优 - + # ... # 获取范围内的有效坐标 start_x = max(0, avatar.pos_x - radius) end_x = min(self.world.map.width - 1, avatar.pos_x + radius) @@ -91,8 +88,9 @@ class Simulator: f"{avatar.name} 路过 {region.name},发现无主,将其占据。", related_avatars=[avatar.id] ) - avatar.add_event(event) - + events.append(event) + return events + async def _phase_decide_actions(self): """ 决策阶段:仅对需要新计划的角色调用 AI(当前无动作且无计划), @@ -328,6 +326,34 @@ class Simulator: for event in events: logger.info("EVENT: %s", str(event)) + def _phase_process_interactions(self, events: list[Event]): + """ + 处理事件中的交互逻辑: + 遍历所有事件,如果事件涉及多个角色,自动更新这些角色之间的交互计数。 + """ + for event in events: + if not event.related_avatars or len(event.related_avatars) < 2: + continue + + # 只有当事件涉及 >=2 个角色时才视为交互 + for aid in event.related_avatars: + avatar = self.world.avatar_manager.get_avatar(aid) + if avatar: + avatar.process_interaction_from_event(event) + + def _phase_handle_interactions(self, events: list[Event], processed_ids: set[str]): + """ + 从事件列表中提取尚未处理过的交互事件,并更新交互计数。 + """ + new_interactions = [] + for e in events: + if e.id not in processed_ids: + if e.related_avatars and len(e.related_avatars) >= 2: + new_interactions.append(e) + processed_ids.add(e.id) + + if new_interactions: + self._phase_process_interactions(new_interactions) async def _phase_evolve_relations(self): """ @@ -351,15 +377,8 @@ class Simulator: continue # 判定是否触发 - count = state["count"] - should_trigger = False - threshold = CONFIG.social.relation_check_threshold - - if count >= threshold: - should_trigger = True - - if should_trigger: + if state["count"] >= threshold: # 确保唯一性 id1, id2 = sorted([str(avatar.id), str(target.id)]) pair_key = (id1, id2) @@ -369,17 +388,14 @@ class Simulator: pairs_to_resolve.append((avatar, target)) # 重置双方的计数器,防止重复触发 - # 1. 重置 A 侧 state["count"] = 0 state["checked_times"] += 1 - # 2. 重置 B 侧 (如果 B 也有状态记录) - if hasattr(target, "relation_interaction_states"): - # target 对 avatar 的记录 - t_state = target.relation_interaction_states[str(avatar.id)] - t_state["count"] = 0 - t_state["checked_times"] += 1 + # 2. 重置 B 侧 + t_state = target.relation_interaction_states[str(avatar.id)] + t_state["count"] = 0 + t_state["checked_times"] += 1 events = [] if pairs_to_resolve: @@ -392,22 +408,33 @@ class Simulator: async def step(self): """ - 前进一步(每步模拟是一个月时间) - 结算这个时间内的所有情况。 - 角色行为、世界变化、重大事件、etc。 - 先结算多个角色间互相交互的事件。 - 再去结算单个角色的事件。 + 前进一个时间步(一个月): + 1. 感知与认知更新(及自动占据洞府) + 2. 长期目标思考 + 3. Gathering 多人聚集结算 + 4. 决策阶段 (AI 选择动作) + 5. 提交阶段 (开始执行动作) + 6. 执行阶段 (动作 Tick) + 7. 处理初步交互计数 (用于后续关系演化) + 8. 关系演化阶段 + 9. 结算死亡 + 10. 年龄与新生 + 11. 被动结算 (丹药、时间效果、奇遇) + 12. 绰号生成 + 13. 天地灵机更新 + 14. 处理剩余交互计数 (如奇遇产生的交互) + 15. 归档与时间推进 """ - events = [] # list of Event + events: list[Event] = [] + processed_event_ids: set[str] = set() - # 1. 感知与认知更新阶段(包括自动占据洞府) - # 在思考和决策之前,先让角色感知世界 - self._phase_update_perception_and_knowledge() + # 1. 感知与认知更新 + events.extend(self._phase_update_perception_and_knowledge()) - # 2. 长期目标思考阶段(在决策之前) + # 2. 长期目标思考 events.extend(await self._phase_long_term_objective_thinking()) - # 3. Gathering 结算阶段 + # 3. Gathering 结算 events.extend(await self._phase_process_gatherings()) # 4. 决策阶段 @@ -419,42 +446,53 @@ class Simulator: # 6. 执行阶段 events.extend(await self._phase_execute_actions()) - # 7. 关系演化阶段 + # 7. 处理初步交互计数 + self._phase_handle_interactions(events, processed_event_ids) + + # 8. 关系演化 events.extend(await self._phase_evolve_relations()) - # 8. 结算死亡 + # 9. 结算死亡 events.extend(self._phase_resolve_death()) - # 9. 年龄与新生 + # 10. 年龄与新生 events.extend(self._phase_update_age_and_birth()) - # 10. 被动结算(时间效果+奇遇) + # 11. 被动结算 events.extend(await self._phase_passive_effects()) - # 11. 绰号生成 + # 12. 绰号生成 events.extend(await self._phase_nickname_generation()) - # 12. 更新天地灵机 + # 13. 更新天地灵机 events.extend(self._phase_update_celestial_phenomenon()) - # 13. 日志 - # 去重:基于 ID 去重,防止同一个事件对象(或相同ID的事件)被多次添加 - # 常见情况:Gathering 既返回了事件,又将其加入了 Avatar 的 pending_events - unique_events = {} + # 14. 处理剩余阶段的交互计数 + self._phase_handle_interactions(events, processed_event_ids) + + # 15. 归档与时间推进 + return self._finalize_step(events) + + def _finalize_step(self, events: list[Event]) -> list[Event]: + """ + 本轮步进的最终归档:去重、入库、打日志、推进时间。 + """ + # 1. 基于 ID 去重(防止同一个事件对象被多次添加) + unique_events: dict[str, Event] = {} for e in events: if e.id not in unique_events: unique_events[e.id] = e - # 保持原有顺序(Python 3.7+ dict 保持插入序) - events = list(unique_events.values()) + final_events = list(unique_events.values()) - # 统一写入事件管理器 - if hasattr(self.world, "event_manager") and self.world.event_manager is not None: - for e in events: + # 2. 统一写入事件管理器 + if self.world.event_manager: + for e in final_events: self.world.event_manager.add_event(e) - self._phase_log_events(events) + + # 3. 记录日志 + self._phase_log_events(final_events) - # 14. 时间推进 + # 4. 时间推进 self.world.month_stamp = self.world.month_stamp + 1 - - - return events + + return final_events diff --git a/static/config.yml b/static/config.yml index 04ed344..326e8a9 100644 --- a/static/config.yml +++ b/static/config.yml @@ -1,5 +1,5 @@ meta: - version: "1.1.0" + version: "1.2.0" llm: default_modes: @@ -28,7 +28,7 @@ game: fortune_probability: 0.005 misfortune_probability: 0.005 gathering: - auction_trigger_count: 10 + auction_trigger_count: 5 df: ids_separator: ";" diff --git a/tests/test_event.py b/tests/test_event.py new file mode 100644 index 0000000..68448e2 --- /dev/null +++ b/tests/test_event.py @@ -0,0 +1,178 @@ + +import pytest +from unittest.mock import MagicMock, patch +from src.classes.event import Event +from src.sim.simulator import Simulator +from src.classes.calendar import create_month_stamp, Year, Month + +class TestEventLogic: + + @pytest.fixture + def avatar_a(self, dummy_avatar): + dummy_avatar.id = "avatar_a" + dummy_avatar.name = "角色A" + # 重置交互状态 + dummy_avatar.relation_interaction_states.clear() + return dummy_avatar + + @pytest.fixture + def avatar_b(self, base_world): + from src.classes.avatar.core import Avatar, Gender + from src.classes.age import Age + from src.classes.cultivation import Realm + from src.classes.root import Root + from src.classes.alignment import Alignment + + av = Avatar( + world=base_world, + name="角色B", + id="avatar_b", + birth_month_stamp=create_month_stamp(Year(2000), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.FEMALE, + pos_x=0, + pos_y=0, + root=Root.WATER, + personas=[], + alignment=Alignment.RIGHTEOUS + ) + av.relation_interaction_states.clear() + return av + + def test_process_interaction_from_event(self, avatar_a, avatar_b): + """测试 Avatar.process_interaction_from_event 是否正确增加计数""" + month_stamp = avatar_a.world.month_stamp + event = Event( + month_stamp=month_stamp, + content="A与B发生了互动", + related_avatars=[avatar_a.id, avatar_b.id] + ) + + # 初始计数应为 0 + assert avatar_a.relation_interaction_states[avatar_b.id]["count"] == 0 + assert avatar_b.relation_interaction_states[avatar_a.id]["count"] == 0 + + # 处理事件 + avatar_a.process_interaction_from_event(event) + avatar_b.process_interaction_from_event(event) + + # 计数应增加 + assert avatar_a.relation_interaction_states[avatar_b.id]["count"] == 1 + assert avatar_b.relation_interaction_states[avatar_a.id]["count"] == 1 + + def test_process_interaction_self_exclusion(self, avatar_a): + """测试交互计数是否排除了自身""" + month_stamp = avatar_a.world.month_stamp + event = Event( + month_stamp=month_stamp, + content="A自己做了某事", + related_avatars=[avatar_a.id] + ) + + avatar_a.process_interaction_from_event(event) + + # 不应该有自己的交互记录 + assert avatar_a.id not in avatar_a.relation_interaction_states + + def test_simulator_phase_handle_interactions(self, base_world, avatar_a, avatar_b): + """测试 Simulator._phase_handle_interactions 的增量处理逻辑""" + # 注册角色 + base_world.avatar_manager.register_avatar(avatar_a) + base_world.avatar_manager.register_avatar(avatar_b) + + sim = Simulator(base_world) + processed_ids = set() + + event1 = Event(base_world.month_stamp, "事件1", related_avatars=[avatar_a.id, avatar_b.id]) + event2 = Event(base_world.month_stamp, "事件2", related_avatars=[avatar_a.id, avatar_b.id]) + + # 1. 处理第一批事件 + sim._phase_handle_interactions([event1], processed_ids) + assert avatar_a.relation_interaction_states[avatar_b.id]["count"] == 1 + assert event1.id in processed_ids + + # 2. 再次处理相同的事件(模拟 Phase 14 补漏但去重) + sim._phase_handle_interactions([event1, event2], processed_ids) + # event1 应该被跳过,event2 应该被处理 + assert avatar_a.relation_interaction_states[avatar_b.id]["count"] == 2 + assert event2.id in processed_ids + + @pytest.mark.asyncio + async def test_simulator_full_step_interaction_counting(self, base_world, avatar_a, avatar_b, mock_llm_managers): + """测试 Simulator.step 完整流程中的交互计数统计""" + # 将角色注册到世界 + base_world.avatar_manager.register_avatar(avatar_a) + base_world.avatar_manager.register_avatar(avatar_b) + + # Mock 一个返回事件的阶段,例如 Action 执行 + # 我们直接手动模拟 events 列表的变化 + + sim = Simulator(base_world) + + # 构造一个交互事件 + interaction_event = Event( + base_world.month_stamp, + "发生了某种跨阶段的交互", + related_avatars=[avatar_a.id, avatar_b.id] + ) + + # 我们通过 patch 让某些阶段返回这个事件 + with patch.object(Simulator, "_phase_execute_actions", return_value=[interaction_event]): + # 执行一步 + await sim.step() + + # 验证交互计数是否正确增加 + # 注意:由于我们在 step 中有两个 Phase (7 和 14) 调用了 _phase_handle_interactions, + # 且通过 processed_event_ids 去重,所以最终计数应该是 1 而不是 2。 + assert avatar_a.relation_interaction_states[avatar_b.id]["count"] == 1 + assert avatar_b.relation_interaction_states[avatar_a.id]["count"] == 1 + + @pytest.mark.asyncio + async def test_simulator_relation_evolution_trigger(self, base_world, avatar_a, avatar_b, mock_llm_managers): + """测试交互计数达到阈值时触发关系演化并重置计数""" + from src.utils.config import CONFIG + threshold = CONFIG.social.relation_check_threshold + + # 1. 注册角色并人工设置高计数 + base_world.avatar_manager.register_avatar(avatar_a) + base_world.avatar_manager.register_avatar(avatar_b) + + avatar_a.relation_interaction_states[avatar_b.id]["count"] = threshold + avatar_b.relation_interaction_states[avatar_a.id]["count"] = threshold + + sim = Simulator(base_world) + + # 2. 模拟 LLM 返回关系变化 + mock_llm_managers["rr"].return_value = [Event(base_world.month_stamp, "关系进化了", related_avatars=[avatar_a.id, avatar_b.id])] + + # 3. 执行关系演化阶段 + events = await sim._phase_evolve_relations() + + # 4. 验证 + assert len(events) == 1 + assert "关系进化了" in events[0].content + # 计数器应该被重置 + assert avatar_a.relation_interaction_states[avatar_b.id]["count"] == 0 + assert avatar_b.relation_interaction_states[avatar_a.id]["count"] == 0 + # 检查次数应该增加 + assert avatar_a.relation_interaction_states[avatar_b.id]["checked_times"] == 1 + assert avatar_b.relation_interaction_states[avatar_a.id]["checked_times"] == 1 + + def test_event_helper_push_pair_no_longer_calls_target_add_event(self, avatar_a, avatar_b): + """测试 EventHelper.push_pair 是否已移除冗余的 target.add_event 调用""" + from src.classes.action.event_helper import EventHelper + + event = Event(avatar_a.world.month_stamp, "测试事件", related_avatars=[avatar_a.id, avatar_b.id]) + + # 重置 mock 或记录 + avatar_a._pending_events = [] + avatar_b._pending_events = [] + + EventHelper.push_pair(event, initiator=avatar_a, target=avatar_b) + + # 应该只有发起者收到了事件(进入待处理队列) + assert len(avatar_a._pending_events) == 1 + assert len(avatar_b._pending_events) == 0 + + # 验证交互计数没有在这里被增加(因为现在由 Simulator 统一处理) + assert avatar_a.relation_interaction_states[avatar_b.id]["count"] == 0 diff --git a/tests/test_simulator.py b/tests/test_simulator.py index a00f4cb..ae58105 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -7,7 +7,8 @@ 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, mock_llm_managers): +@pytest.mark.asyncio +async 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 @@ -29,8 +30,8 @@ def test_simulator_step_moves_avatar_and_sets_tile(base_world, dummy_avatar, moc # 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()) + # Run step + await 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}") @@ -44,3 +45,81 @@ def test_simulator_step_moves_avatar_and_sets_tile(base_world, dummy_avatar, moc assert dummy_avatar.tile is not None assert dummy_avatar.tile.x == 3 assert dummy_avatar.tile.y == 1 + +@pytest.mark.asyncio +async def test_simulator_interaction_counting(base_world, dummy_avatar, mock_llm_managers): + """测试交互计数在 step 中被正确处理且去重""" + from src.classes.event import Event + from src.classes.avatar import Avatar, Gender + from src.classes.age import Age + from src.classes.cultivation import Realm + from src.classes.calendar import create_month_stamp, Year, Month + from src.classes.root import Root + from src.classes.alignment import Alignment + + sim = Simulator(base_world) + + # 创建另一个角色用于交互 + other_avatar = Avatar( + world=base_world, + name="OtherAvatar", + id="other_id", + birth_month_stamp=create_month_stamp(Year(2000), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.FEMALE, + pos_x=0, + pos_y=0, + root=Root.WOOD, + alignment=Alignment.NEUTRAL + ) + + base_world.avatar_manager.register_avatar(dummy_avatar) + base_world.avatar_manager.register_avatar(other_avatar) + + # 1. 模拟第一阶段(执行动作)产生的事件 + ev1 = Event(base_world.month_stamp, "阶段1交互", related_avatars=[dummy_avatar.id, other_avatar.id]) + + # 2. 模拟第二阶段(被动效果/奇遇)产生的事件 + ev2 = Event(base_world.month_stamp, "阶段2交互", related_avatars=[dummy_avatar.id, other_avatar.id]) + + with patch.object(sim, '_phase_execute_actions', new_callable=AsyncMock) as mock_exec, \ + patch.object(sim, '_phase_passive_effects', new_callable=AsyncMock) as mock_passive: + + mock_exec.return_value = [ev1] + mock_passive.return_value = [ev2] + + await sim.step() + + # 验证交互计数是否增加了 2 (两个独立事件) + count = dummy_avatar.relation_interaction_states[other_avatar.id]["count"] + assert count == 2 + +@pytest.mark.asyncio +async def test_simulator_event_deduplication(base_world, dummy_avatar, mock_llm_managers): + """测试事件去重逻辑:同一个事件对象在多个阶段被返回时,入库应只有一次""" + from src.classes.event import Event + sim = Simulator(base_world) + base_world.avatar_manager.register_avatar(dummy_avatar) + + ev = Event(base_world.month_stamp, "重复事件") + ev_id = ev.id + + # 模拟两个不同的阶段返回了同一个 ID 的事件对象 + with patch.object(sim, '_phase_execute_actions', new_callable=AsyncMock) as mock_exec, \ + patch.object(sim, '_phase_passive_effects', new_callable=AsyncMock) as mock_passive: + + mock_exec.return_value = [ev] + mock_passive.return_value = [ev] + + # 拦截 event_manager.add_event + base_world.event_manager.add_event = MagicMock() + + await sim.step() + + # 验证该 ID 的事件只被添加了一次 + # 我们过滤出 ID 匹配的调用 + target_calls = [ + call for call in base_world.event_manager.add_event.call_args_list + if call.args[0].id == ev_id + ] + assert len(target_calls) == 1 diff --git a/web/src/components/LoadingOverlay.vue b/web/src/components/LoadingOverlay.vue index 3d1b380..b3e6c6c 100644 --- a/web/src/components/LoadingOverlay.vue +++ b/web/src/components/LoadingOverlay.vue @@ -14,16 +14,6 @@ const phaseTexts: Record = { 'initializing_sects': '宗门入世', 'generating_avatars': '众修士降临', 'checking_llm': '连通天道意志', - 'generating_initial_events': [ - '天道轮转,命运初显', - '因果交织,机缘暗涌', - '气运流转,风云将起', - '众生沉浮,天机莫测', - '劫数将至,各凭造化', - '红尘万丈,道心初定', - '缘起缘灭,皆是天意', - '大道无形,万法归一', - ], 'loading_save': '读取前世因果', 'parsing_data': '解析天地法则', 'restoring_state': '恢复时空位面', @@ -32,9 +22,6 @@ const phaseTexts: Record = { '': '混沌初始', } -// 用于 generating_initial_events 阶段的轮换文案。 -const eventPhaseTextIndex = ref(0) - // Tips 列表 const tips = [ '修改角色目标,可以改变该角色的行事风格', @@ -56,6 +43,7 @@ const tips = [ '由于大模型需要思考,游戏启动可能耗时较久', '模拟世界对大模型token消耗较大,请注意', '开局时设定历史,整个修仙世界也会随之而改变', + '拍卖会中拍到的珍宝可能大大提升你的实力,但是要留好灵石', ] const currentTip = ref(tips[Math.floor(Math.random() * tips.length)]) @@ -67,11 +55,7 @@ let elapsedInterval: ReturnType | null = null const progress = computed(() => displayProgress.value) const phaseText = computed(() => { const phaseName = props.status?.phase_name || '' - const text = phaseTexts[phaseName] || phaseTexts[''] - if (Array.isArray(text)) { - return text[eventPhaseTextIndex.value % text.length] - } - return text + return phaseTexts[phaseName] || phaseTexts[''] }) const isError = computed(() => props.status?.status === 'error') const errorMessage = computed(() => props.status?.error || '未知错误') @@ -141,11 +125,6 @@ function startTimers() { } } } - - // 每 5 秒切换一次 generating_initial_events 的文案。 - if (localElapsed.value % 5 === 0) { - eventPhaseTextIndex.value++ - } }, 1000) }