update can_start

This commit is contained in:
bridge
2025-10-21 23:58:39 +08:00
parent 67180b3343
commit 17afb4c876
24 changed files with 103 additions and 83 deletions

View File

@@ -129,8 +129,8 @@ class ActualActionMixin():
"""
@abstractmethod
def can_start(self, **params) -> bool:
return True
def can_start(self, **params) -> tuple[bool, str]:
return True, ""
@abstractmethod
def start(self, **params) -> Event | None:

View File

@@ -31,10 +31,11 @@ class Battle(InstantAction):
winner.hp.reduce(winner_damage)
self._last_result = (winner.name, loser.name, loser_damage, winner_damage)
def can_start(self, avatar_name: str | None = None) -> bool:
def can_start(self, avatar_name: str | None = None) -> tuple[bool, str]:
if avatar_name is None:
return False
return self._get_target(avatar_name) is not None
return False, "缺少参数 avatar_name"
ok = self._get_target(avatar_name) is not None
return (ok, "" if ok else "目标不存在")
def start(self, avatar_name: str) -> Event:
target = self._get_target(avatar_name)

View File

@@ -103,8 +103,9 @@ class Breakthrough(TimedAction):
self.avatar.mp.add_max(mp_increase)
self.avatar.mp.recover(mp_increase) # 突破时完全恢复MP
def can_start(self) -> bool:
return self.avatar.cultivation_progress.can_break_through()
def can_start(self) -> tuple[bool, str]:
ok = self.avatar.cultivation_progress.can_break_through()
return (ok, "" if ok else "当前不处于瓶颈,无法突破")
def start(self) -> Event:
# 清理状态

View File

@@ -23,7 +23,7 @@ class Catch(TimedAction):
- 按动物境界映射成功率尝试捕捉,成功则成为灵兽(覆盖旧灵兽)。
"""
COMMENT = "尝试驯服一只灵兽,成为自身灵兽"
COMMENT = "尝试驯服一只灵兽,成为自身灵兽。只能有一只灵兽,但是可以高级替换低级。"
DOABLES_REQUIREMENTS = "仅百兽宗;在有动物的普通区域;目标动物境界不高于角色"
PARAMS = {}
@@ -55,15 +55,17 @@ class Catch(TimedAction):
# 覆盖为新的灵兽
self.avatar.spirit_animal = SpiritAnimal(name=target.name, realm=target.realm)
def can_start(self) -> bool:
def can_start(self) -> tuple[bool, str]:
# 仅百兽宗
sect = getattr(self.avatar, "sect", None)
if sect is None or getattr(sect, "name", "") != "百兽宗":
return False
return False, "仅百兽宗弟子可用"
region = self.avatar.tile.region
if not isinstance(region, NormalRegion):
return False
return len(self._get_available_animals()) > 0
return False, "当前不在普通区域"
if len(self._get_available_animals()) == 0:
return False, "当前区域无可御兽的动物或其境界过高"
return True, ""
def start(self) -> Event:
region = self.avatar.tile.region

View File

@@ -45,17 +45,17 @@ class Cultivate(TimedAction):
base = 100
return base * essence_density
def can_start(self) -> bool:
def can_start(self) -> tuple[bool, str]:
root = self.avatar.root
region = self.avatar.tile.region
essence_types = get_essence_types_for_root(root)
if not self.avatar.cultivation_progress.can_cultivate():
return False
return False, "修为已达瓶颈,无法继续修炼"
if not isinstance(region, CultivateRegion):
return False
return False, "当前不在修炼区域"
if all(region.essence.get_density(et) == 0 for et in essence_types):
return False
return True
return False, "当前区域无与灵根相符的灵气"
return True, ""
def start(self) -> Event:
return Event(self.world.month_stamp, f"{self.avatar.name}{self.avatar.tile.region.name} 开始修炼", related_avatars=[self.avatar.id])

View File

@@ -23,9 +23,10 @@ class DevourMortals(TimedAction):
gain = random.randint(10, 100)
tr.devoured_souls = min(10000, int(tr.devoured_souls) + gain)
def can_start(self) -> bool:
def can_start(self) -> tuple[bool, str]:
legal = self.avatar.effects.get("legal_actions", [])
return "DevourMortals" in legal
ok = "DevourMortals" in legal
return (ok, "" if ok else "未被允许的非法动作(缺少万魂幡或权限)")
def start(self) -> Event:
return Event(self.world.month_stamp, f"{self.avatar.name} 在城镇开始吞噬凡人", related_avatars=[self.avatar.id])

View File

@@ -56,8 +56,8 @@ class Escape(InstantAction):
if start_event is not None:
EventHelper.push_pair(start_event, initiator=self.avatar, target=target, to_sidebar_once=True)
def can_start(self, avatar_name: str | None = None) -> bool:
return True
def can_start(self, avatar_name: str | None = None) -> tuple[bool, str]:
return True, ""
def start(self, avatar_name: str) -> Event:
target = self._find_avatar_by_name(avatar_name)

View File

@@ -52,14 +52,14 @@ class Harvest(TimedAction):
"""
return 1.0 # 100%成功率
def can_start(self) -> bool:
def can_start(self) -> tuple[bool, str]:
region = self.avatar.tile.region
if not isinstance(region, NormalRegion):
return False
return False, "当前不在普通区域"
avaliable_plants = self.get_available_plants()
if len(avaliable_plants) == 0:
return False
return True
return False, "当前区域无可采集的植物或其境界过高"
return True, ""
def start(self) -> Event:
region = self.avatar.tile.region

View File

@@ -27,14 +27,16 @@ class HelpMortals(TimedAction):
if getattr(self.avatar.magic_stone, "value", 0) >= cost:
self.avatar.magic_stone = self.avatar.magic_stone - cost
def can_start(self) -> bool:
def can_start(self) -> tuple[bool, str]:
region = self.avatar.tile.region
if not isinstance(region, CityRegion):
return False
return False, "仅能在城市区域执行"
if self.avatar.alignment != Alignment.RIGHTEOUS:
return False
return False, "仅正阵营可执行"
cost = self.COST
return self.avatar.magic_stone >= cost
if not (self.avatar.magic_stone >= cost):
return False, "灵石不足"
return True, ""
def start(self) -> Event:
return Event(self.world.month_stamp, f"{self.avatar.name} 在城镇开始帮助凡人")

View File

@@ -52,14 +52,14 @@ class Hunt(TimedAction):
"""
return 1.0 # 100%成功率
def can_start(self) -> bool:
def can_start(self) -> tuple[bool, str]:
region = self.avatar.tile.region
if not isinstance(region, NormalRegion):
return False
return False, "当前不在普通区域"
available_animals = self.get_available_animals()
if len(available_animals) == 0:
return False
return True
return False, "当前区域无可狩猎的动物或其境界过高"
return True, ""
def start(self) -> Event:
region = self.avatar.tile.region

View File

@@ -39,8 +39,8 @@ class MoveAwayFromAvatar(TimedAction):
dx, dy = clamp_manhattan_with_diagonal_priority(raw_dx, raw_dy, step)
Move(self.avatar, self.world).execute(dx, dy)
def can_start(self, avatar_name: str | None = None) -> bool:
return True
def can_start(self, avatar_name: str | None = None) -> tuple[bool, str]:
return True, ""
def start(self, avatar_name: str) -> Event:
target_name = avatar_name

View File

@@ -32,14 +32,14 @@ class MoveAwayFromRegion(InstantAction):
dx, dy = clamp_manhattan_with_diagonal_priority(away_dx, away_dy, step)
Move(self.avatar, self.world).execute(dx, dy)
def can_start(self, region: str | None = None) -> bool:
def can_start(self, region: str | None = None) -> tuple[bool, str]:
if region is None:
return True
return True, ""
try:
resolve_region(self.world, region)
return True
return True, ""
except Exception:
return False
return False, f"无法解析区域: {region}"
def start(self, region: str) -> Event:
r = resolve_region(self.world, region)

View File

@@ -37,8 +37,8 @@ class MoveToAvatar(DefineAction, ActualActionMixin):
dx, dy = clamp_manhattan_with_diagonal_priority(raw_dx, raw_dy, step)
Move(self.avatar, self.world).execute(dx, dy)
def can_start(self, avatar_name: str | None = None) -> bool:
return True
def can_start(self, avatar_name: str | None = None) -> tuple[bool, str]:
return True, ""
def start(self, avatar_name: str) -> Event:
target = self._get_target(avatar_name)

View File

@@ -30,24 +30,24 @@ class MoveToRegion(DefineAction, ActualActionMixin):
dx, dy = clamp_manhattan_with_diagonal_priority(raw_dx, raw_dy, step)
Move(self.avatar, self.world).execute(dx, dy)
def can_start(self, region: Region | str | None = None) -> bool:
def can_start(self, region: Region | str | None = None) -> tuple[bool, str]:
if region is None:
return False
return False, "缺少参数 region"
try:
self._resolve_region(region)
return True
except (ValueError, TypeError):
return False
resolve_region(self.world, region)
return True, ""
except Exception:
return False, f"无法解析区域: {region}"
def start(self, region: Region | str) -> Event:
r = self._resolve_region(region)
r = resolve_region(self.world, region)
region_name = r.name # 仅使用规范化后的区域名
return Event(self.world.month_stamp, f"{self.avatar.name} 开始移动向 {region_name}", related_avatars=[self.avatar.id])
def step(self, region: Region | str) -> ActionResult:
self.execute(region=region)
# 完成条件:到达目标区域
r = resolve_region(region)
r = resolve_region(self.world, region)
# 常规:基于 tile.region 精确判定;兜底:当前位置坐标属于目标区域的格点集合
done = self.avatar.is_in_region(r) or ((self.avatar.pos_x, self.avatar.pos_y) in getattr(r, "cors", ()))
return ActionResult(status=(ActionStatus.COMPLETED if done else ActionStatus.RUNNING), events=[])

View File

@@ -23,8 +23,8 @@ class Play(TimedAction):
# 比如增加心情值、减少压力等
pass
def can_start(self) -> bool:
return True
def can_start(self) -> tuple[bool, str]:
return True, ""
def start(self) -> Event:
return Event(self.world.month_stamp, f"{self.avatar.name} 开始玩耍")

View File

@@ -26,11 +26,13 @@ class PlunderMortals(TimedAction):
gain = self.GAIN
self.avatar.magic_stone = self.avatar.magic_stone + gain
def can_start(self) -> bool:
def can_start(self) -> tuple[bool, str]:
region = self.avatar.tile.region
if not isinstance(region, CityRegion):
return False
return self.avatar.alignment == Alignment.EVIL
return False, "仅能在城市区域执行"
if self.avatar.alignment != Alignment.EVIL:
return False, "仅邪阵营可执行"
return True, ""
def start(self) -> Event:
return Event(self.world.month_stamp, f"{self.avatar.name} 在城镇开始搜刮凡人", related_avatars=[self.avatar.id])

View File

@@ -37,16 +37,18 @@ class SelfHeal(TimedAction):
hq_name = getattr(getattr(sect, "headquarter", None), "name", None) or getattr(sect, "name", None)
return bool(hq_name) and region.name == hq_name
def can_start(self) -> bool:
def can_start(self) -> tuple[bool, str]:
# 必须是宗门弟子且在自身宗门总部且当前HP未满
if getattr(self.avatar, "sect", None) is None:
return False
return False, "仅宗门弟子可用"
if not self._is_in_own_sect_headquarter():
return False
return False, "需要位于自身宗门总部"
hp_obj = getattr(self.avatar, "hp", None)
if hp_obj is None:
return False
return hp_obj.cur < hp_obj.max
return False, "缺少HP信息"
if not (hp_obj.cur < hp_obj.max):
return False, "当前HP已满"
return True, ""
def start(self) -> Event:
region = getattr(getattr(self.avatar, "tile", None), "region", None)

View File

@@ -43,17 +43,19 @@ class SellItems(InstantAction):
self.avatar.magic_stone = self.avatar.magic_stone + total_gain
def can_start(self, item_name: str | None = None) -> bool:
def can_start(self, item_name: str | None = None) -> tuple[bool, str]:
region = self.avatar.tile.region
if not isinstance(region, CityRegion):
return False
return False, "仅能在城市区域执行"
if item_name is None:
# 用于动作空间:只要背包非空即可
return bool(self.avatar.items)
ok = bool(self.avatar.items)
return (ok, "" if ok else "背包为空,无可出售物品")
item = items_by_name.get(item_name)
if item is None:
return False
return self.avatar.get_item_quantity(item) > 0
return False, f"未知物品: {item_name}"
ok = self.avatar.get_item_quantity(item) > 0
return (ok, "" if ok else "该物品数量为0")
def start(self, item_name: str) -> Event:
return Event(self.world.month_stamp, f"{self.avatar.name} 在城镇出售 {item_name}")

View File

@@ -26,9 +26,10 @@ class Talk(InstantAction):
# Talk 本身不做长期效果,主要在 step 中驱动 Conversation
return
def can_start(self, **kwargs) -> bool:
def can_start(self, **kwargs) -> tuple[bool, str]:
# 感知范围内是否存在其他NPC用于展示在动作空间
return len(self._get_observed_others()) > 0
ok = len(self._get_observed_others()) > 0
return (ok, "" if ok else "感知范围内没有可交谈对象")
def start(self) -> Event:
self.observed_others = self._get_observed_others()

View File

@@ -271,11 +271,11 @@ class Avatar:
action = self.create_action(plan.action_name)
# 再验证
params_for_can_start = filter_kwargs_for_callable(action.can_start, plan.params)
can_start = bool(action.can_start(**params_for_can_start))
can_start, reason = action.can_start(**params_for_can_start)
if not can_start:
# 记录不合法动作
logger = get_logger().logger
logger.warning("非法动作: Avatar(name=%s,id=%s) 的动作 %s 参数=%s 无法启动", self.name, self.id, plan.action_name, plan.params)
logger.warning("非法动作: Avatar(name=%s,id=%s) 的动作 %s 参数=%s 无法启动,原因=%s", self.name, self.id, plan.action_name, plan.params, reason)
continue
# 启动
params_for_start = filter_kwargs_for_callable(action.start, plan.params)
@@ -471,7 +471,8 @@ class Avatar:
doable_actions: list[Action] = []
for action in actual_actions:
# 用 can_start 的无参形式,用于“是否在动作空间中显示”
if action.can_start():
ok, _reason = action.can_start()
if ok:
doable_actions.append(action)
action_space = [action.name for action in doable_actions]
return action_space

View File

@@ -52,13 +52,16 @@ class Conversation(MutualAction):
"possible_relations": possible_relations,
}
def can_start(self, target_avatar: "Avatar|str|None" = None, **kwargs) -> bool:
def can_start(self, target_avatar: "Avatar|str|None" = None, **kwargs) -> tuple[bool, str]:
if target_avatar is None:
return False
return False, "缺少参数 target_avatar"
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
if target is None:
return False, "目标不存在"
if target.tile is None or self.avatar.tile is None:
return False, "任一角色未处于有效区域"
ok = target.tile.region == self.avatar.tile.region
return (ok, "" if ok else "目标不在同一区域")
def start(self, target_avatar: "Avatar|str", **kwargs) -> Event:
target = self._get_target_avatar(target_avatar)

View File

@@ -35,18 +35,18 @@ class DualCultivation(MutualAction):
# 复用 mutual_action 模板,仅需返回 Accept/Reject
return CONFIG.paths.templates / "mutual_action.txt"
def can_start(self, target_avatar: "Avatar|str|None" = None) -> bool:
def can_start(self, target_avatar: "Avatar|str|None" = None) -> tuple[bool, str]:
if target_avatar is None:
return False
return False, "缺少参数 target_avatar"
# 基于 effects 判断是否允许
effects = self.avatar.effects
legal_actions = effects.get("legal_actions", [])
if not isinstance(legal_actions, list) or "DualCultivation" not in legal_actions:
return False
return False, "仅合欢宗或未被允许"
target = self._get_target_avatar(target_avatar)
if target is None:
return False
return True
return False, "目标不存在"
return True, ""
def start(self, target_avatar: "Avatar|str") -> Event:
target = self._get_target_avatar(target_avatar)

View File

@@ -141,17 +141,18 @@ class MutualAction(DefineAction, LLMAction, TargetingMixin):
self._apply_feedback(target_avatar, feedback)
# 实现 ActualActionMixin 接口
def can_start(self, target_avatar: "Avatar|str|None" = None) -> bool:
def can_start(self, target_avatar: "Avatar|str|None" = None) -> tuple[bool, str]:
"""
检查互动动作能否启动:目标需在发起者的感知范围内。
"""
if target_avatar is None:
return False
return False, "缺少参数 target_avatar"
target = self._get_target_avatar(target_avatar)
if target is None:
return False
return False, "目标不存在"
from src.classes.observe import is_within_observation
return is_within_observation(self.avatar, target)
ok = is_within_observation(self.avatar, target)
return (ok, "" if ok else "目标不在感知范围内")
def start(self, target_avatar: "Avatar|str") -> Event:
"""