add talk and conversation

This commit is contained in:
bridge
2025-10-02 22:49:25 +08:00
parent 3711987379
commit bdb1d7fec7
9 changed files with 277 additions and 10 deletions

View File

@@ -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 []

View File

@@ -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]

View File

@@ -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)
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 []

View File

@@ -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)
# - 师徒(方向性):
# MASTERto 是 from 的师傅 → to.level >= from.level + 20
# APPRENTICEto 是 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

View File

@@ -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
persona_num: 3
# 社交相关配置
social:
# 攀谈触发关系的基础概率(仅“攀谈”驱动的交谈会尝试;直接“交谈”不会)
talk_into_relation_probability: 0.1

View File

@@ -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,你是一个内向的人,你更享受独处与自我思考,倾向回避不必要的社交与长谈。
1 id name exclusion_ids prompt
20 18 霸道 11;17 你是一个霸道的人,你行事强势,不讲道理,习惯以自己的利益为先,倾向多吃多占、压人一步,对他人的反对不以为意。
21 19 修行痴迷 2;3;5 你是一个对修行极度痴迷的人,你将绝大多数时间用于修炼,厌恶与修行无关的社交与享乐。
22 20 极端 11;14;2;5;3;10;17 你是一个极端的人,你仇视对立阵营,如果你是正义阵营,那么你极度正义;如果你是邪恶阵营,那么你极度邪恶。
23 21 外向 13;14;22 你是一个外向的人,你乐于与人交流,主动结识伙伴,倾向接受对话和合作。
24 22 内向 21 你是一个内向的人,你更享受独处与自我思考,倾向回避不必要的社交与长谈。

View File

@@ -1,4 +1,4 @@
你是一个决策者,这是一个修仙的仙侠世界你负责来决定一些NPC的下一步行为。
你是一个决策者这是一个仙侠世界你负责来决定一些NPC的下一步行为。
{global_info}
你需要进行决策的NPC的dict[AvatarName, info]为
{avatar_infos}

View File

@@ -1,4 +1,4 @@
你是一个决策者,这是一个修仙的仙侠世界你负责来决定两个NPC的下一步行为。
你是一个决策者这是一个仙侠世界你负责来决定两个NPC的下一步行为。
你需要进行决策的NPC的dict[AvatarName, info]为
{avatar_infos}

20
static/templates/talk.txt Normal file
View File

@@ -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}的身份。
}}
}}