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

+35 -5
View File
@@ -24,7 +24,7 @@ import urllib.request
from farm import config, net
from farm.engine import GameError
from farm.server.daemon import DaemonManager, pid_alive, port_busy
from farm.server.daemon import DaemonManager, ensure_parent_dir, pid_alive, port_busy
from farm.server.hub import (
CLOSE_MESSAGE,
Client,
@@ -657,11 +657,16 @@ class BriefAndRulesTestCase(unittest.TestCase):
self.hub = ConnectionHub()
self.session = self.manager.get("alice")
def test_brief_keys_exist_in_the_snapshot(self):
"""回归:snapshot 改字段名时,brief 必须跟着改,否则动作回执直接 500。"""
snap = self.session.snapshot()
def test_brief_keys_exist_in_the_brief_payload(self):
"""回归:brief_source 改字段名时,BRIEF_KEYS 必须跟着改,否则动作回执直接 500。
BRIEF_KEYS 的数据源是 ``FarmEngine.brief_source()``(精简状态),
**不是**完整快照 ``session.snapshot()``——后者包含 market/plots 等大块数据,
拿它来断言会把"brief 只读精简字段"这个设计意图测丢。
"""
data = brief(self.session, self.hub)
for key in BRIEF_KEYS:
self.assertIn(key, snap, f"快照里没有 {key}")
self.assertIn(key, data, f"回执里没有 {key}")
def test_brief_exposes_the_to_do_lists(self):
self.session.engine.plant(1, "wheat")
@@ -805,6 +810,31 @@ class DaemonTestCase(unittest.TestCase):
self.assertEqual(len(lines), 5)
self.assertEqual(lines[-1], "第49行")
def test_ensure_parent_dir_tolerates_a_bare_file_name(self):
"""回归:路径没有目录部分时 dirname 返回空串,makedirs("") 直接崩。"""
ensure_parent_dir("server.pid") # 不该抛
ensure_parent_dir(os.path.join(self.dir, "a", "b", "server.pid"))
self.assertTrue(os.path.isdir(os.path.join(self.dir, "a", "b")))
def test_pid_file_without_a_directory_still_works(self):
"""``--pid-file server.pid`` 这种只给文件名的用法必须能起来。"""
manager = DaemonManager(
host="127.0.0.1",
port=free_port(),
save_dir=self.dir,
pid_file="server.pid",
log_file="server.log",
)
cwd = os.getcwd()
os.chdir(self.dir)
try:
manager.write_pid(4321)
self.assertEqual(manager.read_pid(), 4321)
manager.clear_pid()
self.assertIsNone(manager.read_pid())
finally:
os.chdir(cwd)
@unittest.skipUnless(HAS_TESTCLIENT, "未安装 httpx(TestClient 依赖它)")
class RestApiTestCase(unittest.TestCase):