diff --git a/src/classes/fortune.py b/src/classes/fortune.py index b8f62c0..32b3887 100644 --- a/src/classes/fortune.py +++ b/src/classes/fortune.py @@ -2,7 +2,7 @@ from __future__ import annotations import random from enum import Enum -from typing import Optional +from typing import Optional, Any import asyncio from src.utils.config import CONFIG @@ -421,6 +421,50 @@ async def try_trigger_fortune(avatar: Avatar) -> list[Event]: related_avatars = [avatar.id] actors_for_story = [avatar] # 用于生成故事的角色列表 + + # 导入单选决策模块 + from src.classes.single_choice import make_decision + + async def _resolve_choice( + new_obj: Any, + old_obj: Optional[Any], + type_label: str, + extra_context: str = "" + ) -> tuple[bool, str]: + """ + 通用决策辅助函数 + Returns: (should_replace, result_text) + """ + new_name = new_obj.name + new_grade = new_obj.grade.value + + if old_obj is None: + return True, f"{avatar.name} 获得{new_grade}{type_label}『{new_name}』" + + old_name = old_obj.name + old_grade = old_obj.grade.value + + options = [ + { + "key": "A", + "desc": f"保留原{type_label}『{old_name}』({old_grade}),放弃新{type_label}『{new_name}』({new_grade})。" + }, + { + "key": "B", + "desc": f"放弃原{type_label},接受新{type_label}『{new_name}』({new_grade})。" + } + ] + + base_context = f"你在奇遇中发现了{new_grade}{type_label}『{new_name}』,但你手中已有『{old_name}』。" + context = f"{base_context} {extra_context}".strip() + + choice = await make_decision(avatar, context, options) + + if choice == "A": + return False, f"{avatar.name} 放弃了{new_grade}{type_label}『{new_name}』,保留了『{old_name}』" + else: + return True, f"{avatar.name} 获得了{new_grade}{type_label}『{new_name}』,替换了『{old_name}』" + if kind == FortuneKind.WEAPON: weapon = _get_weapon_for_avatar(avatar) if weapon is None: @@ -428,8 +472,11 @@ async def try_trigger_fortune(avatar: Avatar) -> list[Event]: kind = FortuneKind.TECHNIQUE theme = _pick_theme(kind) else: - avatar.change_weapon(weapon) - res_text = f"{avatar.name} 获得{weapon.grade}兵器『{weapon.name}』" + should_equip, res_text = await _resolve_choice( + weapon, avatar.weapon, "兵器" + ) + if should_equip: + avatar.change_weapon(weapon) if kind == FortuneKind.AUXILIARY: auxiliary = _get_auxiliary_for_avatar(avatar) @@ -438,16 +485,26 @@ async def try_trigger_fortune(avatar: Avatar) -> list[Event]: kind = FortuneKind.TECHNIQUE theme = _pick_theme(kind) else: - avatar.change_auxiliary(auxiliary) - res_text = f"{avatar.name} 获得{auxiliary.grade}辅助装备『{auxiliary.name}』" + should_equip, res_text = await _resolve_choice( + auxiliary, avatar.auxiliary, "辅助装备" + ) + if should_equip: + avatar.change_auxiliary(auxiliary) if kind == FortuneKind.TECHNIQUE: tech = _get_fortune_technique_for_avatar(avatar) if tech is None: - # 若无可用上品功法(宗门弟子可能因宗门限制而找不到),则不奖励 return [] - avatar.technique = tech - res_text = f"{avatar.name} 得到上品功法『{tech.name}』" + + should_learn, res_text = await _resolve_choice( + tech, avatar.technique, "功法", + extra_context=f"这与你当前主修的『{avatar.technique.name if avatar.technique else ''}』冲突。" + ) + + if should_learn: + avatar.technique = tech + + elif kind == FortuneKind.FIND_MASTER: master = _find_potential_master(avatar) diff --git a/src/classes/single_choice.py b/src/classes/single_choice.py new file mode 100644 index 0000000..9122467 --- /dev/null +++ b/src/classes/single_choice.py @@ -0,0 +1,63 @@ +from typing import Any, Dict, List, Optional +from src.utils.llm import call_llm_with_template +from src.classes.avatar import Avatar +import json + +async def make_decision( + avatar: Avatar, + context_desc: str, + options: List[Dict[str, Any]] +) -> str: + """ + 让角色在多个选项中做出单选决策。 + + Args: + avatar: 做出决策的角色 + context_desc: 决策背景描述 + options: 选项列表,每个选项是一个字典,必须包含 'key' 和 'desc' 字段 + 例如: [{'key': 'A', 'desc': '...'}, {'key': 'B', 'desc': '...'}] + + Returns: + str: AI 选择的选项 Key (如 'A' 或 'B') + """ + # 1. 获取角色信息 (详细模式) + avatar_infos = str(avatar.get_info(detailed=True)) + + # 2. 格式化选项字符串 + choices_list = [f"{opt.get('key', '')}: {opt.get('desc', '')}" for opt in options] + choices_str = "\n".join(choices_list) + full_choices_str = f"【当前情境】:{context_desc}\n\n{choices_str}" + + # 3. 调用 AI + result = await call_llm_with_template( + "single_choice", + avatar_infos=avatar_infos, + choices=full_choices_str + ) + + # 4. 解析结果 + choice = "" + if isinstance(result, dict): + choice = result.get("choice", "").strip() + elif isinstance(result, str): + clean_result = result.strip() + # 尝试解析可能包含 markdown 的 json + if "{" in clean_result and "}" in clean_result: + try: + # 提取可能的 json 部分 + start = clean_result.find("{") + end = clean_result.rfind("}") + 1 + json_str = clean_result[start:end] + data = json.loads(json_str) + choice = data.get("choice", "").strip() + except (json.JSONDecodeError, ValueError): + # 如果 JSON 解析失败,直接看字符串内容是否就是选项 key + choice = clean_result + else: + choice = clean_result + + # 验证 choice 是否在 options key 中 + valid_keys = {opt["key"] for opt in options} + assert choice in valid_keys, f"choice {choice} not in valid_keys {valid_keys}" + return choice + diff --git a/static/templates/conversation.txt b/static/templates/conversation.txt index 5646c3f..5a23cfc 100644 --- a/static/templates/conversation.txt +++ b/static/templates/conversation.txt @@ -15,6 +15,7 @@ "{avatar_name_2}": {{ "thinking": ..., // 简单思考对话如何进行 "conversation_content": ... // 对话双方均为第三人称视角的对话,100~150字,仙侠语言风格。可以是聊天也可以是对话概括。 + "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/single_choice.txt b/static/templates/single_choice.txt new file mode 100644 index 0000000..8f05e2f --- /dev/null +++ b/static/templates/single_choice.txt @@ -0,0 +1,14 @@ +你是一个决策者,这是一个修仙世界,你需要帮一个修仙者做一个决策。 + +你需要进行决策的NPC的dict[AvatarName, info]为 +{avatar_infos} + +你的可选选项为 +{choices} + +注意,只返回json格式的结果,格式为: +{{ + "thinking": ..., // 简单思考 + "choice": "" // 你的决策,注意只返回对应的选项字母 +}} + diff --git a/static/templates/story_dual.txt b/static/templates/story_dual.txt index ec8208d..04a302a 100644 --- a/static/templates/story_dual.txt +++ b/static/templates/story_dual.txt @@ -19,6 +19,7 @@ {{ "thinking": ..., // 简单思考故事剧情和关系变化 "story": "", // 第三人称的故事正文,仙侠语言风格 + "analyze_relation": ... // 分析是否应该有关系的取消或者新增 "new_relation": ... // 如果你认为可以让两者产生某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。 "cancel_relation": ... // 可选,如果你认为可以让两者取消某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。 }}