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

+3 -3
View File
@@ -30,6 +30,8 @@ EXPECTED_ACTIONS = {
"collect",
"buy_feed",
"buy_plot",
"sell",
"build",
"pause",
}
@@ -292,9 +294,7 @@ class HarvestAllTestCase(unittest.TestCase):
result = run_action(self.engine, "harvest_all", {})
self.assertEqual(result["plots"], [1, 2])
self.assertEqual(
result["quantity"], result["earned"] // config.CROPS["wheat"]["base_price"]
)
self.assertEqual(result["quantity"], result["value"] // config.CROPS["wheat"]["base_price"])
self.assertEqual(result["errors"], [])
self.assertFalse(self.engine.state.plots[2].is_empty, "没熟的 3 号地要保持原样")
+18 -2
View File
@@ -291,15 +291,31 @@ class RunTestCase(unittest.TestCase):
self.assertEqual(set(summary) >= {"name", "days", "actions", "failures", "net_worth"}, True)
def test_no_action_points_means_clocking_out(self):
# AP 用完时,服务端的 available_actions 只会剩下 0 消耗的动作
# (卖货/建造/收获/收取),这里模拟"连这些都没有"的极端情况:
# 机器人应当直接收工,而不是空转或乱发请求。
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
actions=[],
state={**BASE_STATE, "ap": 0},
catalog=CATALOG,
)
bot = make_bot(client)
summary = bot.run(rounds=5)
self.assertEqual(summary["actions"], 0)
self.assertEqual(client.calls, [], "没行动点就不该发动作请求")
self.assertEqual(client.calls, [], "没得做就不该发动作请求")
def test_free_actions_still_run_without_action_points(self):
# 新设计:收获不再直接给钱,得靠 sell 把谷仓里的货变现;而 sell/build
# 是 0 消耗动作,AP=0 时也必须能执行,否则一整天只能干等着。
client = FakeClient(
actions=[{"action": "sell", "params": {}}],
state={**BASE_STATE, "ap": 0},
catalog=CATALOG,
)
bot = make_bot(client)
summary = bot.run(rounds=1)
self.assertEqual(summary["actions"], 1)
self.assertEqual(client.calls, [("sell", {})])
def test_idle_limit_triggers_the_waiting_path(self):
client = FakeClient(actions=[], state=BASE_STATE)
+19 -14
View File
@@ -370,15 +370,17 @@ class ActionTestCase(unittest.TestCase):
self.assert_code("already_watered", self.engine.water, 2)
# ------------------------------------------------------------------ 收获
def test_harvest_pays_the_market_price(self):
def test_harvest_lands_in_the_granary_at_market_price(self):
self.engine.plant(1, "wheat")
self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"]
gold = self.engine.state.gold
with mock.patch("farm.engine.random.random", return_value=0.99):
result = self.engine.harvest(1)
self.assertEqual(result["quantity"], 1)
self.assertEqual(result["earned"], config.CROPS["wheat"]["base_price"])
self.assertEqual(self.engine.state.gold, gold + result["earned"])
self.assertEqual(result["price"], self.engine.market_price("wheat"))
self.assertEqual(result["value"], result["price"] * result["quantity"])
self.assertEqual(self.engine.state.inventory["wheat"], 1, "收成先进谷仓")
self.assertEqual(self.engine.state.gold, gold, "收获本身不给钱,卖了才给")
self.assertTrue(self.engine.state.plots[0].is_empty)
def test_lucky_harvest_doubles_the_yield(self):
@@ -387,7 +389,7 @@ class ActionTestCase(unittest.TestCase):
with mock.patch("farm.engine.random.random", return_value=0.0):
result = self.engine.harvest(1)
self.assertEqual(result["quantity"], 2)
self.assertEqual(result["earned"], config.CROPS["wheat"]["base_price"] * 2)
self.assertEqual(self.engine.state.inventory["wheat"], 2)
def test_harvest_before_ripe_is_refused(self):
self.engine.plant(1, "carrot")
@@ -458,15 +460,17 @@ class ActionTestCase(unittest.TestCase):
self.assert_code("bad_param", self.engine.feed, aid, 0)
self.assert_code("bad_param", self.engine.feed, aid, "many")
def test_collect_pays_for_every_pending_product(self):
def test_collect_lands_every_pending_product_in_the_granary(self):
self.engine.buy_animal("chicken")
animal = self.engine.state.animals[0]
animal.pending = 3
gold = self.engine.state.gold
result = self.engine.collect(animal.aid)
self.assertEqual(result["quantity"], 3)
self.assertEqual(result["earned"], config.ANIMALS["chicken"]["base_price"] * 3)
self.assertEqual(self.engine.state.gold, gold + result["earned"])
self.assertEqual(result["price"], self.engine.market_price("animal:chicken"))
self.assertEqual(result["value"], result["price"] * 3)
self.assertEqual(self.engine.state.inventory["animal:chicken"], 3, "产物先入库")
self.assertEqual(self.engine.state.gold, gold, "收取本身不给钱,卖了才给")
self.assertEqual(animal.pending, 0)
def test_collect_without_output_is_refused(self):
@@ -563,25 +567,26 @@ class GrowthTestCase(unittest.TestCase):
self.engine.plant(1, "carrot") # 3 天,刚好够观察缺水停摆
self.assertEqual(self.engine.state.plots[0].moisture, config.PLANT_INITIAL_MOISTURE)
self.engine.advance_day()
# 天气按天随机,这里钉死成晴天(每天蒸发 1 点),才观察得到"断水停摆"。
self.engine.advance_day(weather="sunny")
self.assertEqual(self.engine.state.plots[0].growth, 1)
self.engine.advance_day()
self.engine.advance_day(weather="sunny")
self.assertEqual(self.engine.state.plots[0].moisture, 0)
self.assertEqual(self.engine.state.plots[0].growth, 2)
events = self.engine.advance_day()
events = self.engine.advance_day(weather="sunny")
self.assertEqual(self.engine.state.plots[0].growth, 2, "没水就不该再长")
self.assertTrue(any("缺水" in text for text in events))
def test_watering_resumes_growth(self):
self.engine.plant(1, "carrot")
self.engine.advance_day()
self.engine.advance_day()
self.engine.advance_day() # 此刻缺水停摆
self.engine.advance_day(weather="sunny")
self.engine.advance_day(weather="sunny")
self.engine.advance_day(weather="sunny") # 此刻缺水停摆
self.assertEqual(self.engine.state.plots[0].growth, 2, "断水期间不该生长")
self.engine.water(1) # 浇到 3 点水
self.engine.advance_day()
self.engine.advance_day(weather="sunny")
self.assertEqual(self.engine.state.plots[0].growth, 3, "浇水后又开始长了")
self.assertTrue(self.engine.state.plots[0].is_ripe)
+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):
+6 -4
View File
@@ -455,9 +455,9 @@ class TuiLetterKeyBehaviourTestCase(unittest.TestCase):
self.assertEqual(self.engine.state.plots[0].moisture, config.WATER_CAPACITY)
self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"]
gold = self.engine.state.gold
before = self.engine.state.inventory.get("wheat", 0)
self.app._handle_key("h")
self.assertGreater(self.engine.state.gold, gold)
self.assertGreater(self.engine.state.inventory.get("wheat", 0), before, "收获进谷仓")
def test_e_harvests_everything_ripe(self):
for number in (1, 2, 3):
@@ -481,9 +481,11 @@ class TuiLetterKeyBehaviourTestCase(unittest.TestCase):
def test_c_collects_the_focused_animal(self):
self.engine.buy_animal("chicken")
self.engine.state.animals[0].pending = 2
gold = self.engine.state.gold
before = self.engine.state.inventory.get("animal:chicken", 0)
self.app._handle_key("c")
self.assertGreater(self.engine.state.gold, gold)
self.assertGreater(
self.engine.state.inventory.get("animal:chicken", 0), before, "产物进谷仓"
)
def test_l_buys_land_and_k_buys_feed(self):
self.engine.state.level = 2