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

+56 -36
View File
@@ -33,8 +33,10 @@ import json
import os
import sys
import time
from typing import Any
from farm import config
from farm.models import format_duration
DEFAULT_URL = "http://127.0.0.1:8765"
@@ -87,15 +89,21 @@ def die(message: str, hint: str = "", code: int = 1) -> int:
# ---------------------------------------------------------------------- 参数
def build_parser() -> argparse.ArgumentParser:
"""命令行总表:服务器那几个子命令 + 客户端那几个子命令。"""
parser = argparse.ArgumentParser(
prog="uv run main.py",
description="SimpleFarm:终端农场游戏 + 小型多人游戏服务器(HTTP/WebSocket 供 AI 调用)",
description="SimpleFarm:终端农场游戏 + 小型多人游戏服务器(HTTP + SSE 供 AI 调用)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=BANNER,
)
sub = parser.add_subparsers(dest="command", metavar="<命令>")
_add_server_parsers(sub)
_add_client_parsers(sub)
return parser
# ---- 服务器
def _add_server_parsers(sub) -> None:
"""serve / stop / status / logs —— 管服务器本身的那几个。"""
p_serve = sub.add_parser("serve", help="启动游戏服务器(可后台运行)")
p_serve.add_argument("--daemon", action="store_true", help="转到后台运行(守护进程)")
p_serve.add_argument(
@@ -127,29 +135,25 @@ def build_parser() -> argparse.ArgumentParser:
p_serve.add_argument("--pid-file", default=None, help="守护进程 pid 文件路径")
p_stop = sub.add_parser("stop", help="停止后台服务器(会先落盘存档)")
p_stop.add_argument("--port", type=int, default=8765)
p_stop.add_argument("--host", default="127.0.0.1")
p_stop.add_argument("--token", default=None)
_add_endpoint_args(p_stop)
p_stop.add_argument("--force", action="store_true", help="超时后强制结束进程")
p_status = sub.add_parser("status", help="查看服务器状态")
p_status.add_argument("--port", type=int, default=8765)
p_status.add_argument("--host", default="127.0.0.1")
p_status.add_argument("--token", default=None)
_add_endpoint_args(p_status)
p_status.add_argument("--logs", action="store_true", help="同时打印日志尾部")
p_status.add_argument("--json", action="store_true", help="输出机器可读的 JSON")
p_logs = sub.add_parser("logs", help="查看服务器日志尾部")
p_logs.add_argument("-n", "--lines", type=int, default=40)
# ---- 客户端
def _add_client_parsers(sub) -> None:
"""play / solo / do / batch / actions / state / log / watch / bot / farms / help。"""
p_play = sub.add_parser("play", help="打开终端界面(TUI)连接服务器玩")
p_play.add_argument("--url", default=DEFAULT_URL, help=f"服务器地址,默认 {DEFAULT_URL}")
p_play.add_argument("--farm", default="default", help="牧场名,默认 default")
p_play.add_argument("--token", default=None)
_add_client_args(p_play)
p_play.add_argument("--name", default="", help="在服务器上显示的昵称")
p_play.add_argument("--no-color", action="store_true")
p_play.add_argument("--no-realtime", action="store_true", help="不用 WebSocket,改走 HTTP 轮询")
p_play.add_argument("--no-realtime", action="store_true", help="不用 SSE 推送,改走 HTTP 轮询")
p_solo = sub.add_parser("solo", help="离线单机模式(本地引擎 + 终端界面,不开服务器)")
p_solo.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS)
@@ -188,6 +192,9 @@ def build_parser() -> argparse.ArgumentParser:
p_bot.add_argument("--rounds", type=int, default=0, help="最多执行多少轮,0 表示不限")
p_bot.add_argument("--minutes", type=float, default=0.0, help="最多跑多少分钟,0 表示不限")
p_bot.add_argument("--pause", type=float, default=0.25, help="每个动作之间的间隔秒数")
p_bot.add_argument(
"--wait", type=float, default=0.0, help="没事可做时最多等多少秒(等下一天),0 表示直接收工"
)
p_farms = sub.add_parser("farms", help="查看服务器上的牧场排行榜")
_add_client_args(p_farms)
@@ -195,15 +202,35 @@ def build_parser() -> argparse.ArgumentParser:
p_help = sub.add_parser("help", help="玩法与接口说明")
_add_client_args(p_help)
return parser
def _add_client_args(parser: argparse.ArgumentParser) -> None:
"""连服务器要用的三件套:地址、牧场名、令牌。"""
parser.add_argument("--url", default=DEFAULT_URL, help=f"服务器地址,默认 {DEFAULT_URL}")
parser.add_argument("--farm", default="default", help="牧场名,默认 default")
parser.add_argument("--token", default=None, help="访问令牌")
def _add_endpoint_args(parser: argparse.ArgumentParser) -> None:
"""只用来"找到那个服务器"的参数,stop / status 共用。"""
parser.add_argument("--host", default="127.0.0.1", help="服务器地址,默认 127.0.0.1")
parser.add_argument("--port", type=int, default=8765, help="服务器端口,默认 8765")
parser.add_argument("--token", default=None, help="访问令牌")
def _leaderboard_lines(rows: list[dict[str, Any]], limit: int = 0) -> list[str]:
"""排行榜表格行,``cmd_farms`` 与 ``cmd_status`` 共用。
列宽只在这里定义一次,加一列时不必去两个地方各调一次格式。
"""
header = f"{'#':>2} {'牧场':<14} {'净资产':>9} {'金币':>7} {'天':>4} {'等级':>4} {'在线':>4}"
selected = rows[:limit] if limit else rows
return [header] + [
f"{row['rank']:>2} {row['farm']:<14} {row['net_worth']:>9.0f} "
f"{row['gold']:>7} {row['day']:>4} {row['level']:>4} {row['online']:>4}"
for row in selected
]
# ---------------------------------------------------------------------- 客户端
def make_client(args, realtime: bool = False):
"""构造远程客户端;连不上时给出可执行的下一步建议。"""
@@ -304,11 +331,8 @@ def cmd_status(args) -> int:
world = manager.world()
if world and world.get("leaderboard"):
print(" 排行榜")
for row in world["leaderboard"][:5]:
print(
f" #{row['rank']} {row['farm']:<14} 净资产 {row['net_worth']:>8.0f}"
f" · 第 {row['day']} 天 · Lv.{row['level']} · 在线 {row['online']}"
)
for line in _leaderboard_lines(world["leaderboard"], limit=5):
print(" " + line)
if args.logs:
print(dim(" ---- 日志尾部 ----"))
@@ -458,7 +482,8 @@ def cmd_actions(args) -> int:
)
if not items:
print(warn("• 当前没有可执行的动作"))
print(dim(" 试试:uv run main.py do sleep (推进一天)"))
print(dim(" 多半是行动点用完了:时间走完一天就会恢复(默认一天 40 分钟)"))
print(dim(" 也可能是作物还没熟:uv run main.py state 看进度"))
return 0
print(f"共 {len(items)} 个候选动作:\n")
for index, item in enumerate(items, start=1):
@@ -481,7 +506,7 @@ def cmd_state(args) -> int:
print(
f"第 {snap.get('day')} 天"
+ (
f" · {snap.get('seconds_to_tick')}s 后进入下一天"
f" · {format_duration(snap.get('seconds_to_tick'))} 后进入下一天"
if snap.get("seconds_to_tick") is not None
else " · 已暂停"
)
@@ -555,7 +580,7 @@ def cmd_bot(args) -> int:
client = make_client(args, realtime=False)
bot = RuleBot(client, pause=args.pause, name=args.farm)
try:
summary = bot.run(rounds=args.rounds, max_minutes=args.minutes)
summary = bot.run(rounds=args.rounds, max_minutes=args.minutes, wait_seconds=args.wait)
except KeyboardInterrupt:
bot.stop()
print("\n已中断。")
@@ -573,12 +598,8 @@ def cmd_farms(args) -> int:
f"牧场 {world.get('farms')} 个 · 连接 {world.get('clients')} 个"
)
print(f"时钟:每 {world.get('tick_seconds')} 秒推进一天\n")
print(f" {'#':>2} {'牧场':<14} {'净资产':>9} {'金币':>7} {'天':>4} {'等级':>4} {'在线':>4}")
for row in world.get("leaderboard", []):
print(
f" {row['rank']:>2} {row['farm']:<14} {row['net_worth']:>9.0f} "
f"{row['gold']:>7} {row['day']:>4} {row['level']:>4} {row['online']:>4}"
)
for line in _leaderboard_lines(world.get("leaderboard", [])):
print(" " + line)
return 0
@@ -604,7 +625,6 @@ QUICKSTART = """
uv run ruff check . # 静态检查(未用导入、可疑写法……)
uv run ruff format . # 统一格式
uv run pyright # 类型检查
uv run python -m unittest discover -s tests # 全部测试
直接调接口(AI / 脚本)
-----------------------
@@ -618,12 +638,12 @@ QUICKSTART = """
uv run main.py state --json # 机器可读的完整状态
uv run main.py watch # 实时看别人/机器人玩
WebSocket(实时推送)
---------------------
ws://127.0.0.1:8765/ws?farm=default
服务器在状态变化时推送 {"type":"state", ...};
也可以在 WS 上直接发动作:{"type":"action","action":"plant",
"params":{"plot":1,"crop":"wheat"},"id":1},会收到 {"type":"result",...} 回执。
SSE 实时推送(只读)
--------------------
curl -N 'http://127.0.0.1:8765/api/stream?farm=default'
服务器在状态变化时推 {"type":"state", ...},每帧一行 data: JSON;
没有消息时每 15 秒发一行 ": keep-alive" 心跳。
写操作走 HTTP:POST /api/actions,两条路改的是同一个引擎。
"""