diff --git a/src/classes/avatar/action_mixin.py b/src/classes/avatar/action_mixin.py index cb3039a..6b0b485 100644 --- a/src/classes/avatar/action_mixin.py +++ b/src/classes/avatar/action_mixin.py @@ -155,6 +155,16 @@ 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 get_planned_actions_str(self: "Avatar") -> str: """ diff --git a/src/classes/avatar/core.py b/src/classes/avatar/core.py index 8dd43d9..01e2eef 100644 --- a/src/classes/avatar/core.py +++ b/src/classes/avatar/core.py @@ -4,6 +4,7 @@ Avatar 核心类 精简后的 Avatar 类,通过 Mixin 组合完整功能。 """ import random +from collections import defaultdict from dataclasses import dataclass, field from enum import Enum from typing import Optional, List, TYPE_CHECKING @@ -119,6 +120,9 @@ class Avatar( known_regions: set[int] = field(default_factory=set) + # 关系交互计数器: key=target_id, value={"count": 0, "checked_times": 0} + relation_interaction_states: dict[str, dict[str, int]] = field(default_factory=lambda: defaultdict(lambda: {"count": 0, "checked_times": 0})) + # ========== 宗门相关 ========== def join_sect(self, sect: Sect, rank: "SectRank") -> None: diff --git a/src/classes/calendar.py b/src/classes/calendar.py index f7628a4..da2df0e 100644 --- a/src/classes/calendar.py +++ b/src/classes/calendar.py @@ -41,4 +41,9 @@ class MonthStamp(int): def create_month_stamp(year: Year, month: Month) -> MonthStamp: """从年和月创建MonthStamp""" - return MonthStamp(int(year) * 12 + month.value - 1) \ No newline at end of file + return MonthStamp(int(year) * 12 + month.value - 1) + +def get_date_str(stamp: int) -> str: + """将 MonthStamp (int) 转换为 'X年Y月' 格式""" + ms = MonthStamp(stamp) + return f"{ms.get_year()}年{ms.get_month().value}月" \ No newline at end of file diff --git a/src/classes/mutual_action/conversation.py b/src/classes/mutual_action/conversation.py index facfd8c..593cd76 100644 --- a/src/classes/mutual_action/conversation.py +++ b/src/classes/mutual_action/conversation.py @@ -48,9 +48,6 @@ class Conversation(MutualAction): avatar_name_2: target_avatar.get_info(detailed=True), } - # 获取关系上下文 - possible_new_relations, possible_cancel_relations = get_relation_change_context(self.avatar, target_avatar) - # 获取后续计划 p1 = self.avatar.get_planned_actions_str() p2 = target_avatar.get_planned_actions_str() @@ -62,8 +59,6 @@ class Conversation(MutualAction): "avatar_infos": avatar_infos, "avatar_name_1": avatar_name_1, "avatar_name_2": avatar_name_2, - "possible_new_relations": possible_new_relations, - "possible_cancel_relations": possible_cancel_relations, "planned_actions": planned_actions_str, } @@ -101,15 +96,6 @@ class Conversation(MutualAction): related_avatars=[self.avatar.id, target.id] ) events_to_return.append(content_event) - - # 处理关系变化 (调用通用逻辑) - # 注意:process_relation_changes 可能会生成关系变化的事件 - # 这部分逻辑需要确认是否也遵循新模式。 - # 假设 process_relation_changes 内部使用了 add_event,则需要留意是否存在双重添加风险。 - # 目前看来 process_relation_changes 是通过 EventHelper 或直接 add_event 操作的。 - # 如果它内部逻辑完备(如使用了 EventHelper 去重),则无需改动。 - process_relation_changes(self.avatar, target, result, month_stamp) - return ActionResult(status=ActionStatus.COMPLETED, events=events_to_return) def step(self, target_avatar: "Avatar|str", **kwargs) -> ActionResult: diff --git a/src/classes/relation.py b/src/classes/relation.py index c6ce0c3..8c2ddbe 100644 --- a/src/classes/relation.py +++ b/src/classes/relation.py @@ -57,6 +57,36 @@ INNATE_RELATIONS: set[Relation] = { } +# —— 规则定义 —— + +ADD_RELATION_RULES: dict[Relation, str] = { + Relation.LOVERS: "【道侣】需双方为异性。必须是双方非常相互认可且情投意合。", + Relation.FRIEND: "【朋友】友善互动(交谈、切磋点到为止、治疗)。无实质利益冲突。", + Relation.ENEMY: "【仇人】发生过实质性伤害(攻击致伤、偷窃、羞辱)。单次严重伤害或多次轻微摩擦。", + Relation.MASTER: "【师傅】需境界显著高于徒弟(例如金丹vs练气)。", + Relation.APPRENTICE: "【徒弟】相对于师傅的身份,通常由师傅关系自动确立。", +} + +CANCEL_RELATION_RULES: dict[Relation, str] = { + Relation.LOVERS: "【解除道侣】冲突、感情破裂、发生严重背叛。", + Relation.FRIEND: "【绝交】发生利益冲突、背叛或长期无互动导致疏远。", + Relation.ENEMY: "【化敌为友】一方主动示好并被接受,或共同经历生死患难,或仇恨被冲淡。", + Relation.MASTER: "【逐出师门/叛出师门】徒弟大逆不道或师傅无力教导。", + Relation.APPRENTICE: "【解除师徒】同上。", +} + + +def get_relation_rules_desc() -> str: + """获取关系规则的描述文本,用于 Prompt""" + lines = ["【建立关系规则】"] + for rel, desc in ADD_RELATION_RULES.items(): + lines.append(f"- {desc}") + lines.append("\n【取消关系规则】") + for rel, desc in CANCEL_RELATION_RULES.items(): + lines.append(f"- {desc}") + return "\n".join(lines) + + def is_innate(relation: Relation) -> bool: return relation in INNATE_RELATIONS diff --git a/src/classes/relation_resolver.py b/src/classes/relation_resolver.py new file mode 100644 index 0000000..dce21e0 --- /dev/null +++ b/src/classes/relation_resolver.py @@ -0,0 +1,136 @@ +from __future__ import annotations +from typing import TYPE_CHECKING, List, Tuple + +from src.classes.relation import ( + Relation, + get_relation_rules_desc, + relation_display_names +) +from src.classes.relations import ( + set_relation, + cancel_relation, +) +from src.classes.calendar import get_date_str +from src.classes.event import Event +from src.classes.action.event_helper import EventHelper +from src.utils.llm import call_llm_with_template, LLMMode +from src.utils.config import CONFIG + +if TYPE_CHECKING: + from src.classes.avatar import Avatar + +from src.utils.ai_batch import AITaskBatch + +class RelationResolver: + TEMPLATE_PATH = CONFIG.paths.templates / "relation_update.txt" + + @staticmethod + def _build_prompt_data(avatar_a: "Avatar", avatar_b: "Avatar") -> dict: + # 1. 获取近期交互记录 + # 优先使用 EventManager 的索引 + event_manager = avatar_a.world.event_manager + + # 获取已归档的历史事件 (取最近10条) + # get_events_between 返回的是按时间正序排列的 + recent_events = event_manager.get_events_between(avatar_a.id, avatar_b.id, limit=10) + + event_lines = [str(e) for e in recent_events] + + recent_events_text = "\n".join(event_lines) if event_lines else "近期无显著交互记录。" + + # 2. 获取当前关系描述 + current_rel = avatar_a.get_relation(avatar_b) + rel_desc = "无" + if current_rel: + rel_name = relation_display_names.get(current_rel, current_rel.value) + rel_desc = f"{rel_name}" + + # 获取当前世界时间 + current_time_str = get_date_str(avatar_a.world.month_stamp) + + return { + "relation_rules_desc": get_relation_rules_desc(), + "avatar_a_info": str(avatar_a.get_info(detailed=True)), + "avatar_a_name": avatar_a.name, + "avatar_b_info": str(avatar_b.get_info(detailed=True)), + "current_relations": f"目前关系:{rel_desc}", + "recent_events_text": recent_events_text, + "current_time": current_time_str + } + + @staticmethod + async def resolve_pair(avatar_a: "Avatar", avatar_b: "Avatar") -> None: + """ + 处理一对角色的关系变化 + """ + infos = RelationResolver._build_prompt_data(avatar_a, avatar_b) + + result = await call_llm_with_template(RelationResolver.TEMPLATE_PATH, infos, mode=LLMMode.FAST) + + changed = result.get("changed", False) + if not changed: + return + + month_stamp = avatar_a.world.month_stamp + + c_type = result.get("change_type") + rel_name = result.get("relation") + reason = result.get("reason", "") + + if not rel_name: + return + + # 解析关系枚举 + try: + rel = Relation[rel_name] + except KeyError: + return + + display_name = relation_display_names.get(rel, rel_name) + + if c_type == "ADD": + # 检查是否已有 + # Prompt 定义:输出 MASTER 意味着 A 是 B 的师傅 + # 代码逻辑:set_relation(from, to, rel) -> from.relations[to] = rel (from 认为 to 是 rel) + # 因此,如果 LLM 输出 MASTER (A 是师傅),意味着 A 认为 B 是徒弟(APPRENTICE),B 认为 A 是师傅(MASTER) + # 所以我们要调用 set_relation(B, A, MASTER) 或者 set_relation(A, B, APPRENTICE) + # 统一逻辑:以“谁视谁为某个关系”来思考。 + # 如果 rel 是 A 的身份(如 MASTER),则 B 视 A 为 rel。 + # 调用 set_relation(B, A, rel) 会设置 B.relations[A] = rel + # set_relation 内部会自动处理对偶关系,所以 A.relations[B] 也会被设为 APPRENTICE + + # 但要注意对称关系(LOVERS, FRIEND, ENEMY)。 + # A 是 B 的朋友 -> B 视 A 为 FRIEND。 set_relation(B, A, FRIEND) -> A.relations[B] = FRIEND (正确) + + # 结论:始终调用 set_relation(avatar_b, avatar_a, rel) + + current_rel = avatar_b.get_relation(avatar_a) + if current_rel == rel: + return + + set_relation(avatar_b, avatar_a, rel) + + event_text = f"【关系新增】{reason},{avatar_a.name} 与 {avatar_b.name} 结为{display_name}。" + event = Event(month_stamp, event_text, related_avatars=[avatar_a.id, avatar_b.id], is_major=True) + EventHelper.push_pair(event, initiator=avatar_a, target=avatar_b, to_sidebar_once=True) + + elif c_type == "REMOVE": + # 同样反转调用 + success = cancel_relation(avatar_b, avatar_a, rel) + if success: + event_text = f"【关系断绝】{reason},{avatar_a.name} 与 {avatar_b.name} 不再是{display_name}。" + event = Event(month_stamp, event_text, related_avatars=[avatar_a.id, avatar_b.id], is_major=True) + EventHelper.push_pair(event, initiator=avatar_a, target=avatar_b, to_sidebar_once=True) + + @staticmethod + async def run_batch(pairs: List[Tuple["Avatar", "Avatar"]]) -> None: + """ + 批量并发处理 + """ + if not pairs: + return + + async with AITaskBatch() as batch: + for a, b in pairs: + batch.add(RelationResolver.resolve_pair(a, b)) + diff --git a/src/classes/story_teller.py b/src/classes/story_teller.py index cab32d1..d50ebac 100644 --- a/src/classes/story_teller.py +++ b/src/classes/story_teller.py @@ -73,8 +73,6 @@ class StoryTeller: # 如果有两个有效角色,计算可能的关系 non_null = [a for a in actors if a is not None] if len(non_null) >= 2: - # 计算 actors[1] 相对于 actors[0] 的可能关系 - possible_new_relations, possible_cancel_relations = get_relation_change_context(non_null[0], non_null[1]) avatar_name_1 = non_null[0].name avatar_name_2 = non_null[1].name @@ -86,8 +84,6 @@ class StoryTeller: "res": res, "style": random.choice(story_styles), "story_prompt": prompt, - "possible_new_relations": possible_new_relations, - "possible_cancel_relations": possible_cancel_relations, } @staticmethod @@ -124,14 +120,6 @@ class StoryTeller: # 移除了 try-except 块,允许异常向上冒泡,以便 Fail Fast data = await call_llm_with_template(template_path, infos, LLMMode.FAST) story = data.get("story", "").strip() - - # 仅在双人模式下处理关系变化 - if is_dual: - avatar_1 = non_null[0] - avatar_2 = non_null[1] - # 尝试获取 month_stamp - month_stamp = getattr(avatar_1.world, "month_stamp", 0) - process_relation_changes(avatar_1, avatar_2, data, month_stamp) if story: return story diff --git a/src/sim/simulator.py b/src/sim/simulator.py index 80b4a1c..79b500f 100644 --- a/src/sim/simulator.py +++ b/src/sim/simulator.py @@ -301,6 +301,61 @@ class Simulator: logger.info("EVENT: %s", str(event)) + async def _phase_evolve_relations(self): + """ + 关系演化阶段:检查并处理满足条件的角色关系变化 + """ + from src.classes.relation_resolver import RelationResolver + + pairs_to_resolve = [] + processed_pairs = set() # (id1, id2) id1 < id2 + + living_avatars = self.world.avatar_manager.get_living_avatars() + + for avatar in living_avatars: + target_ids = list(avatar.relation_interaction_states.keys()) + + for target_id in target_ids: + state = avatar.relation_interaction_states[target_id] + target = self.world.avatar_manager.get_avatar(target_id) + + # 判定是否触发 + count = state["count"] + should_trigger = False + + threshold = CONFIG.social.relation_check_threshold + + if count >= threshold: + should_trigger = True + + if should_trigger: + # 确保唯一性 + id1, id2 = sorted([str(avatar.id), str(target.id)]) + pair_key = (id1, id2) + + if pair_key not in processed_pairs: + processed_pairs.add(pair_key) + 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 + + if pairs_to_resolve: + # 批量并发处理 + await RelationResolver.run_batch(pairs_to_resolve) + + return [] + async def step(self): """ 前进一步(每步模拟是一个月时间) @@ -327,29 +382,32 @@ class Simulator: # 3. 执行阶段 events.extend(await self._phase_execute_actions()) - # 4. 结算死亡 + # 4. 关系演化阶段 + await self._phase_evolve_relations() + + # 5. 结算死亡 events.extend(self._phase_resolve_death()) - # 5. 年龄与新生 + # 6. 年龄与新生 events.extend(self._phase_update_age_and_birth()) - # 6. 被动结算(时间效果+奇遇) + # 7. 被动结算(时间效果+奇遇) events.extend(await self._phase_passive_effects()) - # 7. 绰号生成 + # 8. 绰号生成 events.extend(await self._phase_nickname_generation()) - # 8. 更新天地灵机 + # 9. 更新天地灵机 events.extend(self._phase_update_celestial_phenomenon()) - # 9. 日志 + # 10. 日志 # 统一写入事件管理器 if hasattr(self.world, "event_manager") and self.world.event_manager is not None: for e in events: self.world.event_manager.add_event(e) self._phase_log_events(events) - # 9. 时间推进 + # 11. 时间推进 self.world.month_stamp = self.world.month_stamp + 1 diff --git a/static/config.yml b/static/config.yml index 840e93e..16dc675 100644 --- a/static/config.yml +++ b/static/config.yml @@ -36,6 +36,7 @@ avatar: social: major_event_context_num: 10 # 大事(长期记忆)展示数量 minor_event_context_num: 10 # 小事(短期记忆)展示数量 + relation_check_threshold: 5 # 关系检查需要的交互次数 nickname: major_event_threshold: 5 # 获得绰号需要的长期事件数量 diff --git a/static/templates/conversation.txt b/static/templates/conversation.txt index db450a7..eb59e00 100644 --- a/static/templates/conversation.txt +++ b/static/templates/conversation.txt @@ -1,4 +1,4 @@ -你是一个小说家,这是一个仙侠世界,你负责来生成两个NPC间的对话内容,并决定两人是否会有关系的变化。 +你是一个小说家,这是一个仙侠世界,你负责来生成两个NPC间的对话内容。 你需要进行决策的NPC的dict[AvatarName, info]为 {avatar_infos} @@ -7,18 +7,11 @@ {avatar_name_1}和{avatar_name_2}的对话可能是善意\恶意\闲聊。目的和内容参考NPC信息得出。 -两者可能进入的关系:{possible_new_relations} -两者可能取消的关系:{possible_cancel_relations} -注意:进入/取消关系不是必须的,完全由你根据对话情况、双方性格、历史事件等判断决定。 - 注意,只返回json格式的结果。 格式为: {{ "{avatar_name_2}": {{ "thinking": ..., // 简单思考对话如何进行 "conversation_content": ... // 对包含神态、动作描写的第三人称小说有来有回的多轮对话片段,100~300字。 - "analyze_relation": ... // 分析是否应该有关系的取消或者新增 - "new_relation": ... // 如果你认为可以让两者产生某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。 - "cancel_relation": ... // 可选,如果你认为可以让两者取消某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。 }} }} diff --git a/static/templates/relation_update.txt b/static/templates/relation_update.txt new file mode 100644 index 0000000..7eb64e7 --- /dev/null +++ b/static/templates/relation_update.txt @@ -0,0 +1,33 @@ +你是一个修仙世界的关系裁决者。根据角色的历史交互,判断两人的关系是否应该发生变化。 + +【规则定义】 +{relation_rules_desc} + +【角色 A 信息】 +{avatar_a_info} + +【角色 B 信息】 +{avatar_b_info} + +【当前时间】 +{current_time} + +【当前关系】 +{current_relations} + +【近期交互记录】 +{recent_events_text} + +请分析: +1. 根据交互记录,分析两人的互动是怎样的? +2. 是否满足规则定义中建立新关系或取消旧关系的条件? +3. 分析是否应该改变关系,关系的新增或者取消应该符合相关条件。 + +返回 JSON 格式: +{{ + "analysis": "...", // 简要分析思路,明确指出为何变化或为何不变化 + "changed": true | false, // 是否发生关系变更。如无必要,请填 false + "change_type": "ADD" | "REMOVE", // 变更类型。changed为false时可忽略 + "relation": "LOVERS" | "FRIEND" | "ENEMY" | "MASTER" ... (必须是大写枚举名), // 涉及的关系。changed为false时可忽略。注意是{avatar_a_name}相对于{avatar_b_name}的身份。如输出MASTER,即A变为B的师傅。 + "reason": "..." // 用于生成事件日志,如“经过多次生死与共,A与B结为道侣”。changed为false时可忽略 +}} diff --git a/static/templates/story_dual.txt b/static/templates/story_dual.txt index eb5b65c..3d78168 100644 --- a/static/templates/story_dual.txt +++ b/static/templates/story_dual.txt @@ -1,13 +1,8 @@ 你是一个小说家,这是一个仙侠世界,你需要把一个事件扩展为一个约200~500字的故事。 -同时,根据事件发展和双方信息,决定两人是否会有关系的变化。 你需要进行决策的NPC的dict[AvatarName, info]为 {avatar_infos} -两者可能进入的关系:{possible_new_relations} -两者可能取消的关系:{possible_cancel_relations} -注意:进入/取消关系不是必须的,完全由你根据故事情况、双方性格、历史事件等判断决定。 - 写作风格提示:{style} 额外主题提示:{story_prompt} @@ -18,10 +13,6 @@ 注意,只返回json格式的结果,格式为: {{ - "thinking": ..., // 简单思考故事剧情和关系变化 - "story": "", // 第三人称的故事正文,仙侠语言风格 - "analyze_relation": ... // 分析是否应该有关系的取消或者新增,除非很合理,不强求关系的改变 - "new_relation": ... // 如果你认为可以让两者产生某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。 - "cancel_relation": ... // 可选,如果你认为可以让两者取消某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。 + "thinking": ..., // 简单思考故事剧情 + "story": "" // 第三人称的故事正文,仙侠语言风格 }} -