From d2cf56815451ef0f712c0e119f285a859843d705 Mon Sep 17 00:00:00 2001 From: bridge Date: Wed, 19 Nov 2025 00:04:38 +0800 Subject: [PATCH] add long term objective --- src/classes/ai.py | 8 +- src/classes/avatar.py | 22 ++- src/classes/long_term_objective.py | 191 +++++++++++++++++++++++ src/classes/mutual_action/talk.py | 2 +- src/classes/world.py | 10 +- src/sim/load/avatar_load_mixin.py | 14 +- src/sim/save/avatar_save_mixin.py | 7 +- src/sim/simulator.py | 20 ++- static/templates/ai.txt | 4 +- static/templates/long_term_objective.txt | 31 ++++ 10 files changed, 291 insertions(+), 18 deletions(-) create mode 100644 src/classes/long_term_objective.py create mode 100644 static/templates/long_term_objective.txt diff --git a/src/classes/ai.py b/src/classes/ai.py index f275f47..324e889 100644 --- a/src/classes/ai.py +++ b/src/classes/ai.py @@ -39,9 +39,9 @@ class AI(ABC): results.update(await self._decide(world, avatars_to_decide[i:i+max_decide_num])) for avatar, result in list(results.items()): - action_name_params_pairs, avatar_thinking, objective = result # type: ignore + action_name_params_pairs, avatar_thinking, short_term_objective = result # type: ignore # 不在决策阶段生成开始事件,提交阶段统一触发 - results[avatar] = (action_name_params_pairs, avatar_thinking, objective, NULL_EVENT) + results[avatar] = (action_name_params_pairs, avatar_thinking, short_term_objective, NULL_EVENT) return results @@ -90,8 +90,8 @@ class LLMAI(AI): raise ValueError(f"LLM未返回有效的action_name_params_pairs: {r}") avatar_thinking = r.get("avatar_thinking", r.get("thinking", "")) - objective = r.get("objective", "") - results[avatar] = (pairs, avatar_thinking, objective) + short_term_objective = r.get("short_term_objective", "") + results[avatar] = (pairs, avatar_thinking, short_term_objective) return results llm_ai = LLMAI() \ No newline at end of file diff --git a/src/classes/avatar.py b/src/classes/avatar.py index fb66247..2917566 100644 --- a/src/classes/avatar.py +++ b/src/classes/avatar.py @@ -44,6 +44,7 @@ from src.classes.sect import Sect from src.classes.appearance import Appearance, get_random_appearance from src.classes.battle import get_base_strength from src.classes.spirit_animal import SpiritAnimal +from src.classes.long_term_objective import LongTermObjective persona_num = CONFIG.avatar.persona_num @@ -87,7 +88,8 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): current_action: Optional[ActionInstance] = None planned_actions: List[ActionPlan] = field(default_factory=list) thinking: str = "" - objective: str = "" + short_term_objective: str = "" + long_term_objective: Optional[LongTermObjective] = None magic_stone: MagicStone = field(default_factory=lambda: MagicStone(0)) # 灵石,即货币 items: dict[Item, int] = field(default_factory=dict) hp: HP = field(default_factory=lambda: HP(0, 0)) # 将在__post_init__中初始化 @@ -268,6 +270,12 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): # 灵兽:仅在存在时显示 if self.spirit_animal is not None: info_dict["灵兽"] = spirit_animal_info + # 长期目标:仅在存在时显示 + if self.long_term_objective is not None: + info_dict["长期目标"] = self.long_term_objective.content + # 短期目标:仅在存在时显示 + if self.short_term_objective: + info_dict["短期目标"] = self.short_term_objective return info_dict def __str__(self) -> str: @@ -289,14 +297,14 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): action_cls = ActionRegistry.get(action_name) return action_cls(self, self.world) - def load_decide_result_chain(self, action_name_params_pairs: ACTION_NAME_PARAMS_PAIRS, avatar_thinking: str, objective: str): + def load_decide_result_chain(self, action_name_params_pairs: ACTION_NAME_PARAMS_PAIRS, avatar_thinking: str, short_term_objective: str): """ 加载AI的决策结果(动作链),立即设置第一个为当前动作,其余进入队列。 """ if not action_name_params_pairs: return self.thinking = avatar_thinking - self.objective = objective + self.short_term_objective = short_term_objective # 转为计划并入队(不立即提交,交由提交阶段统一触发开始事件) plans: List[ActionPlan] = [ActionPlan(name, params) for name, params in action_name_params_pairs] self.planned_actions.extend(plans) @@ -563,6 +571,7 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): info["观察到的角色"] = observed info["长期记忆"] = major_list info["短期记忆"] = minor_list + info["长期目标"] = self.long_term_objective.content if self.long_term_objective else "无" return info def get_hover_info(self) -> list[str]: @@ -617,9 +626,12 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): if self.thinking: from src.utils.text_wrap import wrap_text add_section(lines, "思考", wrap_text(self.thinking, 28)) - if getattr(self, "objective", None): + if self.long_term_objective: from src.utils.text_wrap import wrap_text - add_section(lines, "目标", wrap_text(self.objective, 28)) + add_section(lines, "长期目标", wrap_text(self.long_term_objective.content, 28)) + if self.short_term_objective: + from src.utils.text_wrap import wrap_text + add_section(lines, "短期目标", wrap_text(self.short_term_objective, 28)) # 兵器(必有,使用颜色标记等级) if self.weapon is not None: diff --git a/src/classes/long_term_objective.py b/src/classes/long_term_objective.py new file mode 100644 index 0000000..a40e634 --- /dev/null +++ b/src/classes/long_term_objective.py @@ -0,0 +1,191 @@ +""" +长期目标模块 +为角色生成和管理长期目标(3-5年) +""" +from __future__ import annotations +from dataclasses import dataclass +from typing import Optional, TYPE_CHECKING +import random + +if TYPE_CHECKING: + from src.classes.avatar import Avatar + +from src.classes.event import Event +from src.utils.config import CONFIG +from src.utils.llm import get_prompt_and_call_llm_async +from src.run.log import get_logger + +logger = get_logger().logger + + +@dataclass +class LongTermObjective: + """长期目标类""" + content: str # 目标内容 + origin: str # "llm" 或 "user" + set_year: int # 设定时的年份 + + +def can_generate_long_term_objective(avatar: "Avatar") -> bool: + """ + 检查角色是否需要生成/更新长期目标 + + 规则: + 1. 已有用户设定的目标,永不自动生成 + 2. 无目标时,可以生成 + 3. 距离上次设定 <3年,不生成 + 4. 距离上次设定 ≥5年,必定生成 + 5. 距离上次设定 3-5年,按概率生成(渐进概率) + + Args: + avatar: 要检查的角色 + + Returns: + 是否应该生成长期目标 + """ + # 已有用户设定的目标,不再自动生成 + if avatar.long_term_objective and avatar.long_term_objective.origin == "user": + return False + + current_year = avatar.world.month_stamp.get_year() + + # 首次设定(无目标) + if not avatar.long_term_objective: + return True + + years_passed = current_year - avatar.long_term_objective.set_year + + if years_passed < 3: + return False + elif years_passed >= 5: + return True + else: # 3-5年之间 + # 渐进概率:3年时10%,4年时50%,接近5年时接近100% + probability = (years_passed - 3) / 2 * 0.9 + 0.1 + return random.random() < probability + + +async def generate_long_term_objective(avatar: "Avatar") -> Optional[LongTermObjective]: + """ + 为角色生成长期目标 + + 调用LLM基于角色信息和事件历史生成合适的长期目标 + + Args: + avatar: 要生成长期目标的角色 + + Returns: + 生成的LongTermObjective对象,失败则返回None + """ + + # 准备世界信息 + world_info = avatar.world.get_info() + + # 准备角色信息 + avatar_info = avatar.get_info(detailed=True) + avatar_info_str = "\n".join([f"{k}: {v}" for k, v in avatar_info.items()]) + + # 获取事件历史 + em = avatar.world.event_manager + major_limit = CONFIG.social.major_event_context_num + minor_limit = CONFIG.social.minor_event_context_num + major_events = em.get_major_events_by_avatar(avatar.id, limit=major_limit) + minor_events = em.get_minor_events_by_avatar(avatar.id, limit=minor_limit) + + major_events_str = "\n".join([f"- {str(e)}" for e in major_events]) if major_events else "无" + minor_events_str = "\n".join([f"- {str(e)}" for e in minor_events]) if minor_events else "无" + + # 准备模板参数 + template_path = CONFIG.paths.templates / "long_term_objective.txt" + infos = { + "world_info": world_info, + "avatar_info": avatar_info_str, + "major_events": major_events_str, + "minor_events": minor_events_str + } + + # 调用LLM并自动解析JSON(使用fast模型) + response_data = await get_prompt_and_call_llm_async(template_path, infos, mode="fast") + + content = response_data.get("long_term_objective", "").strip() + + if not content: + logger.warning(f"为角色 {avatar.name} 生成长期目标失败:返回空内容") + return None + + current_year = avatar.world.month_stamp.get_year() + objective = LongTermObjective( + content=content, + origin="llm", + set_year=current_year + ) + + logger.info(f"为角色 {avatar.name} 生成长期目标:{content}") + + return objective + + + +async def process_avatar_long_term_objective(avatar: "Avatar") -> Optional[Event]: + """ + 处理单个角色的长期目标生成/更新 + + 检查角色是否需要生成目标,需要则生成并返回对应事件 + + Args: + avatar: 要处理的角色 + + Returns: + 生成的事件,如果不需要生成或生成失败则返回None + """ + if not can_generate_long_term_objective(avatar): + return None + + old_objective = avatar.long_term_objective + new_objective = await generate_long_term_objective(avatar) + + if not new_objective: + return None + + avatar.long_term_objective = new_objective + + # 生成事件 + if old_objective: + # 更新目标 + event = Event( + avatar.world.month_stamp, + f"{avatar.name}经过深思熟虑,重新确定了自己的长期目标:{new_objective.content}", + related_avatars=[avatar.id], + is_major=False + ) + else: + # 首次设定目标 + event = Event( + avatar.world.month_stamp, + f"{avatar.name}确定了自己的长期目标:{new_objective.content}", + related_avatars=[avatar.id], + is_major=False + ) + + return event + + +def set_user_long_term_objective(avatar: "Avatar", objective_content: str) -> None: + """ + 玩家设定角色的长期目标 + + 用户设定后,origin标记为"user",系统将不再自动调用LLM更新该目标 + 但允许玩家再次调用此函数修改 + + Args: + avatar: 要设定目标的角色 + objective_content: 目标内容 + """ + current_year = avatar.world.month_stamp.get_year() + avatar.long_term_objective = LongTermObjective( + content=objective_content, + origin="user", + set_year=current_year + ) + logger.info(f"玩家为角色 {avatar.name} 设定长期目标:{objective_content}") + diff --git a/src/classes/mutual_action/talk.py b/src/classes/mutual_action/talk.py index 97de506..ee445ac 100644 --- a/src/classes/mutual_action/talk.py +++ b/src/classes/mutual_action/talk.py @@ -57,7 +57,7 @@ class Talk(MutualAction): self.avatar.load_decide_result_chain( [("Conversation", {"target_avatar": target.name})], self.avatar.thinking, - self.avatar.objective + self.avatar.short_term_objective ) else: # 拒绝攀谈 diff --git a/src/classes/world.py b/src/classes/world.py index 96f2ab2..bb12820 100644 --- a/src/classes/world.py +++ b/src/classes/world.py @@ -27,11 +27,17 @@ class World(): """ 返回世界信息(dict),其中包含地图信息(dict)。 """ + static_info = self.static_info map_info = self.map.get_info(detailed=detailed) - return map_info + world_info = {**map_info, **static_info} + return world_info def get_avatars_in_same_region(self, avatar: "Avatar"): return self.avatar_manager.get_avatars_in_same_region(avatar) def get_observable_avatars(self, avatar: "Avatar"): - return self.avatar_manager.get_observable_avatars(avatar) \ No newline at end of file + return self.avatar_manager.get_observable_avatars(avatar) + + @property + def static_info(self) -> dict: + return {"static_info": "这是一个修仙世界,修仙的境界有:练气、筑基、金丹、元婴。"} \ No newline at end of file diff --git a/src/sim/load/avatar_load_mixin.py b/src/sim/load/avatar_load_mixin.py index 96cce92..eb11b49 100644 --- a/src/sim/load/avatar_load_mixin.py +++ b/src/sim/load/avatar_load_mixin.py @@ -148,9 +148,21 @@ class AvatarLoadMixin: # 设置行动与AI avatar.thinking = data.get("thinking", "") - avatar.objective = data.get("objective", "") + avatar.short_term_objective = data.get("short_term_objective", data.get("objective", "")) # 兼容旧存档 avatar._action_cd_last_months = data.get("_action_cd_last_months", {}) + # 加载长期目标 + long_term_objective_data = data.get("long_term_objective") + if long_term_objective_data: + from src.classes.long_term_objective import LongTermObjective + avatar.long_term_objective = LongTermObjective( + content=long_term_objective_data.get("content", ""), + origin=long_term_objective_data.get("origin", "llm"), + set_year=long_term_objective_data.get("set_year", 100) + ) + else: + avatar.long_term_objective = None + # 重建planned_actions planned_actions_data = data.get("planned_actions", []) avatar.planned_actions = [ActionPlan.from_dict(plan_data) for plan_data in planned_actions_data] diff --git a/src/sim/save/avatar_save_mixin.py b/src/sim/save/avatar_save_mixin.py index 5572ee0..8765349 100644 --- a/src/sim/save/avatar_save_mixin.py +++ b/src/sim/save/avatar_save_mixin.py @@ -95,7 +95,12 @@ class AvatarSaveMixin: "current_action": current_action_dict, "planned_actions": planned_actions_list, "thinking": self.thinking, - "objective": self.objective, + "short_term_objective": self.short_term_objective, + "long_term_objective": { + "content": self.long_term_objective.content, + "origin": self.long_term_objective.origin, + "set_year": self.long_term_objective.set_year + } if self.long_term_objective else None, "_action_cd_last_months": self._action_cd_last_months, } diff --git a/src/sim/simulator.py b/src/sim/simulator.py index f3cd6cd..edd5068 100644 --- a/src/sim/simulator.py +++ b/src/sim/simulator.py @@ -13,6 +13,7 @@ from src.utils.config import CONFIG from src.run.log import get_logger from src.classes.fortune import try_trigger_fortune from src.classes.celestial_phenomenon import get_random_celestial_phenomenon +from src.classes.long_term_objective import process_avatar_long_term_objective class Simulator: def __init__(self, world: World): @@ -33,9 +34,9 @@ class Simulator: ai = llm_ai decide_results = await ai.decide(self.world, avatars_to_decide) for avatar, result in decide_results.items(): - action_name_params_pairs, avatar_thinking, objective, _event = result + action_name_params_pairs, avatar_thinking, short_term_objective, _event = result # 仅入队计划,不在此处添加开始事件,避免与提交阶段重复 - avatar.load_decide_result_chain(action_name_params_pairs, avatar_thinking, objective) + avatar.load_decide_result_chain(action_name_params_pairs, avatar_thinking, short_term_objective) def _phase_commit_next_plans(self): """ @@ -135,6 +136,18 @@ class Simulator: events.append(event) return events + async def _phase_long_term_objective_thinking(self): + """ + 长期目标思考阶段 + 检查角色是否需要生成/更新长期目标 + """ + events = [] + for avatar in list(self.world.avatar_manager.avatars.values()): + event = await process_avatar_long_term_objective(avatar) + if event: + events.append(event) + return events + def _phase_update_celestial_phenomenon(self): """ 更新天地灵机: @@ -209,6 +222,9 @@ class Simulator: """ events = [] # list of Event + # 0.5 长期目标思考阶段(在决策之前) + events.extend(await self._phase_long_term_objective_thinking()) + # 1. 决策阶段 await self._phase_decide_actions() diff --git a/static/templates/ai.txt b/static/templates/ai.txt index 4fb7653..7c74f7e 100644 --- a/static/templates/ai.txt +++ b/static/templates/ai.txt @@ -11,8 +11,8 @@ {{ AvatarName: {{ "thinking": ..., // 简单思考应该怎么决策 - "objective": ..., // 角色接下来一段时间的目标 - // 基于objective,一次性决定未来的3~8个动作,按顺序执行 + "short_term_objective": ..., // 角色接下来一段时间的短期目标 + // 基于short_term_objective和角色的长期目标(如有),一次性决定未来的3~8个动作,按顺序执行 "action_name_params_pairs": list[Tuple[action_name, action_params]], "avatar_thinking": ... // 从角色角度,以第一人称视角,基于action_name_params_pairs描述想法 }} diff --git a/static/templates/long_term_objective.txt b/static/templates/long_term_objective.txt new file mode 100644 index 0000000..122679a --- /dev/null +++ b/static/templates/long_term_objective.txt @@ -0,0 +1,31 @@ +你是一个仙侠世界的决策者,负责为修仙角色设定长期目标。 + +长期目标是角色在接下来3-5年内想要达成的目标,可以宏大也可以具体。 + +当前世界信息: +{world_info} + +角色信息: +{avatar_info} + +角色的重大事迹: +{major_events} + +角色的近期经历: +{minor_events} + +基于以上信息,为该角色设定一个符合其身份、性格、境遇的长期目标。 + +返回JSON格式: +{{ + "thinking": "分析当前世界局势、角色处境、性格、宗门、境界、特质、人际关系、重大事件等,思考什么样的长期目标最合适。不要虚构未出现的信息。", + "long_term_objective": "目标内容,简洁清晰明快,15字以内。" +}} + +要求: +- 目标要符合角色身份和修仙世界观 +- 可以是宏大的也可以是具体的 +- 要考虑角色的性格、特质、宗门、阵营、人际关系等因素 +- 需要基于角色的各个因素详细分析 +- thinking要详细分析,long_term_objective只返回目标内容本身 +