Files
SimpleFarm/farm/server/actions.py
T
snowy-yang d7a7107b0a chore: 引入 pyright 类型检查清零 + README 工程化章节
pyright(basic 模式)从 15 errors / 10 warnings 降到 0/0:

- pyproject: pythonPlatform="All"(termios/msvcrt 双平台分支不再误报),
  reportUnsupportedDunderAll=false(__init__ 惰性导出属正常写法)
- keys.py: 捕获 sys.stdin 局部变量 + 提前返回,消除 fileno() 的 None 收窄告警
- server/app.py: lifespan 改用 @asynccontextmanager 并标注 AsyncIterator[None]
- server/actions.py: 显式标注 result: dict[str, Any],消除 isinstance 冗余报错
- server/__main__.py: 用 importlib.util.find_spec 探测 websockets
- tests/test_server.py: 同上,uvicorn 探测改为 find_spec
- tools/preview_tui.py: TYPE_CHECKING 下引入 RemoteFarm,修正返回类型标注
- farm/tui.py: 取消未使用变量,统一 round/取整写法

其他:
- README: 新增第 11 节「工程化:uv + ruff + pyright」,快速开始全部改用
  uv sync / uv run;原第 11 节起顺延为 12
- requirements.txt: 由 uv export --no-dev --no-hashes 重新生成(锁定版本)
- 清理临时日志文件

验收:ruff check 全通过 / ruff format 26 文件无改动 / pyright 0 错误 /
unittest 97 项全部通过
2026-09-18 21:24:30 +08:00

307 lines
9.8 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 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