动手前先修掉了两处「死机制」,新增的三个系统各自激活一个: * 天气:小麦 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 项端到端验收,全过。
333 lines
13 KiB
Python
333 lines
13 KiB
Python
"""动作表测试:所有入口共用的那唯一一条执行通道。
|
||
|
||
动作表(``farm/server/actions.py``)是 HTTP、命令行、网页、机器人共同的分发点,
|
||
所以"参数校验只在这里做一次"这件事必须被钉住——校验漏一处,就等于每个入口都漏。
|
||
|
||
uv run python -m unittest tests.test_actions -v
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import unittest
|
||
|
||
from farm import config
|
||
from farm.engine import FarmEngine, GameError
|
||
from farm.server.actions import (
|
||
ACTIONS,
|
||
Param,
|
||
action_names,
|
||
describe_actions,
|
||
run_action,
|
||
)
|
||
|
||
EXPECTED_ACTIONS = {
|
||
"plant",
|
||
"water",
|
||
"harvest",
|
||
"harvest_all",
|
||
"buy_animal",
|
||
"feed",
|
||
"collect",
|
||
"buy_feed",
|
||
"buy_plot",
|
||
"sell",
|
||
"build",
|
||
"pause",
|
||
}
|
||
|
||
|
||
def make_engine() -> FarmEngine:
|
||
engine = FarmEngine()
|
||
engine.state.gold = 5000
|
||
return engine
|
||
|
||
|
||
class ParamTestCase(unittest.TestCase):
|
||
"""参数规格的取值转换——外部 JSON 什么类型都可能传进来。"""
|
||
|
||
def test_int_accepts_the_obvious_spellings(self):
|
||
spec = Param("plot", "int")
|
||
self.assertEqual(spec.coerce(3), 3)
|
||
self.assertEqual(spec.coerce("3"), 3)
|
||
self.assertEqual(spec.coerce(" 12 "), 12)
|
||
self.assertEqual(spec.coerce("-4"), -4)
|
||
self.assertEqual(spec.coerce("+7"), 7)
|
||
self.assertEqual(spec.coerce(3.0), 3)
|
||
|
||
def test_int_rejects_booleans_floats_and_words(self):
|
||
spec = Param("plot", "int")
|
||
for bad in (True, 3.5, "3.5", "三", "", None, [], {}):
|
||
with self.subTest(raw=bad):
|
||
with self.assertRaises(GameError) as ctx:
|
||
spec.coerce(bad)
|
||
self.assertEqual(ctx.exception.code, "bad_param")
|
||
|
||
def test_str_accepts_scalars(self):
|
||
spec = Param("crop", "str")
|
||
self.assertEqual(spec.coerce("wheat"), "wheat")
|
||
self.assertEqual(spec.coerce(123), "123")
|
||
self.assertEqual(spec.coerce(1.5), "1.5")
|
||
|
||
def test_str_rejects_containers(self):
|
||
spec = Param("crop", "str")
|
||
for bad in (["wheat"], {"a": 1}, None):
|
||
with self.subTest(raw=bad), self.assertRaises(GameError):
|
||
spec.coerce(bad)
|
||
|
||
def test_bool_accepts_common_spellings(self):
|
||
spec = Param("paused", "bool")
|
||
self.assertIs(spec.coerce(True), True)
|
||
self.assertIs(spec.coerce(False), False)
|
||
self.assertIs(spec.coerce("true"), True)
|
||
self.assertIs(spec.coerce("FALSE"), False)
|
||
self.assertIs(spec.coerce("1"), True)
|
||
self.assertIs(spec.coerce("0"), False)
|
||
self.assertIs(spec.coerce(1), True)
|
||
self.assertIs(spec.coerce(0), False)
|
||
|
||
def test_bool_rejects_ambiguous_words(self):
|
||
spec = Param("paused", "bool")
|
||
for bad in ("yes", "no", "暂停", "", [], 1.5):
|
||
with self.subTest(raw=bad):
|
||
with self.assertRaises(GameError) as ctx:
|
||
spec.coerce(bad)
|
||
self.assertEqual(ctx.exception.code, "bad_param")
|
||
|
||
def test_describe_matches_the_spec(self):
|
||
spec = Param("quantity", "int", required=False, default=10, desc="份数")
|
||
self.assertEqual(
|
||
spec.to_dict(),
|
||
{"name": "quantity", "type": "int", "required": False, "default": 10, "desc": "份数"},
|
||
)
|
||
|
||
|
||
class ActionTableTestCase(unittest.TestCase):
|
||
def test_the_table_holds_exactly_the_expected_actions(self):
|
||
self.assertEqual(set(action_names()), EXPECTED_ACTIONS)
|
||
|
||
def test_action_names_are_sorted(self):
|
||
names = action_names()
|
||
self.assertEqual(names, sorted(names))
|
||
|
||
def test_save_and_reset_are_deliberately_absent(self):
|
||
"""存档类动作必须由牧场会话处理,否则会写默认路径、覆盖别人的档。"""
|
||
self.assertNotIn("save", ACTIONS)
|
||
self.assertNotIn("reset", ACTIONS)
|
||
for name, spec in ACTIONS.items():
|
||
with self.subTest(action=name):
|
||
self.assertNotIn(name, ("save", "reset"))
|
||
self.assertNotEqual(spec.summary, "")
|
||
|
||
def test_removed_sleep_action_stays_removed(self):
|
||
"""「休息 / 手动推进一天」已经整体移除,别悄悄回来。"""
|
||
self.assertNotIn("sleep", ACTIONS)
|
||
self.assertNotIn("rest", ACTIONS)
|
||
self.assertNotIn("next_day", ACTIONS)
|
||
|
||
def test_describe_actions_is_json_shaped(self):
|
||
described = describe_actions()
|
||
self.assertEqual(len(described), len(ACTIONS))
|
||
for item in described:
|
||
with self.subTest(action=item["action"]):
|
||
self.assertEqual(set(item), {"action", "summary", "params"})
|
||
for param in item["params"]:
|
||
self.assertEqual(set(param), {"name", "type", "required", "default", "desc"})
|
||
|
||
def test_every_action_handler_returns_a_dict(self):
|
||
"""返回类型约定是 dict:HTTP 层直接把它铺进 JSON 响应。"""
|
||
engine = make_engine()
|
||
engine.state.level = 5
|
||
results = [
|
||
run_action(engine, "pause", {"paused": True}),
|
||
run_action(engine, "buy_feed", {"quantity": 1}),
|
||
run_action(engine, "plant", {"plot": 1, "crop": "wheat"}),
|
||
]
|
||
for result in results:
|
||
with self.subTest(result=result):
|
||
self.assertIsInstance(result, dict)
|
||
|
||
|
||
class RunActionValidationTestCase(unittest.TestCase):
|
||
"""统一执行通道的输入校验。"""
|
||
|
||
def setUp(self):
|
||
self.engine = make_engine()
|
||
|
||
def assert_code(self, code: str, name, params=None) -> GameError:
|
||
with self.assertRaises(GameError) as ctx:
|
||
run_action(self.engine, name, params)
|
||
self.assertEqual(ctx.exception.code, code)
|
||
return ctx.exception
|
||
|
||
def test_missing_or_blank_action_name(self):
|
||
for bad in (None, "", " ", 123, ["plant"]):
|
||
with self.subTest(name=bad):
|
||
self.assert_code("bad_param", bad)
|
||
|
||
def test_unknown_action_lists_the_valid_ones(self):
|
||
exc = self.assert_code("unknown_action", "fly_to_moon")
|
||
for name in EXPECTED_ACTIONS:
|
||
self.assertIn(name, exc.message)
|
||
|
||
def test_action_name_is_normalized(self):
|
||
result = run_action(self.engine, " PLANT ", {"plot": 1, "crop": "wheat"})
|
||
self.assertEqual(result["action"], "plant")
|
||
self.assertTrue(result["ok"])
|
||
|
||
def test_params_must_be_an_object(self):
|
||
for bad in ([1, 2], "plot=1", 3):
|
||
with self.subTest(params=bad):
|
||
self.assert_code("bad_param", "plant", bad)
|
||
|
||
def test_none_params_becomes_an_empty_mapping(self):
|
||
self.assert_code("missing_param", "plant", None)
|
||
|
||
def test_missing_required_parameter(self):
|
||
exc = self.assert_code("missing_param", "plant", {"plot": 1})
|
||
self.assertIn("crop", exc.message)
|
||
|
||
def test_unknown_parameter_is_rejected_not_ignored(self):
|
||
"""静默忽略拼错的参数,会让 AI 以为动作生效了。"""
|
||
exc = self.assert_code("bad_param", "plant", {"plot": 1, "crop": "wheat", "pot": 2})
|
||
self.assertIn("pot", exc.message)
|
||
|
||
def test_wrong_parameter_type(self):
|
||
self.assert_code("bad_param", "plant", {"plot": "第一块", "crop": "wheat"})
|
||
|
||
def test_null_parameter_counts_as_missing(self):
|
||
self.assert_code("missing_param", "plant", {"plot": None, "crop": "wheat"})
|
||
|
||
def test_optional_parameter_falls_back_to_its_default(self):
|
||
before = self.engine.state.feed
|
||
result = run_action(self.engine, "buy_feed", {})
|
||
self.assertEqual(self.engine.state.feed, before + config.FEED_BULK)
|
||
self.assertEqual(result["action"], "buy_feed")
|
||
|
||
def test_error_dict_is_machine_readable(self):
|
||
exc = self.assert_code("unknown_action", "nope")
|
||
payload = exc.to_dict()
|
||
self.assertEqual(payload, {"ok": False, "error": "unknown_action", "message": exc.message})
|
||
|
||
def test_result_always_carries_ok_and_action(self):
|
||
run_action(self.engine, "plant", {"plot": 1, "crop": "wheat"})
|
||
self.engine.state.plots[0].growth = 2
|
||
result = run_action(self.engine, "harvest_all", {})
|
||
self.assertIs(result["ok"], True)
|
||
self.assertEqual(result["action"], "harvest_all")
|
||
|
||
|
||
class ActionBehaviourTestCase(unittest.TestCase):
|
||
"""经过动作表之后的玩法行为——确认分发没有接错线。"""
|
||
|
||
def setUp(self):
|
||
self.engine = make_engine()
|
||
self.engine.state.level = 5
|
||
|
||
def test_plant_then_water_then_harvest(self):
|
||
run_action(self.engine, "plant", {"plot": 2, "crop": "carrot"})
|
||
plot = self.engine.state.plots[1]
|
||
self.assertEqual(plot.crop, "carrot")
|
||
self.assertEqual(plot.index, 1)
|
||
|
||
plot.moisture = 0
|
||
run_action(self.engine, "water", {"plot": 2})
|
||
self.assertEqual(plot.moisture, config.WATER_CAPACITY)
|
||
|
||
plot.growth = plot.grow_days
|
||
result = run_action(self.engine, "harvest", {"plot": 2})
|
||
self.assertEqual(result["action"], "harvest")
|
||
self.assertTrue(plot.is_empty)
|
||
|
||
def test_buy_and_feed_and_collect_an_animal(self):
|
||
bought = run_action(self.engine, "buy_animal", {"kind": "chicken"})
|
||
aid = bought["animal"]["id"]
|
||
|
||
fed = run_action(self.engine, "feed", {"animal_id": aid})
|
||
self.assertEqual(fed["animal"]["fed"], True)
|
||
|
||
self.engine.state.animals[0].pending = 2
|
||
collected = run_action(self.engine, "collect", {"animal_id": aid})
|
||
self.assertEqual(collected["quantity"], 2)
|
||
|
||
def test_feed_accepts_an_explicit_quantity(self):
|
||
bought = run_action(self.engine, "buy_animal", {"kind": "cow"})
|
||
aid = bought["animal"]["id"]
|
||
self.engine.state.feed = 20
|
||
run_action(self.engine, "feed", {"animal_id": aid, "quantity": "2"})
|
||
self.assertEqual(self.engine.state.feed, 18, "字符串数字要能转换")
|
||
|
||
def test_buy_plot_returns_the_next_price(self):
|
||
result = run_action(self.engine, "buy_plot", {})
|
||
self.assertEqual(result["next_plot_price"], self.engine.state.next_plot_price)
|
||
|
||
def test_pause_through_the_table(self):
|
||
self.assertEqual(run_action(self.engine, "pause", {"paused": "true"})["paused"], True)
|
||
self.assertEqual(run_action(self.engine, "pause", {"paused": False})["paused"], False)
|
||
self.assertEqual(
|
||
run_action(self.engine, "pause", {})["paused"],
|
||
True,
|
||
"省略参数表示反转,此时是 False -> True",
|
||
)
|
||
|
||
def test_pause_rejects_values_that_are_not_boolean(self):
|
||
"""参数校验在前:含糊的字符串会被挡在门口,而不是当成 true。"""
|
||
with self.assertRaises(GameError) as ctx:
|
||
run_action(self.engine, "pause", {"paused": "不确定算不算"})
|
||
self.assertEqual(ctx.exception.code, "bad_param")
|
||
|
||
|
||
class HarvestAllTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.engine = make_engine()
|
||
self.engine.state.level = 5
|
||
|
||
def test_nothing_ripe_is_an_error(self):
|
||
with self.assertRaises(GameError) as ctx:
|
||
run_action(self.engine, "harvest_all", {})
|
||
self.assertEqual(ctx.exception.code, "nothing_to_harvest")
|
||
|
||
def test_harvests_every_ripe_plot_and_skips_the_rest(self):
|
||
for number in (1, 2, 3):
|
||
run_action(self.engine, "plant", {"plot": number, "crop": "wheat"})
|
||
for index in (0, 1):
|
||
self.engine.state.plots[index].growth = 2 # 1、2 号成熟
|
||
result = run_action(self.engine, "harvest_all", {})
|
||
|
||
self.assertEqual(result["plots"], [1, 2])
|
||
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 号地要保持原样")
|
||
|
||
def test_partial_failure_does_not_stop_the_others(self):
|
||
"""一块地失败不能带走其它地块的收成。"""
|
||
for number in (1, 2):
|
||
run_action(self.engine, "plant", {"plot": number, "crop": "wheat"})
|
||
for index in (0, 1):
|
||
self.engine.state.plots[index].growth = 2
|
||
self.engine.state.ap = 10
|
||
|
||
# 把第一块地做成"看起来熟了、实际收不了"(作物键被改坏)
|
||
self.engine.state.plots[0].crop = "wheat"
|
||
original = self.engine.harvest
|
||
|
||
def flaky(number):
|
||
if int(number) == 1:
|
||
raise GameError("plot_empty", "1 号地是空地")
|
||
return original(number)
|
||
|
||
self.engine.harvest = flaky # type: ignore[method-assign]
|
||
result = run_action(self.engine, "harvest_all", {})
|
||
self.assertEqual(result["plots"], [2])
|
||
self.assertEqual(len(result["errors"]), 1)
|
||
self.assertIn("1号地", result["errors"][0])
|
||
|
||
def test_message_summarizes_the_haul(self):
|
||
run_action(self.engine, "plant", {"plot": 1, "crop": "wheat"})
|
||
self.engine.state.plots[0].growth = 2
|
||
result = run_action(self.engine, "harvest_all", {})
|
||
self.assertIn("1 块地", result["message"])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main(verbosity=2)
|