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 项端到端验收,全过。
This commit is contained in:
snowy-yang committed 2026-09-19 17:02:33 +08:00
1 parent a92c29ead2
commit cf24cb43f1
21 files changed
+1349 -112

No files matched your search

+55
View File
@@ -230,6 +230,38 @@ class StateView:
def market(self) -> dict[str, float]:
return self._d.get("market", {})
@property
def weather(self) -> str:
return self._d.get("weather", config.DEFAULT_WEATHER)
@property
def weather_name(self) -> str:
return self._d.get("weather_name") or config.weather_info(self.weather)["name"]
@property
def forecast(self) -> str:
return self._d.get("forecast", config.DEFAULT_WEATHER)
@property
def forecast_name(self) -> str:
return self._d.get("forecast_name") or config.weather_info(self.forecast)["name"]
@property
def inventory(self) -> dict[str, int]:
return self._d.get("inventory", {})
@property
def storage_used(self) -> int:
return int(self._d.get("storage_used", 0))
@property
def granary_capacity(self) -> int:
return int(self._d.get("granary_capacity", config.GRANARY_BASE))
@property
def buildings(self) -> dict[str, int]:
return self._d.get("buildings", {})
def get(self, key: str, default: Any = None) -> Any:
return self._d.get(key, default)
@@ -277,6 +309,10 @@ class RemoteFarm:
self._catalog: dict[str, Any] = {
"crops": [],
"animals": [],
"goods": [],
"buildings": [],
"weather": {},
"granary": {},
"feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY,
"ap_cost": config.AP_COST,
@@ -389,6 +425,13 @@ class RemoteFarm:
self._apply(state)
def snapshot(self) -> dict[str, Any]:
"""本地缓存的状态视图,**不会发请求**。
TUI 每帧都要读它,所以刻意做成纯本地读取;代价是它只反映"上一次同步"
的结果。开实时通道(``realtime=True``,TUI / 网页走这条)时 SSE 会持续
刷新它;关掉实时通道的脚本要自己调 :meth:`refresh`,否则轮询到的
永远是同一份旧数据——一天天推进的服务器看起来就像卡住了。
"""
return self.state.to_dict()
def catalog(self) -> dict[str, Any]:
@@ -480,6 +523,18 @@ class RemoteFarm:
def buy_plot(self) -> dict[str, Any]:
return self._action("buy_plot")
def sell(self, key: Any = None, quantity: Any = None) -> dict[str, Any]:
"""卖出谷仓存货;两个参数都省略就是清仓。"""
params: dict[str, Any] = {}
if key is not None:
params["key"] = key
if quantity is not None:
params["quantity"] = quantity
return self._action("sell", params)
def build(self, key: Any) -> dict[str, Any]:
return self._action("build", {"key": key})
def pause(self, paused: bool | None = None) -> dict[str, Any]:
# 动作回执里的 state(brief)已带 paused 与 seconds_to_tick,
# _action 内部会合并进本地视图,这里只需转发参数