Files
SimpleFarm/farm/server/actions.py
T
snowy-yang a7725f8812 chore: 工程化改造前的基线快照
在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前,
先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。

此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。
2026-09-18 21:12:40 +08:00

241 lines
9.0 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.
"""统一的动作表:HTTP、WebSocket、命令行、机器人都走这一份映射。
为什么要有这一层
----------------
引擎(``farm.engine.FarmEngine``)提供的是**方法**,但外部调用者(AI Agent、
脚本、网页)手里拿到的只有字符串动作名 + 参数字典。如果让每个入口各自写一遍
``if action == "plant"`` 的分发,规则就会散落在三四个文件里,迟早出现
"HTTP 能做、WS 不能做" 这种不一致。
所以这里集中定义:动作名 → 参数规格 → 引擎调用 → 返回结果。
新增一个玩法动作,只需要在这张表里加一行。
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from .. import config
from ..engine import FarmEngine, GameError
class Param:
"""一个动作参数的规格说明(同时用于校验与自动生成给 AI 的文档)。"""
__slots__ = ("name", "kind", "required", "default", "desc")
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__ = ("name", "summary", "params", "handler", "realtime")
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: Optional[Dict[str, 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 = {}
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
result = action.handler(engine, clean)
if not isinstance(result, dict):
result = {"message": str(result)}
result.setdefault("ok", True)
result["action"] = key
return result