diff --git a/src/classes/action/battle.py b/src/classes/action/battle.py index 35aae17..4d9e763 100644 --- a/src/classes/action/battle.py +++ b/src/classes/action/battle.py @@ -4,6 +4,8 @@ from src.classes.action import InstantAction from src.classes.event import Event from src.classes.battle import decide_battle, get_effective_strength_pair from src.classes.story_teller import StoryTeller +from src.classes.action.event_helper import EventHelper +from src.utils.asyncio_utils import schedule_background class Battle(InstantAction): @@ -71,11 +73,23 @@ class Battle(InstantAction): pass result_event = Event(self.world.month_stamp, result_text, related_avatars=rel_ids) - # 生成战斗小故事:使用便捷方法从参与者直接生成 + # 异步生成战斗小故事并在完成后推送事件,避免阻塞事件循环 target = self._get_target(avatar_name) start_text = getattr(self, "_start_event_content", "") or result_event.content - story = StoryTeller.tell_from_actors(start_text, result_event.content, self.avatar, target, prompt=self.STORY_PROMPT) - story_event = Event(self.world.month_stamp, story, related_avatars=rel_ids) - return [result_event, story_event] + month_at_finish = self.world.month_stamp + + async def _gen_and_push_story(): + story = await StoryTeller.tell_from_actors_async(start_text, result_event.content, self.avatar, target, prompt=self.STORY_PROMPT) + story_event = Event(month_at_finish, story, related_avatars=rel_ids) + EventHelper.push_pair(story_event, initiator=self.avatar, target=target, to_sidebar_once=True) + + def _fallback_sync(): + story = StoryTeller.tell_from_actors(start_text, result_event.content, self.avatar, target, prompt=self.STORY_PROMPT) + story_event = Event(month_at_finish, story, related_avatars=rel_ids) + EventHelper.push_pair(story_event, initiator=self.avatar, target=target, to_sidebar_once=True) + + schedule_background(_gen_and_push_story(), fallback=_fallback_sync) + + return [result_event] diff --git a/src/classes/fortune.py b/src/classes/fortune.py index 869d13d..dc8e8d9 100644 --- a/src/classes/fortune.py +++ b/src/classes/fortune.py @@ -2,11 +2,14 @@ from __future__ import annotations import random from typing import Optional +import asyncio from src.utils.config import CONFIG from src.classes.avatar import Avatar from src.classes.event import Event from src.classes.story_teller import StoryTeller +from src.classes.action.event_helper import EventHelper +from src.utils.asyncio_utils import schedule_background from src.classes.technique import TechniqueGrade, get_random_upper_technique_for_avatar from src.classes.treasure import Treasure, treasures_by_id @@ -107,18 +110,28 @@ def try_trigger_fortune(avatar: Avatar) -> list[Event]: avatar.technique = tech res_text = f"{avatar.name} 得到上品功法『{tech.name}』" - # 生成故事 + # 生成故事(异步避免阻塞) event_text = f"遭遇奇遇({theme}),{res_text}" story_prompt = ( f"请据此写100~150字小故事。" ) - story = StoryTeller.tell_from_actors(event_text, res_text, avatar, prompt=story_prompt) - events: list[Event] = [ - Event(avatar.world.month_stamp, event_text, related_avatars=[avatar.id]), - Event(avatar.world.month_stamp, story, related_avatars=[avatar.id]), - ] - return events + month_at_finish = avatar.world.month_stamp + base_event = Event(month_at_finish, event_text, related_avatars=[avatar.id]) + + async def _gen_and_push_story(): + story = await StoryTeller.tell_from_actors_async(event_text, res_text, avatar, prompt=story_prompt) + story_event = Event(month_at_finish, story, related_avatars=[avatar.id]) + EventHelper.push_self(story_event, avatar, to_sidebar=True) + + def _fallback_sync(): + story = StoryTeller.tell_from_actors(event_text, res_text, avatar, prompt=story_prompt) + story_event = Event(month_at_finish, story, related_avatars=[avatar.id]) + EventHelper.push_self(story_event, avatar, to_sidebar=True) + + schedule_background(_gen_and_push_story(), fallback=_fallback_sync) + + return [base_event] __all__ = [ diff --git a/src/classes/mutual_action/mutual_action.py b/src/classes/mutual_action/mutual_action.py index bf5cb12..e99ea5e 100644 --- a/src/classes/mutual_action/mutual_action.py +++ b/src/classes/mutual_action/mutual_action.py @@ -2,11 +2,12 @@ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING +import asyncio from src.classes.action import DefineAction, ActualActionMixin, LLMAction from src.classes.tile import get_avatar_distance from src.classes.event import Event -from src.utils.llm import get_prompt_and_call_llm +from src.utils.llm import get_prompt_and_call_llm, get_prompt_and_call_llm_async from src.utils.config import CONFIG from src.classes.relation import relation_display_names, Relation, get_possible_post_relations from src.classes.action_runtime import ActionResult, ActionStatus @@ -45,6 +46,12 @@ class MutualAction(DefineAction, LLMAction, TargetingMixin): # 若该互动动作可能生成小故事,可在子类中覆盖该提示词 STORY_PROMPT: str | None = None + def __init__(self, avatar: "Avatar", world: "World"): + super().__init__(avatar, world) + # 异步反馈任务句柄与缓存结果 + self._feedback_task: asyncio.Task | None = None + self._feedback_cached: dict | None = None + def _get_template_path(self) -> Path: return CONFIG.paths.templates / "mutual_action.txt" @@ -70,11 +77,17 @@ class MutualAction(DefineAction, LLMAction, TargetingMixin): } def _call_llm_feedback(self, infos: dict) -> dict: + """ + 兼容保留:同步调用(不在事件循环内使用)。 + """ template_path = self._get_template_path() - # mutual用快速llm,不需要复杂决策 res = get_prompt_and_call_llm(template_path, infos, mode="fast") return res + async def _call_llm_feedback_async(self, infos: dict) -> dict: + template_path = self._get_template_path() + return await get_prompt_and_call_llm_async(template_path, infos, mode="fast") + def _set_target_immediate_action(self, target_avatar: "Avatar", action_name: str, action_params: dict) -> None: """ 将反馈决定落地为目标角色的立即动作(清空后加载单步动作链)。 @@ -115,29 +128,25 @@ class MutualAction(DefineAction, LLMAction, TargetingMixin): return target_avatar def _execute(self, target_avatar: "Avatar|str") -> None: + """ + 保留同步实现(不在事件循环内使用)。 + """ target_avatar = self._get_target_avatar(target_avatar) if target_avatar is None: return infos = self._build_prompt_infos(target_avatar) res = self._call_llm_feedback(infos) - # LLM 只返回 {avatar_name_2: {thinking, feedback}} r = res.get(infos["avatar_name_2"], {}) thinking = r.get("thinking", "") feedback = r.get("feedback", "") - # 挂到目标的thinking上(面向UI/日志),并执行反馈落地 target_avatar.thinking = thinking - # 1) 先清空目标后续计划(仅清空队列,不动当前动作) target_avatar.clear_plans() - # 2) 再结算反馈映射为对应动作 self._settle_feedback(target_avatar, feedback) - # 3) 反馈事件(进入侧边栏与双方历史,中文化文案) fb_label = self.FEEDBACK_LABELS.get(str(feedback).strip(), str(feedback)) feedback_event = Event(self.world.month_stamp, f"{target_avatar.name} 对 {self.avatar.name} 的反馈:{fb_label}", related_avatars=[self.avatar.id, target_avatar.id]) - # 侧边栏仅推送一次,另一侧仅写入历史,避免重复 EventHelper.push_pair(feedback_event, initiator=self.avatar, target=target_avatar, to_sidebar_once=True) - # 4) 记录历史(文本记录) self._apply_feedback(target_avatar, feedback) # 实现 ActualActionMixin 接口 @@ -173,10 +182,44 @@ class MutualAction(DefineAction, LLMAction, TargetingMixin): def step(self, target_avatar: "Avatar|str") -> ActionResult: """ - 执行互动动作,互动动作是即时完成的 + 异步化:首帧发起LLM任务并返回RUNNING;任务完成后在后续帧落地反馈并完成。 """ - self.execute(target_avatar=target_avatar) - return ActionResult(status=ActionStatus.COMPLETED, events=[]) + target = self._get_target_avatar(target_avatar) + if target is None: + return ActionResult(status=ActionStatus.FAILED, events=[]) + + # 若无任务,创建异步任务 + if self._feedback_task is None and self._feedback_cached is None: + infos = self._build_prompt_infos(target) + try: + loop = asyncio.get_running_loop() + self._feedback_task = loop.create_task(self._call_llm_feedback_async(infos)) + except RuntimeError: + # 无运行中的事件循环时,退化为同步调用(如离线批处理) + self._feedback_cached = self._call_llm_feedback(infos) + + # 若任务已完成,消费结果 + if self._feedback_task is not None and self._feedback_task.done(): + self._feedback_cached = self._feedback_task.result() + self._feedback_task = None + + if self._feedback_cached is not None: + res = self._feedback_cached + self._feedback_cached = None + r = res.get(target.name, {}) + thinking = r.get("thinking", "") + feedback = r.get("feedback", "") + + target.thinking = thinking + target.clear_plans() + self._settle_feedback(target, feedback) + fb_label = self.FEEDBACK_LABELS.get(str(feedback).strip(), str(feedback)) + feedback_event = Event(self.world.month_stamp, f"{target.name} 对 {self.avatar.name} 的反馈:{fb_label}", related_avatars=[self.avatar.id, target.id]) + EventHelper.push_pair(feedback_event, initiator=self.avatar, target=target, to_sidebar_once=True) + self._apply_feedback(target, feedback) + return ActionResult(status=ActionStatus.COMPLETED, events=[]) + + return ActionResult(status=ActionStatus.RUNNING, events=[]) def finish(self, target_avatar: "Avatar|str") -> list[Event]: """ diff --git a/src/classes/story_teller.py b/src/classes/story_teller.py index 29d8e76..52e6b26 100644 --- a/src/classes/story_teller.py +++ b/src/classes/story_teller.py @@ -1,10 +1,11 @@ from __future__ import annotations from typing import Dict, TYPE_CHECKING +import asyncio import random from src.utils.config import CONFIG -from src.utils.llm import get_prompt_and_call_llm +from src.utils.llm import get_prompt_and_call_llm, get_prompt_and_call_llm_async story_styles = [ "平淡叙述:语句克制、少修饰、像旁观者记录。", @@ -64,12 +65,34 @@ class StoryTeller: if story: return story except Exception: - # 避免过度 try/catch,仅在外部依赖失败时提供降级 pass # 降级文案(不中断主流程) style = infos.get("style", "") return f"{event}。{res}。{style}" + @staticmethod + async def tell_story_async(avatar_infos: Dict[str, dict], event: str, res: str, STORY_PROMPT: str = "") -> str: + """ + 异步版本:生成小故事,失败时返回降级文案。 + """ + template_path = CONFIG.paths.templates / "story.txt" + infos = { + "avatar_infos": avatar_infos, + "event": event, + "res": res, + "style": random.choice(story_styles), + "story_prompt": STORY_PROMPT or "", + } + try: + data = await get_prompt_and_call_llm_async(template_path, infos, mode="fast") + story = str(data.get("story", "")).strip() + if story: + return story + except Exception: + pass + style = infos.get("style", "") + return f"{event}。{res}。{style}" + @staticmethod def tell_from_actors(event: str, res: str, *actors: "Avatar", prompt: str | None = None) -> str: """ @@ -78,6 +101,11 @@ class StoryTeller: avatar_infos = StoryTeller.build_avatar_infos(*actors) return StoryTeller.tell_story(avatar_infos, event, res, prompt or "") + @staticmethod + async def tell_from_actors_async(event: str, res: str, *actors: "Avatar", prompt: str | None = None) -> str: + avatar_infos = StoryTeller.build_avatar_infos(*actors) + return await StoryTeller.tell_story_async(avatar_infos, event, res, prompt or "") + __all__ = ["StoryTeller"] diff --git a/src/utils/asyncio_utils.py b/src/utils/asyncio_utils.py new file mode 100644 index 0000000..015764f --- /dev/null +++ b/src/utils/asyncio_utils.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import asyncio +from typing import Awaitable, Callable, Optional + + +def schedule_background(coro: Awaitable, *, fallback: Optional[Callable[[], None]] = None) -> None: + """ + 在有事件循环时将协程投递为后台任务;否则执行同步回退。 + + - coro: 需要异步执行的协程对象 + - fallback: 无事件循环时的回退执行函数(可为空) + """ + try: + loop = asyncio.get_running_loop() + loop.create_task(coro) + except RuntimeError: + if fallback is not None: + fallback() + else: + # 无回退则静默返回,调用方自行决定后续行为 + return + + diff --git a/static/config.yml b/static/config.yml index 545f590..e543ce0 100644 --- a/static/config.yml +++ b/static/config.yml @@ -15,7 +15,7 @@ ai: max_decide_num: 4 game: - init_npc_num: 3 + init_npc_num: 6 sect_num: 2 # init_npc_num大于sect_num时,会随机选择sect_num个宗门 npc_birth_rate_per_month: 0.001 fortune_probability: 0.001 diff --git a/static/game_configs/persona.csv b/static/game_configs/persona.csv index 1992ccb..550ed8e 100644 --- a/static/game_configs/persona.csv +++ b/static/game_configs/persona.csv @@ -29,4 +29,4 @@ id,name,exclusion_ids,desc,weight,condition 27,腼腆,26,你对待和他人结为道侣或者双修比较谨慎,1, 28,舔狗,13;14;22;27,你对异性中外貌出众者格外友善,倾向主动接近、帮助与合作。,1, 29,嫉妒,11;23,你对在修为、外貌或财富等方面远超于你的人容易产生敌意,更倾向对其冷淡、挑衅或打压。,1, -30,穿越者,,你来自现代社会,怀念现代社会的一切,希望调查清楚你来的原因,早日回到现代,你的思考方式都是现代化的,100, \ No newline at end of file +30,穿越者,,你来自现代社会,怀念现代社会的一切,希望调查清楚你来的原因,早日回到现代,你的思考方式都是现代化的,1, \ No newline at end of file