Files
snowy-yang 15d290a60a 移除行动点系统,倒计时本地走秒,字体优化
为什么:游戏的时间(40 分钟一天、只能自然流动)本身就是唯一的行动节奏
阀门,再叠一层"每天 10 点行动点"纯属重复惩罚——浇水还要体力既不拟真
也不好玩。删掉之后,稀缺的只剩时间本身,放置玩法的定位反而更纯粹。

改动范围(引擎到界面连根拔):
- config 删 START_AP/AP_COST;models 删 ap 字段与 max_ap,存档 payload
  里的旧 ap 键被 from_dict 自然忽略;
- engine 删 _spend_ap 与全部 8 个调用点、每日恢复逻辑与日志;
  available_actions 不再按行动点筛,cost 只剩金币;
- storage 建表去掉 ap 列,老库在 connect() 里 ALTER TABLE DROP COLUMN
  无痛迁移(实测 saves/farm.db 已自动迁完);
- bot 删 AP 分支(无 AP 后 available_actions 恒可用);client/TUI/CLI/
  README/帮助文本同步清理。

网页端顺带两件事:
- "下一天"倒计时改为客户端本地走秒——SSE 只在有变化时推送,以前倒计时
  只在推送瞬间跳变,现在每秒都在动(记基点 + 1s 定时器补齐);
- 字体优化:页面字体栈补齐 MiSans/HarmonyOS Sans/PingFang,正文字号
  14→15,统计数字 tabular-nums;canvas 小字统一字体栈并加粗提对比度。

测试适配:删 2 个 AP 专属用例(无 AP 可测)、改名/收敛 5 个,
532 项全绿(skip 39),ruff/pyright 干净。真机验证:老库迁移、
统计条无行动点、倒计时逐秒递减(39:04→39:02)、动作不再报 no_ap。
2026-09-25 22:44:08 +08:00

332 lines
13 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.
"""动作表测试:所有入口共用的那唯一一条执行通道。
动作表(``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.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)