Files
cultivation-world-simulator/src/classes/ai.py
2025-10-04 22:08:10 +08:00

158 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
NPC AI的类。
这里指的不是LLM或者Machine Learning而是NPC的决策机制
分为两类规则AI和LLM AI
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
import random
from src.classes.world import World
from src.classes.region import Region
from src.classes.root import get_essence_types_for_root
from src.classes.event import Event, NULL_EVENT
from src.utils.llm import get_ai_prompt_and_call_llm_async
from src.classes.typings import ACTION_NAME, ACTION_PARAMS, ACTION_PAIR, ACTION_NAME_PARAMS_PAIRS
from src.utils.config import CONFIG
from src.classes.actions import ACTION_INFOS_STR
if TYPE_CHECKING:
from src.classes.avatar import Avatar
class AI(ABC):
"""
抽象AI统一采用批量接口。
原先的 GroupAI多个角色的AI语义被保留并上移到此基类。
子类需实现 _decide(world, avatars) 返回每个 Avatar 的 (action_name, action_params, thinking)。
"""
@abstractmethod
async def _decide(self, world: World, avatars_to_decide: list[Avatar]) -> dict[Avatar, tuple]:
pass
async def decide(self, world: World, avatars_to_decide: list[Avatar]) -> dict[Avatar, tuple[ACTION_NAME_PARAMS_PAIRS, str, str, Event]]:
"""
决定做什么,同时生成对应的事件。
一个ai支持批量生成多个avatar的动作。
这对LLM AI节省时间和token非常有意义。
"""
results = {}
max_decide_num = CONFIG.ai.max_decide_num
for i in range(0, len(avatars_to_decide), max_decide_num):
results.update(await self._decide(world, avatars_to_decide[i:i+max_decide_num]))
for avatar, result in list(results.items()):
# 兼容RuleAI 返回单动作LLMAI 返回动作链
if result and isinstance(result[0], list):
action_name_params_pairs, avatar_thinking, objective = result # type: ignore
else:
action_name, action_params, avatar_thinking, objective = result # type: ignore
action_name_params_pairs = [(action_name, action_params)]
# 不在决策阶段生成开始事件,提交阶段统一触发
results[avatar] = (action_name_params_pairs, avatar_thinking, objective, NULL_EVENT)
return results
class RuleAI(AI):
"""
规则AI批量接口内部逐个决策
"""
def __decide(self, world: World, avatar: "Avatar", regions: list[Region]) -> tuple[ACTION_NAME, ACTION_PARAMS, str, str]:
"""
单个 Avatar 的决策逻辑。
先做一个简单的:
1. 找到自己灵根对应的最好的区域
2. 检测自己是否在最好的区域
3. 如果不在,则移动到最好的区域
4. 如果已经到达最好的区域,则进行修炼
5. 如果需要突破境界了,则突破境界
"""
if random.random() < 0.1:
return ("Play", {}, "", "放松一下,缓解修行压力")
best_region = self.get_best_region_for_avatar(avatar, regions)
if avatar.is_in_region(best_region):
if avatar.cultivation_progress.can_break_through():
return ("Breakthrough", {}, "", "尽快突破到更高境界")
else:
return ("Cultivate", {}, "", "稳步提升修为")
else:
return ("MoveToRegion", {"region": best_region.name}, "", f"前往{best_region.name}修行")
async def _decide(self, world: World, avatars_to_decide: list[Avatar]) -> dict[Avatar, tuple[ACTION_NAME, ACTION_PARAMS, str, str]]:
"""
决策逻辑:批量接口的实现上,逐个 Avatar 调用 __decide 进行独立决策,
以保持规则AI的可控性与可测试性。
"""
results: dict[Avatar, tuple[ACTION_NAME, ACTION_PARAMS, str, str]] = {}
regions: list[Region] = list(world.map.regions.values())
for avatar in avatars_to_decide:
results[avatar] = self.__decide(world, avatar, regions)
return results
def get_best_region_for_avatar(self, avatar: "Avatar", regions: list[Region]) -> Region:
"""
根据avatar的灵根找到最适合的区域
"""
essence_types = get_essence_types_for_root(avatar.root)
def best_density(region: Region) -> int:
return max((region.essence.get_density(et) for et in essence_types), default=0)
region_with_best_essence = max(regions, key=best_density)
return region_with_best_essence
class LLMAI(AI):
"""
LLM AI
一些思考:
AI动作应该分两类
1. 长期动作,比如要持续很长一段时间的行为
2. 突发应对动作比如突然有人要攻击NPC这个时候的反应
"""
async def _decide(self, world: World, avatars_to_decide: list[Avatar]) -> dict[Avatar, tuple[ACTION_NAME_PARAMS_PAIRS, str, str]]:
"""
异步决策逻辑通过LLM决定执行什么动作和参数
"""
global_info = world.get_info()
# 在提示中包含处于角色观测范围内的其他角色
avatar_infos = {}
for avatar in avatars_to_decide:
observed = world.get_observable_avatars(avatar)
avatar_infos[avatar.name] = avatar.get_prompt_info(observed)
general_action_infos = ACTION_INFOS_STR
info = {
"avatar_infos": avatar_infos,
"global_info": global_info,
"general_action_infos": general_action_infos,
}
res = await get_ai_prompt_and_call_llm_async(info)
results: dict[Avatar, tuple[ACTION_NAME_PARAMS_PAIRS, str, str]] = {}
for avatar in avatars_to_decide:
r = res[avatar.name]
# 仅接受 action_name_params_pairs不再支持单个 action_name/action_params
raw_pairs = r["action_name_params_pairs"]
pairs: ACTION_NAME_PARAMS_PAIRS = []
for p in raw_pairs:
if isinstance(p, list) and len(p) == 2:
pairs.append((p[0], p[1]))
elif isinstance(p, dict) and "action_name" in p and "action_params" in p:
pairs.append((p["action_name"], p["action_params"]))
else:
# 跳过无法解析的项
continue
# 至少有一个
if not pairs:
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)
return results
llm_ai = LLMAI()
rule_ai = RuleAI()