feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径

三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。

实时通道:WebSocket -> SSE
- hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye,
  250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。
- 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 +
  客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。
- 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。

服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2
- 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式
  http="zttp",否则 Config.load() 直接 raise。
- 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖),
  不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。
- 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例
  「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。
- 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被
  except OSError 吞掉,导致每次正常退出都留 stale pid 文件。

节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」
- DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。
- engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉,
  时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。
- 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。

键盘:修复方向键/空格/Tab/ESC 全部静默失效
- keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串
  (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。
- 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。

测试:重建 500 项
- test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 /
  test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑,
  所以是全绿而不是靠跳过伪装。
- 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」
  (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要
  预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。

优化:先量后改,依据与前后数字都在 tools/bench.py
- 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化
  整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、
  GET /api/world -31%、POST /api/actions?full=1 -16%。
- 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、
  build_parser 与 available_actions 分组、__main__.main 160->7 行。
- 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的
  broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json;
  main.py 的 _add_endpoint_args/_leaderboard_lines。
- tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。

门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。
This commit is contained in:
snowy-yang committed 2026-09-19 12:55:45 +08:00
1 parent 47dd818360
commit f8e093c98f
36 files changed
+6865 -2204

No files matched your search

+332
View File
@@ -0,0 +1,332 @@
"""动作表测试:所有入口共用的那唯一一条执行通道。
动作表(``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",
"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["earned"] // 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)