"""统一的动作表:HTTP、WebSocket、命令行、机器人都走这一份映射。 为什么要有这一层 ---------------- 引擎(``farm.engine.FarmEngine``)提供的是**方法**,但外部调用者(AI Agent、 脚本、网页)手里拿到的只有字符串动作名 + 参数字典。如果让每个入口各自写一遍 ``if action == "plant"`` 的分发,规则就会散落在三四个文件里,迟早出现 "HTTP 能做、WS 不能做" 这种不一致。 所以这里集中定义:动作名 → 参数规格 → 引擎调用 → 返回结果。 新增一个玩法动作,只需要在这张表里加一行。 """ from __future__ import annotations from collections.abc import Callable from typing import Any from .. import config from ..engine import FarmEngine, GameError class Param: """一个动作参数的规格说明(同时用于校验与自动生成给 AI 的文档)。""" __slots__ = ("default", "desc", "kind", "name", "required") def __init__( self, name: str, kind: str, required: bool = True, default: Any = None, desc: str = "" ) -> None: self.name = name self.kind = kind # "int" / "str" / "bool" self.required = required self.default = default self.desc = desc def coerce(self, raw: Any) -> Any: """把外部传来的值转成期望类型,失败时抛出带 code 的业务异常。""" if self.kind == "bool": if isinstance(raw, bool): return raw if isinstance(raw, str) and raw.lower() in ("true", "false", "1", "0"): return raw.lower() in ("true", "1") if isinstance(raw, int): return bool(raw) raise GameError("bad_param", f"参数 {self.name} 需要布尔值,收到 {raw!r}") if self.kind == "int": if isinstance(raw, bool): raise GameError("bad_param", f"参数 {self.name} 需要整数,收到布尔值") if isinstance(raw, int): return raw if isinstance(raw, float) and raw.is_integer(): return int(raw) if isinstance(raw, str) and raw.strip().lstrip("+-").isdigit(): return int(raw.strip()) raise GameError("bad_param", f"参数 {self.name} 需要整数,收到 {raw!r}") if self.kind == "str": if isinstance(raw, (str, int, float)): return str(raw) raise GameError("bad_param", f"参数 {self.name} 需要字符串,收到 {raw!r}") return raw def to_dict(self) -> dict[str, Any]: return { "name": self.name, "type": self.kind, "required": self.required, "default": self.default, "desc": self.desc, } class Action: """一个可执行动作。""" __slots__ = ("handler", "name", "params", "realtime", "summary") def __init__( self, name: str, summary: str, params: list[Param], handler: Callable[[FarmEngine, dict[str, Any]], dict[str, Any]], realtime: bool = True, ) -> None: self.name = name self.summary = summary self.params = params self.handler = handler self.realtime = realtime def to_dict(self) -> dict[str, Any]: return { "action": self.name, "summary": self.summary, "params": [p.to_dict() for p in self.params], } # ---------------------------------------------------------------------- 处理器 def _harvest_all(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]: """收获所有成熟作物。逐块调用引擎,个别地块失败不影响其它地块。""" total_gold = 0 total_units = 0 plots: list[int] = [] errors: list[str] = [] # 复制列表:harvest 会修改 state.plots 中元素的内容,但不会增删元素 for plot in list(engine.state.plots): if not plot.is_ripe: continue try: result = engine.harvest(plot.index + 1) except GameError as exc: errors.append(f"{plot.index + 1}号地:{exc.message}") continue total_gold += int(result.get("earned", 0)) total_units += int(result.get("quantity", 0)) plots.append(plot.index + 1) if not plots and not errors: raise GameError("nothing_to_harvest", "当前没有成熟的作物可以收获") return { "message": f"收获 {len(plots)} 块地共 {total_units} 份作物,+{total_gold} 金币", "plots": plots, "quantity": total_units, "earned": total_gold, "errors": errors, } def _save(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]: return engine.save() def _reset(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]: return engine.reset() def _pause(engine: FarmEngine, params: dict[str, Any]) -> dict[str, Any]: value = params.get("paused") return engine.pause(value if isinstance(value, bool) else None) # ---------------------------------------------------------------------- 动作表 ACTIONS: dict[str, Action] = {} def _register(action: Action) -> None: ACTIONS[action.name] = action _register( Action( "plant", "在指定地块播种", [ Param("plot", "int", True, None, "地块编号,从 1 开始"), Param("crop", "str", True, None, "作物标识,如 wheat"), ], lambda e, p: e.plant(p["plot"], p["crop"]), ) ) _register( Action( "water", "给指定地块浇水(补满水分)", [ Param("plot", "int", True, None, "地块编号,从 1 开始"), ], lambda e, p: e.water(p["plot"]), ) ) _register( Action( "harvest", "收获指定地块的成熟作物", [ Param("plot", "int", True, None, "地块编号,从 1 开始"), ], lambda e, p: e.harvest(p["plot"]), ) ) _register(Action("harvest_all", "一键收获所有成熟作物", [], _harvest_all)) _register( Action( "buy_animal", "购买牲畜", [ Param("kind", "str", True, None, "牲畜标识:chicken / sheep / cow"), ], lambda e, p: e.buy_animal(p["kind"]), ) ) _register( Action( "feed", "喂食指定牲畜", [ Param("animal_id", "int", True, None, "牲畜编号(state.animals[].id)"), Param("quantity", "int", False, None, "喂食份数,默认按需求;多喂无益"), ], lambda e, p: e.feed(p["animal_id"], p.get("quantity")), ) ) _register( Action( "collect", "收取牲畜的产出并自动出售", [ Param("animal_id", "int", True, None, "牲畜编号"), ], lambda e, p: e.collect(p["animal_id"]), ) ) _register( Action( "buy_feed", "购买饲料", [ Param("quantity", "int", False, config.FEED_BULK, f"份数,默认 {config.FEED_BULK}"), ], lambda e, p: e.buy_feed(p.get("quantity", config.FEED_BULK)), ) ) _register(Action("buy_plot", "花钱开垦一块新土地", [], lambda e, p: e.buy_plot())) _register(Action("sleep", "主动推进一天(有冷却)", [], lambda e, p: e.sleep())) _register( Action( "pause", "暂停或恢复自动推进时间", [ Param("paused", "bool", False, None, "true 暂停 / false 恢复;省略则反转当前状态"), ], _pause, ) ) _register(Action("save", "把当前农场写入存档文件", [], _save)) _register(Action("reset", "重置农场,从头开始经营", [], _reset)) def action_names() -> list[str]: return sorted(ACTIONS) def describe_actions() -> list[dict[str, Any]]: return [ACTIONS[name].to_dict() for name in action_names()] def run_action(engine: FarmEngine, name: Any, params: Any = None) -> dict[str, Any]: """校验参数并执行动作。 这是所有入口的**唯一**执行通道,因此输入校验只需要在这里做一次。 """ if not isinstance(name, str) or not name.strip(): raise GameError("bad_param", "缺少动作名 action") key = name.strip().lower() action = ACTIONS.get(key) if action is None: raise GameError( "unknown_action", f"未知动作 {name!r},可用动作:{'、'.join(action_names())}", ) if params is None: params = {} # params 来自外部 JSON,类型标注是 Any 而不是 dict——这里的检查是**真的**校验, # 不是多余防御(`params` 完全可能就是数组或字符串) if not isinstance(params, dict): raise GameError("bad_param", "params 必须是对象") known = {p.name for p in action.params} unknown = [k for k in params if k not in known] if unknown: allowed = "、".join(sorted(known)) or "无" raise GameError( "bad_param", f"动作 {key} 不认识参数 {'、'.join(unknown)}(可用:{allowed})", ) clean: dict[str, Any] = {} for spec in action.params: if spec.name in params and params[spec.name] is not None: clean[spec.name] = spec.coerce(params[spec.name]) elif spec.required: raise GameError("missing_param", f"动作 {key} 缺少必需参数 {spec.name}") elif spec.default is not None: clean[spec.name] = spec.default # handler 声明返回 dict,但这里是所有入口的唯一执行通道,仍然兜一层: # 万一以后有 handler 返回了别的类型,也不会污染接口格式 raw: Any = action.handler(engine, clean) result: dict[str, Any] = raw if isinstance(raw, dict) else {"message": str(raw)} result.setdefault("ok", True) result["action"] = key return result