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:
1 parent
47dd818360
commit
f8e093c98f
36 files changed
+6865
-2204
No files matched your search
@@ -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,两条路改的是同一个引擎。
|
||||
"""
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user