Files
snowy-yang cf24cb43f1 feat: 加天气 / 谷仓 / 建筑三套系统,收获改为入库 + 择时卖出
动手前先修掉了两处「死机制」,新增的三个系统各自激活一个:

* 天气:小麦 2 天成熟、初始水分 2、每天只掉 1 点,等于永远不需要浇水。
  现在水分消耗由天气决定(晴 1 / 多云 0 / 干旱 2,雨天反补 3),而
  `state.forecast` 把明天的天气提前告诉你——「今晚浇不浇」成了能算的决策。
  5 种天气、权重合计 100、同种不连出;暴风雨会毁未熟作物,温室免疫。
* 谷仓:市场波动原本纯装饰(收获当场按市价变现,没有择时空间)。现在收获与
  收取只进 `state.inventory`,靠新增的 `sell` 换金币,价格波动才真正参与决策。
  仓满时 `_store()` 抛 `granary_full` 且不改状态,作物原样留在地里。
* 建筑:新增 `build`,买的是「以后不用天天手动做」——水井自动浇干地、
  喂食器自动喂食(只吃库存饲料,绝不自动花钱)、温室、谷仓扩容。
  每天在 `advance_day` 里先于生长执行,同类越造越贵(×1.6)。

存档升到 `SAVE_VERSION=2`,配 `MIN_SAVE_VERSION=1` 向后兼容,旧档缺的新字段
一律走默认值,不作废玩家手里的档。`advance_day(weather=...)` 是留给测试的
确定性入口。

顺带修两个真 bug:
* `daemon.py` 三处 `makedirs(os.path.dirname(path))`:`--pid-file pid.txt`
  这种只给文件名的用法会让 dirname 变成空串,启动阶段直接崩。抽出
  `ensure_parent_dir()` 兜底。
* `RemoteFarm.snapshot()` 是本地缓存,非实时客户端轮询会一直读到旧数据,
  看起来像服务器卡死。补 docstring 指明要自己调 `refresh()`。

质量门禁:ruff format/check 全绿、pyright 0 错误、503 项测试 OK(skip 35)、
bench 无回归(brief 19.5µs / snapshot 48.5µs / POST full=1 400µs)。
另起真实服务器(--tick 3 + 预置存档)跑了 38 项端到端验收,全过。
2026-09-19 17:02:33 +08:00

323 lines
10 KiB
Python
Raw Permalink 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、命令行、机器人都走这一份映射。
为什么要有这一层
----------------
引擎(``farm.engine.FarmEngine``)提供的是**方法**,但外部调用者(AI Agent、
脚本、网页)手里拿到的只有字符串动作名 + 参数字典。如果让每个入口各自写一遍
``if action == "plant"`` 的分发,规则就会散落在三四个文件里,迟早出现
"HTTP 能做、命令行不能做" 这种不一致。
所以这里集中定义:动作名 → 参数规格 → 引擎调用 → 返回结果。
新增一个玩法动作,只需要在这张表里加一行。
"""
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_value = 0.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_value += float(result.get("value", 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} 份作物入谷仓,"
f"按现价约值 {round(total_value)} 金币(用 sell 才换成钱)"
),
"plots": plots,
"quantity": total_units,
"value": round(total_value, 2),
"errors": errors,
}
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(
"pause",
"暂停或恢复自动推进时间",
[
Param("paused", "bool", False, None, "true 暂停 / false 恢复;省略则反转当前状态"),
],
_pause,
)
)
_register(
Action(
"sell",
"把谷仓里的货物按当前市价卖出(省略 key 即全部卖光)",
[
Param("key", "str", False, None, "货物标识,如 wheat / animal:chicken;省略=全卖"),
Param("quantity", "int", False, None, "卖出数量,省略=该货物全卖"),
],
lambda e, p: e.sell(p.get("key"), p.get("quantity")),
)
)
_register(
Action(
"build",
"建造谷仓 / 水井 / 自动喂食器 / 温室",
[
Param("key", "str", True, None, "建筑标识:granary / well / feeder / greenhouse"),
],
lambda e, p: e.build(p["key"]),
)
)
# 注意:save / reset **刻意不进**这张表。它们是"存档类动作",多牧场环境下
# 必须由牧场会话(FarmSession)处理——engine.save() 只会写默认路径,
# 任何一个新入口直接 run_action("save") 都会覆盖别人的存档。
# 见 farm.server.app.execute_action 的拦截逻辑。
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