add action chain

This commit is contained in:
bridge
2025-09-02 00:35:07 +08:00
parent 420a17d471
commit 3047de0367
18 changed files with 370 additions and 150 deletions

View File

@@ -3,16 +3,45 @@ from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
import random
import json
import inspect
from src.classes.essence import Essence, EssenceType
from src.classes.root import Root, corres_essence_type
from src.classes.tile import Region
from src.classes.event import Event, NullEvent
from src.classes.event import Event, NULL_EVENT
if TYPE_CHECKING:
from src.classes.avatar import Avatar
from src.classes.world import World
def long_action(step_month: int):
"""
长态动作装饰器,用于为动作类自动添加时间管理功能
Args:
step_month: 动作需要的月份数
"""
def decorator(cls):
# 设置类属性,供基类使用
cls._step_month = step_month
def is_finished(self, *args, **kwargs) -> bool:
"""
根据时间差判断动作是否完成
接受但忽略额外的参数以保持与其他动作类型的兼容性
"""
if self.start_monthstamp is None:
return False
return (self.world.month_stamp - self.start_monthstamp) >= self.step_month
# 只添加 is_finished 方法
cls.is_finished = is_finished
return cls
return decorator
class Action(ABC):
"""
角色可以执行的动作。
@@ -28,11 +57,37 @@ class Action(ABC):
self.world = world
@abstractmethod
def execute(self) -> Event|NullEvent:
def execute(self) -> None:
pass
class DefineAction(Action):
pass
def __init__(self, avatar: Avatar, world: World):
"""
初始化动作,处理长态动作的属性设置
"""
super().__init__(avatar, world)
# 如果是长态动作,初始化相关属性
if hasattr(self.__class__, '_step_month'):
self.step_month = self.__class__._step_month
self.start_monthstamp = None
def execute(self, *args, **kwargs) -> None:
"""
执行动作处理时间管理逻辑然后调用具体的_execute实现
"""
# 如果是长态动作且第一次执行,记录开始时间
if hasattr(self, 'step_month') and self.start_monthstamp is None:
self.start_monthstamp = self.world.month_stamp
self._execute(*args, **kwargs)
@abstractmethod
def _execute(self, *args, **kwargs) -> None:
"""
具体的动作执行逻辑,由子类实现
"""
pass
class LLMAction(Action):
"""
@@ -44,13 +99,39 @@ class LLMAction(Action):
"""
pass
class ChunkActionMixin():
"""
动作片,可以理解成只是一种切分出来的动作。
不能被avatar直接执行而是成为avatar执行某个动作的步骤。
"""
pass
class Move(DefineAction):
class ActualActionMixin():
"""
实际的可以被规则/LLM调用让avatar去执行的动作。
不一定是多个step也有可能就一个step
"""
@abstractmethod
def is_finished(self) -> bool:
"""
判断动作是否完成
"""
pass
@abstractmethod
def get_event(self, *args, **kwargs) -> Event:
"""
获取动作开始时的事件
"""
pass
class Move(DefineAction, ChunkActionMixin):
"""
最基础的移动动作在tile之间进行切换。
"""
COMMENT = "移动到某个相对位置"
def execute(self, delta_x: int, delta_y: int) -> Event|NullEvent:
def _execute(self, delta_x: int, delta_y: int) -> None:
"""
移动到某个tile
"""
@@ -67,14 +148,13 @@ class Move(DefineAction):
else:
# 超出边界不改变位置与tile
pass
return NullEvent()
class MoveToRegion(DefineAction):
class MoveToRegion(DefineAction, ActualActionMixin):
"""
移动到某个region
"""
COMMENT = "移动到某个区域"
def execute(self, region: Region|str) -> Event|NullEvent:
def _execute(self, region: Region|str) -> None:
"""
移动到某个region
"""
@@ -88,14 +168,36 @@ class MoveToRegion(DefineAction):
delta_x = max(-1, min(1, delta_x))
delta_y = max(-1, min(1, delta_y))
Move(self.avatar, self.world).execute(delta_x, delta_y)
return Event(self.world.year, self.world.month, f"{self.avatar.name} 移动向 {region.name}")
class Cultivate(DefineAction):
def is_finished(self, region: Region|str) -> bool:
"""
判断动作是否完成
"""
if isinstance(region, str):
region = self.world.map.region_names[region]
return self.avatar.is_in_region(region)
def get_event(self, region: Region|str) -> Event:
"""
获取移动动作开始时的事件
"""
if isinstance(region, str):
region_name = region
if region in self.world.map.region_names:
region_name = self.world.map.region_names[region].name
elif hasattr(region, 'name'):
region_name = region.name
else:
region_name = str(region)
return Event(self.world.month_stamp, f"{self.avatar.name} 开始移动向 {region_name}")
@long_action(step_month=10)
class Cultivate(DefineAction, ActualActionMixin):
"""
修炼动作,可以增加修仙进度。
"""
COMMENT = "修炼,增进修为"
def execute(self) -> Event|NullEvent:
def _execute(self) -> None:
"""
修炼
获得的exp增加取决于essence的对应灵根的大小。
@@ -106,7 +208,6 @@ class Cultivate(DefineAction):
essence_density = essence.get_density(essence_type)
exp = self.get_exp(essence_density)
self.avatar.cultivation_progress.add_exp(exp)
return Event(self.world.year, self.world.month, f"{self.avatar.name}{self.avatar.tile.region.name} 修炼")
def get_exp(self, essence_density: int) -> int:
"""
@@ -115,10 +216,17 @@ class Cultivate(DefineAction):
"""
base = 100
return base * essence_density
def get_event(self) -> Event:
"""
获取修炼动作开始时的事件
"""
return Event(self.world.month_stamp, f"{self.avatar.name}{self.avatar.tile.region.name} 开始修炼")
# 突破境界class
class Breakthrough(DefineAction):
@long_action(step_month=1)
class Breakthrough(DefineAction, ActualActionMixin):
"""
突破境界
"""
@@ -129,19 +237,22 @@ class Breakthrough(DefineAction):
"""
return 0.5
def execute(self) -> Event|NullEvent:
def _execute(self) -> None:
"""
突破境界
"""
assert self.avatar.cultivation_progress.can_break_through()
# assert self.avatar.cultivation_progress.can_break_through()
if not self.avatar.cultivation_progress.can_break_through():
print(f"警告,{self.avatar.name} 无法突破境界其level为 {self.avatar.cultivation_progress.level},无法突破")
success_rate = self.calc_success_rate()
if random.random() < success_rate:
self.avatar.cultivation_progress.break_through()
is_success = True
else:
is_success = False
res = "成功" if is_success else "失败"
return Event(self.world.year, self.world.month, f"{self.avatar.name} 突破境界{res}")
def get_event(self) -> Event:
"""
获取突破动作开始时的事件
"""
return Event(self.world.month_stamp, f"{self.avatar.name} 开始尝试突破境界")
ALL_ACTION_CLASSES = [Move, Cultivate, Breakthrough, MoveToRegion]

View File

@@ -1,5 +1,5 @@
import random
from src.classes.calendar import Month, Year
from src.classes.calendar import Month, Year, MonthStamp
from src.classes.cultivation import Realm
class Age:
@@ -57,29 +57,26 @@ class Age:
"""
return random.random() < self.get_death_probability(realm)
def calculate_age(self, current_month: Month, current_year: Year, birth_month: Month, birth_year: Year) -> int:
def calculate_age(self, current_month_stamp: MonthStamp, birth_month_stamp: MonthStamp) -> int:
"""
计算准确的年龄(整数年)
Args:
current_month: 当前月份
current_year: 当前年份
birth_month: 出生月份
birth_year: 出生年份
current_month_stamp: 当前时间戳
birth_month_stamp: 出生时间戳
Returns:
整数年龄
"""
age = current_year - birth_year
if current_month.value < birth_month.value:
age -= 1
return max(0, age)
return max(0, (current_month_stamp - birth_month_stamp) // 12)
def update_age(self, current_month: Month, current_year: Year, birth_month: Month, birth_year: Year):
def update_age(self, current_month_stamp: MonthStamp, birth_month_stamp: MonthStamp):
"""
更新年龄
"""
self.age = self.calculate_age(current_month, current_year, birth_month, birth_year)
self.age = self.calculate_age(current_month_stamp, birth_month_stamp)
def __str__(self) -> str:
"""返回年龄的字符串表示"""

View File

@@ -3,25 +3,45 @@ NPC AI的类。
这里指的不是LLM或者Machine Learning而是NPC的决策机制
分为两类规则AI和LLM AI
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from src.classes.world import World
from src.classes.tile import Region
from src.classes.root import corres_essence_type
from src.classes.action import ACTION_SPACE_STR
from src.classes.event import Event, NULL_EVENT
from src.utils.llm import get_ai_prompt_and_call_llm
if TYPE_CHECKING:
from src.classes.avatar import Avatar
class AI(ABC):
"""
AI的基类
"""
def __init__(self, avatar: 'Avatar'):
def __init__(self, avatar: Avatar):
self.avatar = avatar
@abstractmethod
def decide(self, world: World) -> tuple[str, dict]:
def decide(self, world: World) -> tuple[str, dict, Event]:
"""
决定做什么
决定做什么,同时生成对应的事件
"""
# 先决定动作和参数
action_name, action_params = self._decide(world)
# 获取动作对象并生成事件
action = self.avatar.create_action(action_name)
event = action.get_event(**action_params)
return action_name, action_params, event
@abstractmethod
def _decide(self, world: World) -> tuple[str, dict]:
"""
决策逻辑:决定执行什么动作和参数
由子类实现具体的决策逻辑
"""
pass
@@ -29,9 +49,9 @@ class RuleAI(AI):
"""
规则AI
"""
def decide(self, world: World) -> tuple[str, dict]:
def _decide(self, world: World) -> tuple[str, dict]:
"""
定做什么
策逻辑:决定执行什么动作和参数
先做一个简单的:
1. 找到自己灵根对应的最好的区域
2. 检测自己是否在最好的区域
@@ -69,17 +89,19 @@ class LLMAI(AI):
不能每个单步step都调用一次LLM来决定下一步做什么。这样子一方面动作一直乱变另一方面也太费token了。
decide的作用是拉取既有的动作链如果没有了就call_llm再根据动作链决定动作以及动作之间的衔接。
"""
def decide(self, world: World) -> tuple[str, dict]:
def _decide(self, world: World) -> tuple[str, dict]:
"""
定做什么
策逻辑通过LLM决定执行什么动作和参数
"""
action_space_str = ACTION_SPACE_STR
avatar_infos_str = str(self.avatar)
regions_str = "\n".join([str(region) for region in world.map.regions.values()])
avatar_persona = self.avatar.persona.prompt
dict_info = {
"action_space": action_space_str,
"avatar_infos": avatar_infos_str,
"regions": regions_str
"regions": regions_str,
"avatar_persona": avatar_persona
}
res = get_ai_prompt_and_call_llm(dict_info)
action_name, action_params = res["action_name"], res["action_params"]

View File

@@ -1,18 +1,20 @@
import random
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from src.classes.calendar import Month, Year
from src.classes.calendar import Month, Year, MonthStamp
from src.classes.action import Action, ALL_ACTION_CLASSES
from src.classes.world import World
from src.classes.tile import Tile, Region
from src.classes.cultivation import CultivationProgress, Realm
from src.classes.root import Root
from src.classes.age import Age
from src.utils.strings import to_snake_case
from src.classes.event import NULL_EVENT
from src.classes.ai import AI, RuleAI, LLMAI
from src.classes.persona import Persona, personas_by_id
from src.utils.id_generator import get_avatar_id
class Gender(Enum):
MALE = "male"
@@ -35,26 +37,27 @@ class Avatar:
world: World
name: str
id: str
birth_month: Month
birth_year: Year
birth_month_stamp: MonthStamp
age: Age
gender: Gender
cultivation_progress: CultivationProgress = field(default_factory=lambda: CultivationProgress(0))
pos_x: int = 0
pos_y: int = 0
tile: Optional[Tile] = None
actions: dict[str, Action] = field(default_factory=dict)
root: Root = field(default_factory=lambda: random.choice(list(Root)))
persona: Persona = field(default_factory=lambda: random.choice(list(personas_by_id.values())))
ai: AI = None
action_be_executed: Optional[Action] = None
action_parmas_be_executed: Optional[dict] = None
def __post_init__(self):
"""
在Avatar创建后自动绑定基础动作和AI
在Avatar创建后自动初始化tile和AI
"""
self.tile = self.world.map.get_tile(self.pos_x, self.pos_y)
self.ai = LLMAI(self)
# self.ai = RuleAI(self)
self._bind_basic_actions()
# self.ai = LLMAI(self)
self.ai = RuleAI(self)
def __str__(self) -> str:
"""
@@ -63,38 +66,48 @@ class Avatar:
"""
return f"Avatar(id={self.id}, 性别={self.gender}, 年龄={self.age}, name={self.name}, 区域={self.tile.region.name}, 灵根={self.root.value}, 境界={self.cultivation_progress})"
def _bind_basic_actions(self):
def create_action(self, action_name: str) -> Action:
"""
绑定基础动作,如移动等
根据动作名称创建新的action实例
Args:
action_name: 动作类的名称(如 'Cultivate', 'Breakthrough' 等)
Returns:
新创建的Action实例
Raises:
ValueError: 如果找不到对应的动作类
"""
for action in ALL_ACTION_CLASSES:
self.bind_action(action)
def bind_action(self, action_class: type[Action]):
"""
绑定一个action到avatar
"""
# 以类名为键保存实例,保持可追踪性
self.actions[action_class.__name__] = action_class(self, self.world)
# 同时挂载一个便捷方法名称为蛇形MoveFast -> move_fast并转发参数
method_name = to_snake_case(action_class.__name__)
def _wrapper(*args, **kwargs):
return self.actions[action_class.__name__].execute(*args, **kwargs)
setattr(self, method_name, _wrapper)
# 在所有动作类中查找对应的类
for action_class in ALL_ACTION_CLASSES:
if action_class.__name__ == action_name:
return action_class(self, self.world)
raise ValueError(f"未找到名为 '{action_name}' 的动作类")
def act(self):
"""
角色执行动作。
实际上分为两步决定做什么decide和实习上去做do
实际上分为两步决定做什么decide和实去做do
事件只在决定动作时产生,执行过程不产生事件
"""
action_name, action_args = self.ai.decide(self.world)
action = self.actions[action_name]
event = action.execute(**action_args)
event = NULL_EVENT
if self.action_be_executed is None:
# 决定动作时生成事件
action_name, action_args, event = self.ai.decide(self.world)
self.action_be_executed = self.create_action(action_name)
self.action_parmas_be_executed = action_args
# 纯粹执行动作,不产生事件
self.action_be_executed.execute(**self.action_parmas_be_executed)
if self.action_be_executed.is_finished(**self.action_parmas_be_executed):
self.action_be_executed = None
self.action_parmas_be_executed = None
return event
def update_cultivation(self, new_level: int):
@@ -118,11 +131,11 @@ class Avatar:
"""
return self.age.death_by_old_age(self.cultivation_progress.realm)
def update_age(self, current_month: Month, current_year: Year):
def update_age(self, current_month_stamp: MonthStamp):
"""
更新年龄
"""
self.age.update_age(current_month, current_year, self.birth_month, self.birth_year)
self.age.update_age(current_month_stamp, self.birth_month_stamp)
def get_age_info(self) -> dict:
"""
@@ -145,27 +158,25 @@ class Avatar:
def is_in_region(self, region: Region) -> bool:
return self.tile.region == region
def get_new_avatar_from_ordinary(world: World, current_year: Year, name: str, age: Age):
def get_new_avatar_from_ordinary(world: World, current_month_stamp: MonthStamp, name: str, age: Age):
"""
从凡人中来的新修士
这代表其境界为最低
"""
# 利用uuid功能生成id
avatar_id = str(uuid.uuid4())
# 生成短ID替代UUID4
avatar_id = get_avatar_id()
birth_year = current_year - age.age
birth_month = random.choice(list(Month))
birth_month_stamp = current_month_stamp - age.age * 12 + random.randint(0, 11) # 在出生年内随机选择月份
cultivation_progress = CultivationProgress(0)
pos_x = random.randint(0, world.map.width)
pos_y = random.randint(0, world.map.height)
pos_x = random.randint(0, world.map.width - 1)
pos_y = random.randint(0, world.map.height - 1)
gender = random.choice(list(Gender))
return Avatar(
world=world,
name=name,
id=avatar_id,
birth_month=birth_month,
birth_year=birth_year,
birth_month_stamp=MonthStamp(birth_month_stamp),
age=age,
gender=gender,
cultivation_progress=cultivation_progress,

View File

@@ -24,8 +24,23 @@ class Year(int):
def __add__(self, other: int) -> 'Year':
return Year(int(self) + other)
def next_month(month: Month, year: Year) -> tuple[Month, Year]:
if month == Month.DECEMBER:
return Month.JANUARY, year + 1
else:
return Month(month.value + 1), year
class MonthStamp(int):
"""
0年1月 = 0
之后依次递增
"""
def get_month(self) -> Month:
month_value = (self % 12) + 1
return Month(month_value if month_value <= 12 else 12)
def get_year(self) -> Year:
return Year(self // 12)
def __add__(self, other: int) -> 'MonthStamp':
return MonthStamp(int(self) + other)
def create_month_stamp(year: Year, month: Month) -> MonthStamp:
"""从年和月创建MonthStamp"""
return MonthStamp(int(year) * 12 + month.value - 1)

View File

@@ -92,16 +92,6 @@ class CultivationProgress:
return exp_required + realm_bonus
def can_level_up(self) -> bool:
"""
检查是否可以升级
返回:
如果经验值足够升级则返回True
"""
required_exp = self.get_exp_required()
return self.exp >= required_exp
def get_exp_progress(self) -> tuple[int, int]:
"""
获取当前经验值进度
@@ -150,5 +140,12 @@ class CultivationProgress:
"""
return self.level in level_to_break_through.keys()
def can_level_up(self) -> bool:
"""
检查是否可以升级
可以突破,说明到顶了,说明不能升级。
"""
return not self.can_break_through()
def __str__(self) -> str:
return f"{self.realm.value}{self.stage.value}({self.level}级)。可以突破:{self.can_break_through()}"

View File

@@ -3,17 +3,39 @@ event class
"""
from dataclasses import dataclass
from src.classes.calendar import Month, Year
from src.classes.calendar import Month, Year, MonthStamp
@dataclass
class Event:
year: Year
month: Month
month_stamp: MonthStamp
content: str
def __str__(self) -> str:
return f"{self.year}{self.month}月: {self.content}"
year = self.month_stamp.get_year()
month = self.month_stamp.get_month()
return f"{year}{month}月: {self.content}"
class NullEvent:
"""
空事件单例类
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __str__(self) -> str:
return ""
return ""
def __bool__(self) -> bool:
"""使NullEvent实例在布尔上下文中为False"""
return False
# 全局单例实例
NULL_EVENT = NullEvent()
def is_null_event(event) -> bool:
"""检查事件是否为空事件的便捷函数"""
return event is NULL_EVENT

29
src/classes/persona.py Normal file
View File

@@ -0,0 +1,29 @@
from dataclasses import dataclass, field
# TODO: 配表化
@dataclass
class Persona:
"""
角色个性
"""
id: int
name: str
prompt: str
personas_by_id: dict[int, Persona] = {}
personas_by_name: dict[str, Persona] = {}
p1 = Persona(id=1, name="理性", prompt="你是一个理性的人,你总是会用逻辑来思考问题,做事会谋定而后动。")
p2 = Persona(id=2, name="无常", prompt="你是一个无常的人,你总是会随机应变,性子到哪里了就是哪里。")
p3 = Persona(id=3, name="怠惰", prompt="你是一个怠惰的人,你总是会拖延,不想努力,更热衷于享受人生。")
p4 = Persona(id=4, name="冒险", prompt="你是一个冒险的人,你总是会冒险,喜欢刺激,总想放手一搏。")
personas_by_id[p1.id] = p1
personas_by_id[p2.id] = p2
personas_by_id[p3.id] = p3
personas_by_id[p4.id] = p4
personas_by_name[p1.name] = p1
personas_by_name[p2.name] = p2
personas_by_name[p3.name] = p3
personas_by_name[p4.name] = p4

View File

@@ -1,10 +1,9 @@
from dataclasses import dataclass
from src.classes.tile import Map
from src.classes.calendar import Year, Month
from src.classes.calendar import Year, Month, MonthStamp
@dataclass
class World():
map: Map
year: Year
month: Month
month_stamp: MonthStamp