decide only one avatar each time

This commit is contained in:
bridge
2025-12-08 22:08:53 +08:00
parent 33cf306e58
commit 303bffe413
9 changed files with 96 additions and 30 deletions

View File

@@ -66,22 +66,33 @@ class LLMAI(AI):
async def _decide(self, world: World, avatars_to_decide: list[Avatar]) -> dict[Avatar, tuple[ACTION_NAME_PARAMS_PAIRS, str, str]]:
"""
异步决策逻辑通过LLM决定执行什么动作和参数
改动:支持每个角色仅获取其已知区域的世界信息,并发调用 LLM。
"""
world_info = world.get_info()
# 在提示中包含处于角色观测范围内的其他角色
avatar_infos = {}
for avatar in avatars_to_decide:
observed = world.get_observable_avatars(avatar)
avatar_infos[avatar.name] = avatar.get_expanded_info(observed)
general_action_infos = ACTION_INFOS_STR
info = {
"avatar_infos": avatar_infos,
"world_info": world_info,
"general_action_infos": general_action_infos,
}
res = await call_ai_action(info)
async def decide_one(avatar: Avatar):
# 获取基于该角色已知区域的世界信息
world_info = world.get_info(known_region_ids=avatar.known_regions)
# 在提示中包含处于角色观测范围内的其他角色
observed = world.get_observable_avatars(avatar)
avatar_info = avatar.get_expanded_info(co_region_avatars=observed)
info = {
"avatar_info": avatar_info,
"world_info": world_info,
"general_action_infos": general_action_infos,
}
res = await call_ai_action(info)
return avatar, res
tasks = [decide_one(avatar) for avatar in avatars_to_decide]
results_list = await asyncio.gather(*tasks)
results: dict[Avatar, tuple[ACTION_NAME_PARAMS_PAIRS, str, str]] = {}
for avatar in avatars_to_decide:
for avatar, res in results_list:
if not res or avatar.name not in res:
continue
r = res[avatar.name]
# 仅接受 action_name_params_pairs不再支持单个 action_name/action_params
raw_pairs = r["action_name_params_pairs"]
@@ -94,6 +105,7 @@ class LLMAI(AI):
else:
# 跳过无法解析的项
continue
# 至少有一个
if not pairs:
raise ValueError(f"LLM未返回有效的action_name_params_pairs: {r}")
@@ -101,6 +113,7 @@ class LLMAI(AI):
avatar_thinking = r.get("avatar_thinking", r.get("thinking", ""))
short_term_objective = r.get("short_term_objective", "")
results[avatar] = (pairs, avatar_thinking, short_term_objective)
return results
llm_ai = LLMAI()

View File

@@ -125,6 +125,9 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin):
# 动作冷却:记录动作类名 -> 上次完成月戳
_action_cd_last_months: dict[str, int] = field(default_factory=dict)
# 不缓存 effects实时从宗门与功法合并
# 知道的区域 ID 集合
known_regions: set[int] = field(default_factory=set)
def join_sect(self, sect: Sect, rank: "SectRank") -> None:
"""加入宗门"""
@@ -216,6 +219,24 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin):
# 初始化时计算所有长期效果HP等
self.recalc_effects()
# 初始化已知区域
self._init_known_regions()
def _init_known_regions(self):
"""初始化已知区域:当前位置 + 宗门驻地"""
# 1. 当前位置
if self.tile and self.tile.region:
self.known_regions.add(self.tile.region.id)
# 2. 宗门驻地
if self.sect:
# 遍历地图寻找该宗门的驻地
# map.sect_regions 是 {region_id: SectRegion}
for r in self.world.map.sect_regions.values():
if r.sect_id == self.sect.id:
self.known_regions.add(r.id)
break
@property
def effects(self) -> dict[str, object]:
merged: dict[str, object] = defaultdict(str)
@@ -571,6 +592,11 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin):
if self.current_action is None:
# 当前无动作时才清除标记,避免清除新提交动作的标记
self._new_action_set_this_step = False
# 每次执行动作后,更新已知区域
if self.tile and self.tile.region:
self.known_regions.add(self.tile.region.id)
return events
def update_cultivation(self, new_level: int):

View File

@@ -78,8 +78,8 @@ async def generate_long_term_objective(avatar: "Avatar") -> Optional[LongTermObj
Returns:
生成的LongTermObjective对象失败则返回None
"""
# 准备世界信息
world_info = avatar.world.get_info()
# 准备世界信息(仅获取已知区域)
world_info = avatar.world.get_info(known_region_ids=avatar.known_regions)
# 获取 expanded_info包含详细信息和事件历史
expanded_info = avatar.get_expanded_info(detailed=True)

View File

@@ -80,9 +80,10 @@ class Map():
"""
return self.tiles[(x, y)].region
def get_info(self, detailed: bool = False) -> dict:
def get_info(self, detailed: bool = False, known_region_ids: Optional[set[int]] = None) -> dict:
"""
返回地图信息dict
known_region_ids: 如果提供仅返回这些ID对应的区域信息。
"""
# 动态分类(因为现在没有自动分类字典了)
# 或者我们简单点,不分类返回,只返回总览?
@@ -91,7 +92,10 @@ class Map():
from src.classes.region import NormalRegion, CultivateRegion, CityRegion
def filter_regions(cls):
return {rid: r for rid, r in self.regions.items() if isinstance(r, cls)}
return {
rid: r for rid, r in self.regions.items()
if rid in known_region_ids
}
def build_regions_info(regions_dict) -> list[str]:
if detailed:

View File

@@ -23,12 +23,13 @@ class World():
# 天地灵机开始年份(用于计算持续时间)
phenomenon_start_year: int = 0
def get_info(self, detailed: bool = False) -> dict:
def get_info(self, detailed: bool = False, known_region_ids: Optional[set[int]] = None) -> dict:
"""
返回世界信息dict其中包含地图信息dict
如果指定了 known_region_ids则只返回这些 ID 对应的区域信息。
"""
static_info = self.static_info
map_info = self.map.get_info(detailed=detailed)
map_info = self.map.get_info(detailed=detailed, known_region_ids=known_region_ids)
world_info = {**map_info, **static_info}
if self.current_phenomenon:

View File

@@ -157,6 +157,7 @@ class AvatarLoadMixin:
avatar.thinking = data.get("thinking", "")
avatar.short_term_objective = data.get("short_term_objective", "")
avatar._action_cd_last_months = data.get("_action_cd_last_months", {})
avatar.known_regions = set(data.get("known_regions", []))
# 加载长期目标
long_term_objective_data = data.get("long_term_objective")

View File

@@ -104,5 +104,6 @@ class AvatarSaveMixin:
"set_year": self.long_term_objective.set_year
} if self.long_term_objective else None,
"_action_cd_last_months": self._action_cd_last_months,
"known_regions": list(self.known_regions),
}

View File

@@ -1,9 +1,9 @@
你是一个决策者,这是一个仙侠世界,你负责来决定一些角色之后的动作行为。
你是一个决策者,这是一个仙侠世界,你负责来决定角色之后的动作行为。
{world_info}
全部可执行的动作有:
{general_action_infos}
你需要进行决策的NPC的dict[AvatarName, info]
{avatar_infos}
你需要进行决策的NPC的info为
{avatar_info}
注意只返回json格式结果。

View File

@@ -77,7 +77,6 @@ function spawnCloud(initial: boolean = false) {
const speedY = (Math.random() - 0.5) * 0.1 // Slight vertical drift
let x, y
const margin = 200
if (initial) {
// Random anywhere
@@ -85,7 +84,17 @@ function spawnCloud(initial: boolean = false) {
y = Math.random() * props.height
} else {
// Start from Left (off-screen)
x = -margin - Math.random() * 100
// We need to ensure the cloud is fully off-screen.
// Shadow is offset to the right, so the rightmost point is shadow.x + halfWidth
// shadow.x = x + offset
// rightmost = x + offset + halfWidth
// We want rightmost < 0 -> x < -(offset + halfWidth)
const shadowOffsetX = 40 * scale
const halfWidth = (tex.width * scale) / 2
const safeMargin = shadowOffsetX + halfWidth + 50
x = -safeMargin - Math.random() * 200
y = Math.random() * props.height
}
@@ -112,7 +121,7 @@ function spawnCloud(initial: boolean = false) {
function updateClouds(dt: number) {
const bounds = { w: props.width, h: props.height }
const margin = 300 // Wider margin for cleanup
const verticalMargin = 300 // For Y-axis removal
for (let i = activeClouds.value.length - 1; i >= 0; i--) {
const cloud = activeClouds.value[i]
@@ -122,14 +131,24 @@ function updateClouds(dt: number) {
cloud.sprite.y += cloud.speedY * dt
// Move Shadow (Keep offset)
// Re-calculate offset based on scale to keep it simple, or just apply delta
cloud.shadow.x += cloud.speedX * dt
cloud.shadow.y += cloud.speedY * dt
// Boundary Check (Only check Right side since we move Right)
if (cloud.sprite.x > bounds.w + margin ||
cloud.sprite.y > bounds.h + margin ||
cloud.sprite.y < -margin) {
// Calculate removal boundary
// Use sprite width to determine if fully off-screen
// sprite.width is the scaled width. Anchor is 0.5.
const halfWidth = cloud.sprite.width / 2
// Check if Cloud is fully off-screen to the right
// We check the Leftmost edge of the cloud/shadow complex.
// Since shadow is to the right, the leftmost edge is the cloud's left edge.
// Left edge = cloud.sprite.x - halfWidth
const isOffScreenRight = (cloud.sprite.x - halfWidth) > bounds.w
// Vertical check (standard margin)
const isOffScreenVertical = cloud.sprite.y > bounds.h + verticalMargin || cloud.sprite.y < -verticalMargin
if (isOffScreenRight || isOffScreenVertical) {
// Remove
if (container.value) {
@@ -204,3 +223,4 @@ onUnmounted(() => {
<!-- z-index 300 should be above entities (usually < 100) and map -->
<container ref="container" :z-index="300" event-mode="none" />
</template>