diff --git a/src/classes/action.py b/src/classes/action.py index f43806c..b26c4c6 100644 --- a/src/classes/action.py +++ b/src/classes/action.py @@ -12,6 +12,7 @@ from src.classes.item import Item, items_by_name from src.classes.prices import prices from src.classes.hp_and_mp import HP_MAX_BY_REALM, MP_MAX_BY_REALM from src.classes.battle import decide_battle +from src.utils.config import CONFIG if TYPE_CHECKING: from src.classes.avatar import Avatar @@ -753,7 +754,7 @@ class HelpMortals(DefineAction, ActualActionMixin): if self.avatar.alignment != Alignment.RIGHTEOUS: return False cost = self.COST - return getattr(self.avatar.magic_stone, "value", 0) >= cost + return self.avatar.magic_stone >= cost def start(self) -> Event: return Event(self.world.month_stamp, f"{self.avatar.name} 在城镇开始帮助凡人") @@ -764,3 +765,70 @@ class HelpMortals(DefineAction, ActualActionMixin): def finish(self) -> list[Event]: return [] + + +class Talk(DefineAction, ActualActionMixin): + """ + 攀谈:尝试与同区域内的某个NPC进行交谈。 + - can_start:同区域内存在其他NPC + - 发起后:随机寻找“同一tile”的NPC,若不存在则本次无法攀谈 + - 若找到,则进入 MutualAction: Conversation(允许建立关系) + """ + + COMMENT = "与同区域内的NPC发起攀谈,若同一tile有人则进入交谈" + DOABLES_REQUIREMENTS = "同区域内存在其他NPC" + PARAMS = {} + + def _get_same_region_others(self) -> list["Avatar"]: + return self.world.avatar_manager.get_avatars_in_same_region(self.avatar) + + def _get_same_tile_others(self) -> list["Avatar"]: + same_tile: list["Avatar"] = [] + my_tile = self.avatar.tile + if my_tile is None: + return [] + for v in self.world.avatar_manager.avatars.values(): + if v is self.avatar or v.tile is None: + continue + if v.tile == my_tile: + same_tile.append(v) + return same_tile + + def _execute(self) -> None: + # Talk 本身不做长期效果,主要在 step 中驱动 Conversation + return + + def can_start(self) -> bool: + # 是否同区域存在其他NPC(用于展示在动作空间) + return len(self._get_same_region_others()) > 0 + + def start(self) -> Event: + # 记录开始事件 + return Event(self.world.month_stamp, f"{self.avatar.name} 尝试与同区域的他人攀谈") + + def step(self) -> tuple[StepStatus, list[Event]]: + # 先找同tile对象 + same_tile_others = self._get_same_tile_others() + if not same_tile_others: + # 无同tile对象,本次作罢 + fail_event = Event(self.world.month_stamp, f"{self.avatar.name} 未在同一位置找到可攀谈之人") + self.avatar.add_event(fail_event) + return StepStatus.COMPLETED, [] + + import random + target = random.choice(same_tile_others) + + # 进入交谈:由概率决定本次是否允许建立关系 + from src.classes.mutual_action import Conversation + # 由配置决定本次是否有“有机会进入关系”标记 + prob = float(getattr(CONFIG.social, "talk_into_relation_probability", 0.0)) + can_into_relation = (random.random() < prob) + + conv = Conversation(self.avatar, self.world) + # 启动事件写入历史,不入侧边栏 + conv.start(target_avatar=target) + conv.step(target_avatar=target, can_into_relation=can_into_relation) + return StepStatus.COMPLETED, [] + + def finish(self) -> list[Event]: + return [] diff --git a/src/classes/actions.py b/src/classes/actions.py index 31bbdec..dfbf373 100644 --- a/src/classes/actions.py +++ b/src/classes/actions.py @@ -15,12 +15,14 @@ from src.classes.action import ( Battle, PlunderMortals, HelpMortals, + Talk, ) from src.classes.mutual_action import ( DriveAway, Attack, MoveAwayFromAvatar, MoveAwayFromRegion, + Conversation, ) @@ -37,11 +39,13 @@ ALL_ACTION_CLASSES = [ Sold, PlunderMortals, HelpMortals, + Talk, # 互动相关动作(实际执行的反馈动作也纳入) DriveAway, Attack, MoveAwayFromAvatar, MoveAwayFromRegion, + Conversation, ] ALL_ACTUAL_ACTION_CLASSES = [ @@ -55,8 +59,10 @@ ALL_ACTUAL_ACTION_CLASSES = [ Sold, PlunderMortals, HelpMortals, + Talk, DriveAway, Attack, + Conversation, ] ALL_ACTION_NAMES = [action.__name__ for action in ALL_ACTION_CLASSES] diff --git a/src/classes/mutual_action.py b/src/classes/mutual_action.py index 487e4c0..b01c19a 100644 --- a/src/classes/mutual_action.py +++ b/src/classes/mutual_action.py @@ -11,6 +11,7 @@ from src.classes.event import Event from src.utils.llm import get_prompt_and_call_llm from src.utils.config import CONFIG from src.classes.action import long_action +from src.classes.relation import relation_display_names, Relation, get_possible_post_relations if TYPE_CHECKING: from src.classes.avatar import Avatar @@ -43,14 +44,16 @@ class MutualAction(DefineAction, LLMAction): avatar_name_1: self.avatar.cultivation_progress.get_simple_info(), # avatar1只放境界信息 avatar_name_2: target_avatar.get_prompt_info([]), # avatar2放全量信息 } - feedback_actions = getattr(self, "FEEDBACK_ACTIONS", []) + feedback_actions = self.FEEDBACK_ACTIONS + comment = self.COMMENT + action_name = self.ACTION_NAME return { "avatar_infos": avatar_infos, "avatar_name_1": avatar_name_1, "avatar_name_2": avatar_name_2, - "action_name": getattr(self, "ACTION_NAME", self.name), - "action_info": getattr(self, "COMMENT", ""), - "FEEDBACK_ACTIONS": feedback_actions, + "action_name": action_name, + "action_info": comment, + "feedback_actions": feedback_actions, } def _call_llm_feedback(self, infos: dict) -> dict: @@ -256,4 +259,102 @@ class MoveAwayFromRegion(DefineAction, ActualActionMixin): if self.world.map.is_in_bounds(nx, ny): self.avatar.pos_x = nx self.avatar.pos_y = ny - self.avatar.tile = self.world.map.get_tile(nx, ny) \ No newline at end of file + self.avatar.tile = self.world.map.get_tile(nx, ny) + + +class Conversation(MutualAction, ActualActionMixin): + """交谈:两名角色在同一区域进行交流。 + + - 可由“攀谈”触发,或直接发起 + - 仅当双方处于同一 Region 时可启动 + - 当 can_into_relation=True 且 LLM 决策返回 into_relation 时,根据返回建立关系 + - 会将对话内容写入事件系统 + """ + + ACTION_NAME = "交谈" + COMMENT = "两人需在同一地区,进行一段交流对话" + DOABLES_REQUIREMENTS = "与目标处于同一区域" + PARAMS = {"target_avatar": "AvatarName"} + FEEDBACK_ACTIONS: list[str] = ["Talk", "Reject"] + + def _get_template_path(self) -> Path: + # 使用 talk.txt 模板,以获取是否接受与对话内容 + return CONFIG.paths.templates / "talk.txt" + + def _build_prompt_infos(self, target_avatar: "Avatar", *, can_into_relation: bool) -> dict: + avatar_name_1 = self.avatar.name + avatar_name_2 = target_avatar.name + # 目标的 get_prompt_info 已含 personas、关系等,信息更充分 + avatar_infos = { + avatar_name_1: self.avatar.get_prompt_info([]), + avatar_name_2: target_avatar.get_prompt_info([]), + } + # 可能的后天关系(转中文名,给模板阅读) + possible_relations = [relation_display_names[r] for r in get_possible_post_relations(self.avatar, target_avatar)] + return { + "avatar_infos": avatar_infos, + "avatar_name_1": avatar_name_1, + "avatar_name_2": avatar_name_2, + "can_into_relation": bool(can_into_relation), + "possible_relations": possible_relations, + } + + # 关系解析由 Relation 提供类方法,仅接受中文关系名,无法解析则跳过 + + def can_start(self, target_avatar: "Avatar|str|None" = None, **kwargs) -> bool: + if target_avatar is None: + return False + target = self._get_target_avatar(target_avatar) + if target is None or target.tile is None or self.avatar.tile is None: + return False + return target.tile.region == self.avatar.tile.region + + def start(self, target_avatar: "Avatar|str", **kwargs) -> Event: + target = self._get_target_avatar(target_avatar) + target_name = target.name if target is not None else str(target_avatar) + event = Event(self.world.month_stamp, f"{self.avatar.name} 与 {target_name} 开始交谈") + # 写入历史即可,内容事件稍后生成 + self.avatar.add_event(event, to_sidebar=False) + if target is not None: + target.add_event(event, to_sidebar=False) + return event + + def step(self, target_avatar: "Avatar|str", can_into_relation: bool = False) -> tuple[StepStatus, list[Event]]: + target = self._get_target_avatar(target_avatar) + if target is None: + return StepStatus.COMPLETED, [] + + infos = self._build_prompt_infos(target, can_into_relation=can_into_relation) + res = self._call_llm_feedback(infos) + r = res.get(infos["avatar_name_2"], {}) + thinking = r.get("thinking", "") + feedback = str(r.get("feedback", "")).strip() + talk_content = str(r.get("talk_content", "")).strip() + into_relation_str = str(r.get("into_relation", "")).strip() + + target.thinking = thinking + + # 拒绝则只记录反馈 + if feedback and feedback != "Talk": + feedback_event = Event(self.world.month_stamp, f"{target.name} 拒绝与 {self.avatar.name} 交谈") + self._add_event_pair(feedback_event, initiator=self.avatar, target=target) + return StepStatus.COMPLETED, [] + + # 接受并记录对话内容 + if talk_content: + content_event = Event(self.world.month_stamp, f"{self.avatar.name} 与 {target.name} 的交谈:{talk_content}") + # 进入侧栏一次,并写入双方历史 + self._add_event_pair(content_event, initiator=self.avatar, target=target) + + # 仅当 can_into_relation=True 时,根据返回尝试建立关系 + if can_into_relation and into_relation_str: + rel = Relation.from_chinese(into_relation_str) + if rel is not None: + self.avatar.set_relation(target, rel) + set_event = Event(self.world.month_stamp, f"{self.avatar.name} 与 {target.name} 的关系变为:{relation_display_names.get(rel, str(rel))}") + self._add_event_pair(set_event, initiator=self.avatar, target=target) + + return StepStatus.COMPLETED, [] + + def finish(self, target_avatar: "Avatar|str", **kwargs) -> list[Event]: + return [] \ No newline at end of file diff --git a/src/classes/relation.py b/src/classes/relation.py index d44a6c3..209b94e 100644 --- a/src/classes/relation.py +++ b/src/classes/relation.py @@ -1,6 +1,7 @@ from __future__ import annotations from enum import Enum +from typing import TYPE_CHECKING, List class Relation(Enum): @@ -20,6 +21,19 @@ class Relation(Enum): def __str__(self) -> str: return relation_display_names.get(self, self.value) + @classmethod + def from_chinese(cls, name_cn: str) -> "Relation|None": + """ + 依据中文显示名解析关系;无法解析返回 None。 + """ + if not name_cn: + return None + s = str(name_cn).strip() + for rel, cn in relation_display_names.items(): + if s == cn: + return rel + return None + relation_display_names = { # 血缘(先天) @@ -70,3 +84,54 @@ def get_reciprocal(relation: Relation) -> Relation: """ return RECIPROCAL_RELATION.get(relation, relation) + +# ——— 新增:评估两名角色可能新增的后天关系 ——— +if TYPE_CHECKING: + from src.classes.avatar import Avatar + + +def get_possible_post_relations(from_avatar: "Avatar", to_avatar: "Avatar") -> List[Relation]: + """ + 评估“to_avatar 相对于 from_avatar”可能新增的后天关系集合(方向性明确)。 + + 清晰规则: + - LOVERS(情侣):要求男女异性;若已存在 to->from 的相同关系则不重复 + - MASTER(师傅):要求 to.level >= from.level + 20 + - APPRENTICE(徒弟):要求 to.level <= from.level - 20 + - FRIEND(朋友):始终可能(若未已存在) + - ENEMY(仇人):始终可能(若未已存在) + + 说明:本函数只判断“是否可能”,不做概率与人格相关控制;概率留给上层逻辑。 + 返回的是 Relation 列表,均为 to_avatar 相对于 from_avatar 的候选。 + """ + # 方向相关:检查 to->from 已有关系,避免重复推荐 + existing_to_from = to_avatar.get_relation(from_avatar) + + candidates: list[Relation] = [] + + # 基础信息(Avatar 定义确保存在) + level_from = from_avatar.cultivation_progress.level + level_to = to_avatar.cultivation_progress.level + + # - FRIEND + if existing_to_from != Relation.FRIEND: + candidates.append(Relation.FRIEND) + + # - ENEMY + if existing_to_from != Relation.ENEMY: + candidates.append(Relation.ENEMY) + + # - LOVERS:异性(Avatar 定义确保性别存在) + if from_avatar.gender != to_avatar.gender and existing_to_from != Relation.LOVERS: + candidates.append(Relation.LOVERS) + + # - 师徒(方向性): + # MASTER:to 是 from 的师傅 → to.level >= from.level + 20 + # APPRENTICE:to 是 from 的徒弟 → to.level <= from.level - 20 + if level_to >= level_from + 20 and existing_to_from != Relation.MASTER: + candidates.append(Relation.MASTER) + if level_to <= level_from - 20 and existing_to_from != Relation.APPRENTICE: + candidates.append(Relation.APPRENTICE) + + return candidates + diff --git a/static/config.yml b/static/config.yml index 585caf2..7537b84 100644 --- a/static/config.yml +++ b/static/config.yml @@ -13,11 +13,16 @@ ai: max_decide_num: 3 game: - init_npc_num: 2 + init_npc_num: 6 npc_birth_rate_per_month: 0.001 df: ids_separator: ";" avatar: - persona_num: 3 \ No newline at end of file + persona_num: 3 + +# 社交相关配置 +social: + # 攀谈触发关系的基础概率(仅“攀谈”驱动的交谈会尝试;直接“交谈”不会) + talk_into_relation_probability: 0.1 \ No newline at end of file diff --git a/static/game_configs/persona.csv b/static/game_configs/persona.csv index d4e1b7c..4ccd2c5 100644 --- a/static/game_configs/persona.csv +++ b/static/game_configs/persona.csv @@ -20,3 +20,5 @@ id,name,exclusion_ids,prompt 18,霸道,11;17,你是一个霸道的人,你行事强势,不讲道理,习惯以自己的利益为先,倾向多吃多占、压人一步,对他人的反对不以为意。 19,修行痴迷,2;3;5,你是一个对修行极度痴迷的人,你将绝大多数时间用于修炼,厌恶与修行无关的社交与享乐。 20,极端,11;14;2;5;3;10;17,你是一个极端的人,你仇视对立阵营,如果你是正义阵营,那么你极度正义;如果你是邪恶阵营,那么你极度邪恶。 +21,外向,13;14;22,你是一个外向的人,你乐于与人交流,主动结识伙伴,倾向接受对话和合作。 +22,内向,21,你是一个内向的人,你更享受独处与自我思考,倾向回避不必要的社交与长谈。 diff --git a/static/templates/ai.txt b/static/templates/ai.txt index 97850f9..0b096eb 100644 --- a/static/templates/ai.txt +++ b/static/templates/ai.txt @@ -1,4 +1,4 @@ -你是一个决策者,这是一个修仙的仙侠世界,你负责来决定一些NPC的下一步行为。 +你是一个决策者,这是一个仙侠世界,你负责来决定一些NPC的下一步行为。 {global_info} 你需要进行决策的NPC的dict[AvatarName, info]为 {avatar_infos} diff --git a/static/templates/mutual_action.txt b/static/templates/mutual_action.txt index 7923501..9bf8062 100644 --- a/static/templates/mutual_action.txt +++ b/static/templates/mutual_action.txt @@ -1,4 +1,4 @@ -你是一个决策者,这是一个修仙的仙侠世界,你负责来决定两个NPC的下一步行为。 +你是一个决策者,这是一个仙侠世界,你负责来决定两个NPC的下一步行为。 你需要进行决策的NPC的dict[AvatarName, info]为 {avatar_infos} diff --git a/static/templates/talk.txt b/static/templates/talk.txt new file mode 100644 index 0000000..78aecfa --- /dev/null +++ b/static/templates/talk.txt @@ -0,0 +1,20 @@ +你是一个决策者,这是一个仙侠世界,你负责来决定一个NPC对另一个NPC的攀谈行为。。 + +你需要进行决策的NPC的dict[AvatarName, info]为 +{avatar_infos} +正在进行的动作为:{avatar_name_1}向{avatar_name_2}发起了攀谈。这代表{avatar_name_1}希望与{avatar_name_2}进行对话。这个对话可能是善意的,也可能是恶意的,也可能是闲聊。取决于NPC性格、正邪等因素。 +{avatar_name_2}可以进行的选择为: +["Talk", "Reject"] +两者是否可能进入某种关系:{can_into_relation}。注意,如果为True,也不代表一定要进入某种关系。这都由你来判断。 +{avatar_name_2}可能相对于{avatar_name_1}的身份为: {possible_relations} + +注意,只返回json格式的结果。 +只返回{avatar_name_2}的行动,格式为: +{{ + {avatar_name_2}: {{ + "thinking": ..., // 简单思考应该怎么决策 + "feedback": ... // 面对{avatar_name_1}的行为的合法feedback action name + "talk_content": ... // 如果返回的action为Talk,则输出对话的大概内容。为Reject则返回空str。 + "into_relation": ... // 如果你认为可以让两者产生某种身份关系,则返回。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。 + }} +}} \ No newline at end of file