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
@@ -5,10 +5,10 @@
|
||||
|
||||
模块划分
|
||||
--------
|
||||
* :mod:`farm.server.app` —— FastAPI 应用(REST + WebSocket)
|
||||
* :mod:`farm.server.app` —— FastAPI 应用(REST + SSE)
|
||||
* :mod:`farm.server.actions` —— 动作表:所有入口共用的唯一执行通道
|
||||
* :mod:`farm.server.session` —— 多牧场会话与统一时钟
|
||||
* :mod:`farm.server.hub` —— WebSocket 连接池与状态推送
|
||||
* :mod:`farm.server.hub` —— SSE 订阅池与状态推送
|
||||
* :mod:`farm.server.daemon` —— 守护进程(start/stop/status)
|
||||
* :mod:`farm.server.webui` —— 内嵌网页客户端
|
||||
|
||||
|
||||
+196
-63
@@ -1,5 +1,14 @@
|
||||
"""``uv run python -m farm.server`` —— 服务器进程入口(前台运行)。
|
||||
|
||||
ASGI 服务器用 uvicorn,并打开它的 **HTTP/2** 支持(``http="zttp"`` +
|
||||
``http2=True``):同一个端口既说 HTTP/1.1 也说 HTTP/2(TLS 走 ALPN,明文走
|
||||
h2c prior-knowledge),`curl`、浏览器、AI 脚本各取所需,应用代码一行都不用改。
|
||||
|
||||
为什么 HTTP/2 要选 ``zttp``:uvicorn 自己不再内置 h2,它的 HTTP/2 实现建立在
|
||||
``zttp`` 这个包上(见 ``uvicorn/protocols/http/zttp_h2_impl.py``)。装上之后
|
||||
``AutoZttpProtocol`` 会在一条监听上自动分流两种协议,所以选它**不会**丢掉
|
||||
HTTP/1.1 能力。
|
||||
|
||||
守护进程模式会由 :class:`farm.server.daemon.DaemonManager` 用同样的命令行
|
||||
再拉起一个本模块的实例,只是不带 ``--daemon``。
|
||||
"""
|
||||
@@ -7,18 +16,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from types import FrameType
|
||||
|
||||
from .. import config
|
||||
from .daemon import DEFAULT_LOG_FILE, DEFAULT_PID_FILE, PROJECT_ROOT, DaemonManager
|
||||
from .daemon import (
|
||||
DEFAULT_LOG_FILE,
|
||||
DEFAULT_PID_FILE,
|
||||
PROJECT_ROOT,
|
||||
DaemonManager,
|
||||
port_busy,
|
||||
)
|
||||
|
||||
# 优雅停机的上限(秒)。SSE 是**永不自己结束**的长连接,停机时我们会先用
|
||||
# hub.close_all() 让每个流自己收尾;这个值只是兜底:万一有客户端赖着不走
|
||||
# (比如浏览器标签页没关),到点就取消剩下的连接任务,绝不让 stop 卡死。
|
||||
GRACE_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="uv run python -m farm.server",
|
||||
description="SimpleFarm 游戏服务器:HTTP 接口给 AI,WebSocket 给实时界面。",
|
||||
description="SimpleFarm 游戏服务器:HTTP 接口给 AI/脚本,SSE 给实时界面。",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -68,40 +88,98 @@ def dependency_hint() -> str:
|
||||
"缺少运行服务器所需的依赖。请先安装:\n"
|
||||
" uv sync\n"
|
||||
"如果不想用 uv,也可以用 pip:\n"
|
||||
" pip install fastapi 'uvicorn[standard]'\n"
|
||||
" pip install 'uvicorn[standard]' fastapi zttp\n"
|
||||
f"\n当前解释器:{sys.executable}"
|
||||
)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
def clear_pid_file(path: str | None) -> None:
|
||||
"""删掉自己写的 pid 文件,避免下次 status 看到"幽灵进程"。
|
||||
|
||||
只删内容确实是本进程 pid 的那个:万一文件已经被别的实例接管,
|
||||
删掉它等于把别人的记录抹了。
|
||||
|
||||
顺序有讲究:必须**先读完、关掉句柄,再 os.remove**。Windows 上删一个还开着的
|
||||
文件会抛 ``PermissionError``(WinError 32),而它会被下面的 ``except OSError``
|
||||
吞掉——结果就是 pid 文件永远留在磁盘上(这个坑在本项目里真实存在过,
|
||||
做端到端停机验证时才被发现)。
|
||||
"""
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
content = handle.read().strip()
|
||||
if content == str(os.getpid()):
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_save_dir(args) -> str:
|
||||
"""存档目录:显式 --save-dir 优先,其次老参数 --save 所在目录,最后项目根/saves。"""
|
||||
save_dir = args.save_dir or os.path.join(PROJECT_ROOT, "saves")
|
||||
if args.save_file:
|
||||
save_dir = os.path.dirname(os.path.abspath(args.save_file)) or save_dir
|
||||
return save_dir
|
||||
|
||||
if args.daemon:
|
||||
manager = DaemonManager(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
token=args.token,
|
||||
tick=clamp_tick(args.tick),
|
||||
save_dir=save_dir,
|
||||
pid_file=args.pid_file or DEFAULT_PID_FILE,
|
||||
log_file=args.log_file or DEFAULT_LOG_FILE,
|
||||
)
|
||||
ok, message = manager.start()
|
||||
print(message)
|
||||
return 0 if ok else 1
|
||||
|
||||
def _start_daemon(args, save_dir: str) -> int:
|
||||
"""把服务丢到后台,父进程立刻返回(真正在监听的是子进程)。"""
|
||||
manager = DaemonManager(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
token=args.token,
|
||||
tick=clamp_tick(args.tick),
|
||||
save_dir=save_dir,
|
||||
pid_file=args.pid_file or DEFAULT_PID_FILE,
|
||||
log_file=args.log_file or DEFAULT_LOG_FILE,
|
||||
)
|
||||
ok, message = manager.start()
|
||||
print(message)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _print_startup_banner(cfg, save_dir: str) -> None:
|
||||
print(f"SimpleFarm 服务器启动中(pid {os.getpid()})…")
|
||||
print(f" 监听 http://{cfg.host}:{cfg.port}/")
|
||||
print(f" 存档目录 {save_dir}")
|
||||
print(f" 实时通道 http://{cfg.host}:{cfg.port}/api/stream?farm=<牧场名>(SSE)")
|
||||
print(" ASGI uvicorn(同一端口协商 HTTP/1.1 与 HTTP/2,HTTP/2 由 zttp 提供)")
|
||||
if cfg.token:
|
||||
print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数")
|
||||
|
||||
|
||||
def _ensure_port_free(host: str, port: int) -> int | None:
|
||||
"""端口自检:可用返回 ``None``,被别人占着则打印提示并返回退出码 3。
|
||||
|
||||
必须用"能不能连上"来判断,而不是"能不能 bind":Windows 上只要两边都设了
|
||||
SO_REUSEADDR(uvicorn 会设),第二个进程也能在已被监听的端口上"绑定成功"
|
||||
(然后抢走新连接),于是静默把第一个实例顶掉——这种事故比启动失败难查得多。
|
||||
"""
|
||||
if not port_busy(host, port):
|
||||
return None
|
||||
print(
|
||||
f"启动失败:{host}:{port} 已经有程序在监听(可能是另一个 SimpleFarm 实例)。\n"
|
||||
f" 换一个端口试试:--port {port + 1}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
|
||||
def _run_foreground(args, save_dir: str) -> int:
|
||||
"""在本进程里把 ASGI 服务器跑到退出,返回进程退出码。"""
|
||||
# uvicorn 只在真的要跑服务时才需要——`main.py solo` 这类离线玩法不该被迫装它,
|
||||
# 所以延迟到这一步才导入,失败时给出可照抄的安装命令。
|
||||
try:
|
||||
import uvicorn
|
||||
from uvicorn import Config as UvicornConfig
|
||||
from uvicorn import Server as UvicornServer
|
||||
except ImportError:
|
||||
print(dependency_hint(), file=sys.stderr)
|
||||
return 2
|
||||
# 只探测"在不在",不真的导入——uvicorn 需要它才能完成 WebSocket 协议升级
|
||||
ws_ok = importlib.util.find_spec("websockets") is not None
|
||||
|
||||
from .app import ServerConfig, create_app
|
||||
from .app import ServerConfig, ServerHandle, as_asgi, create_app
|
||||
|
||||
cfg = ServerConfig(
|
||||
host=args.host,
|
||||
@@ -114,67 +192,122 @@ def main(argv=None) -> int:
|
||||
world_name=args.world,
|
||||
allow_remote_control=not args.no_remote_control,
|
||||
)
|
||||
app = create_app(cfg)
|
||||
# 先看端口有没有人占着。
|
||||
busy_code = _ensure_port_free(cfg.host, cfg.port)
|
||||
if busy_code is not None:
|
||||
return busy_code
|
||||
|
||||
server_config = uvicorn.Config(
|
||||
app,
|
||||
app = create_app(cfg)
|
||||
handle = app.state.handle
|
||||
|
||||
uv_config = UvicornConfig(
|
||||
as_asgi(app),
|
||||
host=cfg.host,
|
||||
port=cfg.port,
|
||||
log_level=args.log_level,
|
||||
access_log=args.access_log,
|
||||
loop="asyncio",
|
||||
lifespan="on",
|
||||
# HTTP/2 必须在配置里显式打开:http="zttp" 选出 AutoZttpProtocol,
|
||||
# http2=True 才让它真的启用 h2(uvicorn 只认 zttp 这个后端)。
|
||||
http="zttp",
|
||||
http2=True,
|
||||
# 与 graceful shutdown 的语义一致:等连接自己收尾的最长时间
|
||||
timeout_graceful_shutdown=int(GRACE_TIMEOUT),
|
||||
)
|
||||
server = uvicorn.Server(server_config)
|
||||
# 让 POST /api/admin/shutdown 能触发优雅退出(lifespan 里会把存档写掉)
|
||||
app.state.handle.on_stop(lambda: setattr(server, "should_exit", True))
|
||||
|
||||
# 服务器子类就地定义:覆盖 handle_exit 得继承 uvicorn.Server,而 uvicorn 是
|
||||
# 延迟导入的(见上),写在模块顶层会让离线玩法也依赖它。
|
||||
class FarmServer(UvicornServer):
|
||||
"""uvicorn 服务器 + SimpleFarm 的停机语义。
|
||||
|
||||
唯一改动是 :meth:`handle_exit`(uvicorn 通过 ``capture_signals`` 把它接到
|
||||
SIGINT / SIGTERM / SIGBREAK 上)。默认实现只置 ``should_exit``,那样 SSE
|
||||
长连接收不到 ``bye``,只能干等 ``timeout_graceful_shutdown`` 到点被强杀——
|
||||
实测白等满 5 秒。这里改成先 ``handle.request_stop()``:每个流立刻自己收尾,
|
||||
再由 ``on_stop`` 回调置位 ``should_exit``。于是「接口停机」与「信号停机」
|
||||
走的是同一条路径。
|
||||
"""
|
||||
|
||||
# 由下面在实例化之后注入(放在这里是为了不动基类 __init__ 的签名)
|
||||
handle: ServerHandle
|
||||
|
||||
def __init__(self, config: UvicornConfig) -> None:
|
||||
super().__init__(config)
|
||||
self._interrupts = 0
|
||||
|
||||
def handle_exit(self, sig: int, frame: FrameType | None) -> None:
|
||||
self._interrupts += 1
|
||||
if self._interrupts > 1:
|
||||
# 第二次信号:用户不想再等了,放弃优雅收尾,直接退
|
||||
print("\n再次收到中断信号,强制退出。", file=sys.stderr, flush=True)
|
||||
os._exit(130)
|
||||
# 故意不调 super():uvicorn 的版本只置 should_exit,而我们的停机钩子
|
||||
# 会做同样的事,还要额外让 SSE 流主动收尾。另外保持 _captured_signals
|
||||
# 为空,退出时就不会把信号重放一遍。
|
||||
self.handle.request_stop()
|
||||
|
||||
server = FarmServer(uv_config)
|
||||
server.handle = handle
|
||||
|
||||
# 两条停机路径汇到同一个地方:
|
||||
# • POST /api/admin/shutdown → handle.request_stop()
|
||||
# • Ctrl+C / SIGTERM / SIGBREAK → FarmServer.handle_exit → handle.request_stop()
|
||||
# request_stop() 既让 SSE 流自己收尾(生成器看 handle.stopping),
|
||||
# 也经由这个回调把 should_exit 置位,uvicorn 于是开始优雅退出。
|
||||
handle.on_stop(lambda: setattr(server, "should_exit", True))
|
||||
|
||||
# pid 文件由**本进程**写:父进程(守护进程管理器)拿到的 pid 未必是真正
|
||||
# 在监听端口的那个(Windows 上 venv 的 python.exe 可能是转发器)。
|
||||
# uvicorn 的顺序是"先跑 lifespan(也就是这个钩子)、后 bind 端口",
|
||||
# 所以这里只保证"服务已启动",不保证"端口已监听";万一 bind 失败,
|
||||
# 下面的 finally 会把 pid 文件删掉,不留幽灵。
|
||||
def write_pid_file() -> None:
|
||||
if not args.pid_file:
|
||||
return
|
||||
try:
|
||||
os.makedirs(os.path.dirname(args.pid_file) or ".", exist_ok=True)
|
||||
with open(args.pid_file, "w", encoding="utf-8") as handle:
|
||||
handle.write(str(os.getpid()))
|
||||
with open(args.pid_file, "w", encoding="utf-8") as file_handle:
|
||||
file_handle.write(str(os.getpid()))
|
||||
except OSError as exc:
|
||||
print(f"⚠ 无法写入 pid 文件 {args.pid_file}:{exc}", file=sys.stderr)
|
||||
|
||||
app.state.handle.on_start(write_pid_file)
|
||||
handle.on_start(write_pid_file)
|
||||
|
||||
print(f"SimpleFarm 服务器启动中(pid {os.getpid()})…")
|
||||
print(f" 监听 http://{cfg.host}:{cfg.port}/")
|
||||
print(f" 存档目录 {save_dir}")
|
||||
print(f" 实时通道 ws://{cfg.host}:{cfg.port}/ws?farm=<牧场名>")
|
||||
if not ws_ok:
|
||||
_print_startup_banner(cfg, save_dir)
|
||||
|
||||
status = 0
|
||||
try:
|
||||
server.run()
|
||||
except OSError as exc:
|
||||
print(
|
||||
" ⚠ 未检测到 websockets 库,/ws 实时通道可能不可用;"
|
||||
"网页端会自动降级为轮询。安装:pip install 'uvicorn[standard]'"
|
||||
)
|
||||
if cfg.token:
|
||||
print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数")
|
||||
|
||||
server.run()
|
||||
|
||||
# 收尾:把自己写的 pid 文件清掉,避免下次 status 看到"幽灵进程"
|
||||
if args.pid_file:
|
||||
try:
|
||||
if os.path.exists(args.pid_file):
|
||||
with open(args.pid_file, encoding="utf-8") as handle:
|
||||
if handle.read().strip() == str(os.getpid()):
|
||||
os.remove(args.pid_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not getattr(server, "started", False):
|
||||
print(
|
||||
f"启动失败:端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}",
|
||||
f"启动失败:{exc}\n"
|
||||
f" 端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
print("服务器已停止。")
|
||||
return 0
|
||||
status = 3
|
||||
except SystemExit as exc:
|
||||
# uvicorn 的 lifespan 跑在 bind 之前,监听失败时它自己 sys.exit(3)
|
||||
code = exc.code
|
||||
status = code if isinstance(code, int) and code else 3
|
||||
print(
|
||||
f"启动失败:无法监听 {cfg.host}:{cfg.port}(细节见上面的日志)。\n"
|
||||
f" 换一个端口试试:--port {cfg.port + 1}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
finally:
|
||||
clear_pid_file(args.pid_file)
|
||||
|
||||
if status == 0:
|
||||
print("服务器已停止。")
|
||||
return status
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
"""入口:要么转后台(父进程立刻返回),要么就在本进程里把服务跑起来。"""
|
||||
args = build_parser().parse_args(argv)
|
||||
save_dir = _resolve_save_dir(args)
|
||||
if args.daemon:
|
||||
return _start_daemon(args, save_dir)
|
||||
return _run_foreground(args, save_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+6
-16
@@ -1,11 +1,11 @@
|
||||
"""统一的动作表:HTTP、WebSocket、命令行、机器人都走这一份映射。
|
||||
"""统一的动作表:HTTP、命令行、机器人都走这一份映射。
|
||||
|
||||
为什么要有这一层
|
||||
----------------
|
||||
引擎(``farm.engine.FarmEngine``)提供的是**方法**,但外部调用者(AI Agent、
|
||||
脚本、网页)手里拿到的只有字符串动作名 + 参数字典。如果让每个入口各自写一遍
|
||||
``if action == "plant"`` 的分发,规则就会散落在三四个文件里,迟早出现
|
||||
"HTTP 能做、WS 不能做" 这种不一致。
|
||||
"HTTP 能做、命令行不能做" 这种不一致。
|
||||
|
||||
所以这里集中定义:动作名 → 参数规格 → 引擎调用 → 返回结果。
|
||||
新增一个玩法动作,只需要在这张表里加一行。
|
||||
@@ -128,14 +128,6 @@ def _harvest_all(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _save(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
|
||||
return engine.save()
|
||||
|
||||
|
||||
def _reset(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
|
||||
return engine.reset()
|
||||
|
||||
|
||||
def _pause(engine: FarmEngine, params: dict[str, Any]) -> dict[str, Any]:
|
||||
value = params.get("paused")
|
||||
return engine.pause(value if isinstance(value, bool) else None)
|
||||
@@ -232,8 +224,6 @@ _register(
|
||||
|
||||
_register(Action("buy_plot", "花钱开垦一块新土地", [], lambda e, p: e.buy_plot()))
|
||||
|
||||
_register(Action("sleep", "主动推进一天(有冷却)", [], lambda e, p: e.sleep()))
|
||||
|
||||
_register(
|
||||
Action(
|
||||
"pause",
|
||||
@@ -245,11 +235,11 @@ _register(
|
||||
)
|
||||
)
|
||||
|
||||
_register(Action("save", "把当前农场写入存档文件", [], _save))
|
||||
|
||||
_register(Action("reset", "重置农场,从头开始经营", [], _reset))
|
||||
|
||||
|
||||
# 注意:save / reset **刻意不进**这张表。它们是"存档类动作",多牧场环境下
|
||||
# 必须由牧场会话(FarmSession)处理——engine.save() 只会写默认路径,
|
||||
# 任何一个新入口直接 run_action("save") 都会覆盖别人的存档。
|
||||
# 见 farm.server.app.execute_action 的拦截逻辑。
|
||||
def action_names() -> list[str]:
|
||||
return sorted(ACTIONS)
|
||||
|
||||
|
||||
+357
-355
@@ -1,4 +1,4 @@
|
||||
"""FastAPI 应用:REST + WebSocket + 网页客户端。
|
||||
"""FastAPI 应用:REST + SSE + 网页客户端。
|
||||
|
||||
接口设计原则
|
||||
------------
|
||||
@@ -8,8 +8,9 @@
|
||||
这样 AI 一次请求就能同时"做动作 + 看结果",省一半往返。
|
||||
3. **多牧场隔离**:用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家,
|
||||
每个牧场有自己的引擎、时钟与存档;互不影响。
|
||||
4. **实时通道**:``/ws`` 推送状态,人和 AI 都能订阅;HTTP 与 WS 改的是
|
||||
同一个引擎,所以「网页里点一下,AI 那边立刻看到」。
|
||||
4. **实时通道**:``GET /api/stream`` 是 SSE 长连接,只负责**服务器 → 客户端**
|
||||
的推送(状态、跨天、有人来串门);写操作统一走 ``POST /api/actions``。
|
||||
两者改的是同一个引擎,所以「网页里点一下,AI 那边立刻看到」。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,13 +23,23 @@ from collections.abc import AsyncIterator, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
|
||||
|
||||
from .. import config
|
||||
from ..engine import GameError
|
||||
from . import actions as actions_mod
|
||||
from .hub import Client, ConnectionHub, Publisher, next_seq
|
||||
from .hub import (
|
||||
CLOSE_MESSAGE,
|
||||
HEARTBEAT_SECONDS,
|
||||
IDLE_TICK,
|
||||
Client,
|
||||
ConnectionHub,
|
||||
Publisher,
|
||||
next_seq,
|
||||
sse_frame,
|
||||
sse_heartbeat,
|
||||
)
|
||||
from .session import DEFAULT_FARM, FarmClock, FarmSession, SessionManager, validate_farm_id
|
||||
from .webui import DASHBOARD_HTML, dashboard_text
|
||||
|
||||
@@ -56,8 +67,8 @@ class ServerConfig:
|
||||
class ServerHandle:
|
||||
"""让 HTTP 接口能够优雅地要求本进程退出,并暴露进程生命周期的钩子。
|
||||
|
||||
* ``on_start``:监听端口成功后触发(用于写 pid 文件等);
|
||||
* ``on_stop``:请求关闭时触发(uvicorn 用它设置 ``should_exit``)。
|
||||
* ``on_start``:ASGI 生命周期启动时触发(用于写 pid 文件等);
|
||||
* ``on_stop``:请求关闭时触发(uvicorn 用它把 ``should_exit`` 置位,开始优雅退出)。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -89,43 +100,56 @@ class ServerHandle:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- 工具
|
||||
# brief() 从精简状态里挑选的字段白名单:够 AI 做下一步决策,又不至于太吵。
|
||||
# 数据源是 FarmEngine.brief_source()(**不是**完整快照),加字段时两处一起改。
|
||||
BRIEF_KEYS = (
|
||||
"day",
|
||||
"gold",
|
||||
"level",
|
||||
"exp",
|
||||
"exp_needed",
|
||||
"ap",
|
||||
"max_ap",
|
||||
"feed",
|
||||
"plot_count",
|
||||
"max_plots",
|
||||
"next_plot_price",
|
||||
"animal_count",
|
||||
"max_animals",
|
||||
"net_worth",
|
||||
"goal_index",
|
||||
"seconds_to_tick",
|
||||
"paused",
|
||||
)
|
||||
|
||||
# 特意为 AI 准备的待办清单:不用遍历整个快照就知道该干什么
|
||||
BRIEF_TODO_KEYS = ("ripe_plots", "hungry_animals", "collectable_animals")
|
||||
|
||||
|
||||
def brief(session: FarmSession, hub: ConnectionHub) -> dict[str, Any]:
|
||||
"""操作后回给调用者的精简状态:够做下一步决策,又不至于太吵。"""
|
||||
snap = session.snapshot()
|
||||
return {
|
||||
"farm": session.farm_id,
|
||||
"day": snap["day"],
|
||||
"gold": snap["gold"],
|
||||
"level": snap["level"],
|
||||
"exp": snap["exp"],
|
||||
"exp_needed": snap["exp_needed"],
|
||||
"ap": snap["ap"],
|
||||
"max_ap": snap["max_ap"],
|
||||
"feed": snap["feed"],
|
||||
"plot_count": snap["plot_count"],
|
||||
"max_plots": snap["max_plots"],
|
||||
"next_plot_price": snap["next_plot_price"],
|
||||
"animal_count": snap["animal_count"],
|
||||
"max_animals": snap["max_animals"],
|
||||
"ripe_plots": [p["position"] for p in snap["plots"] if p["is_ripe"]],
|
||||
"hungry_animals": [a["id"] for a in snap["animals"] if not a["fed"]],
|
||||
"collectable_animals": [a["id"] for a in snap["animals"] if a["pending"] > 0],
|
||||
"net_worth": snap["net_worth"],
|
||||
"goal_index": snap["goal_index"],
|
||||
"seconds_to_tick": snap["seconds_to_tick"],
|
||||
"paused": snap["paused"],
|
||||
"online": hub.online(session.farm_id),
|
||||
}
|
||||
"""操作后回给调用者的精简状态。
|
||||
|
||||
只读引擎直接给出的那十几个标量,**不做完整快照**。以前这里是
|
||||
``session.snapshot()`` 之后再挑字段——每块地、每只牲畜都白白序列化一遍,
|
||||
实测每次动作浪费约 60µs;而 ``full=1`` 时后面还要再序列化一次给快照用。
|
||||
"""
|
||||
source = session.engine.brief_source()
|
||||
data: dict[str, Any] = {"farm": session.farm_id}
|
||||
data.update({key: source[key] for key in BRIEF_KEYS})
|
||||
data.update({key: source[key] for key in BRIEF_TODO_KEYS})
|
||||
data["online"] = hub.online(session.farm_id)
|
||||
return data
|
||||
|
||||
|
||||
def rules_text() -> str:
|
||||
return (
|
||||
f"{SERVER_NAME} 规则速览(给 AI 玩家的上下文)\n"
|
||||
"==========================================\n"
|
||||
f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS:.0f} 秒自动推进一天(可配置)。"
|
||||
f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS / 60:.0f} 分钟"
|
||||
f"({config.DEFAULT_TICK_SECONDS:.0f} 秒)自动推进一天,可配置。"
|
||||
f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n"
|
||||
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天"
|
||||
'(POST /api/actions {"action":"sleep"} 可主动推进一天,有 2 秒冷却)。\n'
|
||||
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天——"
|
||||
"时间只能自然流动,没有任何「手动推进一天」的接口,别白费请求。\n"
|
||||
f"3. 水分:作物每天消耗 1 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长。"
|
||||
f"播种时自带 {config.PLANT_INITIAL_MOISTURE} 点水分。\n"
|
||||
f"4. 收获:成熟后 harvest 获得金币与经验,有 {config.LUCKY_CHANCE:.0%} 概率丰收翻倍。\n"
|
||||
@@ -142,94 +166,111 @@ def rules_text() -> str:
|
||||
' POST /api/actions → {"action":"<上一步里的 action>",'
|
||||
'"params":{...}} 直接执行\n'
|
||||
" 响应里的 state 就是执行后的最新状态,无需再查一次。\n"
|
||||
"\n实时通道:ws://host/ws?farm=me ,服务器在状态变化时推送 state 消息;"
|
||||
'也可以直接在 WS 上发送 {"type":"action","action":"plant",'
|
||||
'"params":{"plot":1,"crop":"wheat"},"id":1} 并等待 result 回执。\n'
|
||||
"\n实时通道(单向推送,服务器 → 客户端):GET /api/stream?farm=me 是一条 SSE "
|
||||
'长连接,服务器在状态变化时推 {"type":"state",...};\n'
|
||||
"想主动拉一次用 GET /api/state;写操作一律 POST /api/actions。\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- 应用
|
||||
def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
cfg = cfg or ServerConfig()
|
||||
manager = SessionManager(
|
||||
save_dir=cfg.save_dir,
|
||||
tick_seconds=cfg.tick_seconds,
|
||||
max_sessions=cfg.max_sessions,
|
||||
idle_timeout=cfg.idle_timeout,
|
||||
)
|
||||
hub = ConnectionHub()
|
||||
publisher = Publisher(manager, hub, autosave_interval=cfg.autosave_interval)
|
||||
clock = FarmClock(manager)
|
||||
handle = ServerHandle()
|
||||
started_at = time.time()
|
||||
counters = {"requests": 0, "actions": 0, "ws_messages": 0, "errors": 0}
|
||||
def as_asgi(app: FastAPI) -> Any:
|
||||
"""把应用交给 ASGI 服务器时的类型桥。
|
||||
|
||||
uvicorn 用它自己那份 ``uvicorn._types.Scope`` 描述 ASGI,starlette(FastAPI)
|
||||
用的是 ``starlette.types.Scope``。两者运行期逐字一致,但都是 TypedDict、
|
||||
彼此没有子类型关系,所以 pyright 会拦。返回值声明成 Any 就是明说:
|
||||
这个转换由我们担保,问题只在两份类型声明的重复上。
|
||||
"""
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- 运行时
|
||||
@dataclass
|
||||
class _Services:
|
||||
"""create_app 装配出来的运行时依赖。
|
||||
|
||||
以前这些是 ``create_app`` 的局部变量,于是每个路由都必须是嵌套闭包才能看见
|
||||
它们——一个函数膨胀到 475 行、8 层嵌套。收进一个对象后,路由可以拆成模块级
|
||||
的注册函数,各自只依赖传进来的 ``svc``,读起来是平的。
|
||||
"""
|
||||
|
||||
cfg: ServerConfig
|
||||
manager: SessionManager
|
||||
hub: ConnectionHub
|
||||
publisher: Publisher
|
||||
clock: FarmClock
|
||||
handle: ServerHandle
|
||||
started_at: float
|
||||
counters: dict[str, int]
|
||||
|
||||
@classmethod
|
||||
def create(cls, cfg: ServerConfig) -> _Services:
|
||||
manager = SessionManager(
|
||||
save_dir=cfg.save_dir,
|
||||
tick_seconds=cfg.tick_seconds,
|
||||
max_sessions=cfg.max_sessions,
|
||||
idle_timeout=cfg.idle_timeout,
|
||||
)
|
||||
hub = ConnectionHub()
|
||||
return cls(
|
||||
cfg=cfg,
|
||||
manager=manager,
|
||||
hub=hub,
|
||||
publisher=Publisher(manager, hub, autosave_interval=cfg.autosave_interval),
|
||||
clock=FarmClock(manager),
|
||||
handle=ServerHandle(),
|
||||
started_at=time.time(),
|
||||
# 这里没有 ws_messages:那是 WebSocket 时代的遗留计数,
|
||||
# 改成 SSE 之后再也没有人加过它,留着只会让人以为它在被用。
|
||||
counters={"requests": 0, "actions": 0, "errors": 0},
|
||||
)
|
||||
|
||||
# 标注成 asynccontextmanager:FastAPI 的 lifespan 参数要的就是
|
||||
# "返回异步上下文管理器",裸 async generator 在类型上并不匹配
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
clock.start()
|
||||
publisher.start()
|
||||
# 端口已经绑定成功(uvicorn 先 bind 再跑 lifespan),可以对外宣告"我在了"
|
||||
handle.notify_start()
|
||||
async def lifespan(self, _app: FastAPI) -> AsyncIterator[None]:
|
||||
"""应用生命周期:起时钟与推送线程,退出时按相反顺序收干净。
|
||||
|
||||
装饰成 ``asynccontextmanager`` 是因为 FastAPI 要的就是"能当异步上下文
|
||||
管理器用"的东西,裸 async generator 在类型上并不匹配。
|
||||
"""
|
||||
self.clock.start()
|
||||
self.publisher.start()
|
||||
# 生命周期启动完成,可以对外宣告"我在了"。uvicorn 的顺序是"先跑 lifespan、
|
||||
# 后 bind 端口",所以这里代表"服务已上线",不代表"端口已监听";
|
||||
# bind 失败时 pid 文件由 __main__ 的 finally 负责清掉。
|
||||
self.handle.notify_start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
publisher.stop()
|
||||
clock.stop()
|
||||
manager.shutdown()
|
||||
|
||||
app = FastAPI(
|
||||
title=f"{SERVER_NAME} 游戏服务器",
|
||||
version=API_VERSION,
|
||||
description="命令行农场游戏的服务端:HTTP 给 AI 用,WebSocket 给实时界面用。",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.cfg = cfg
|
||||
app.state.manager = manager
|
||||
app.state.hub = hub
|
||||
app.state.publisher = publisher
|
||||
app.state.clock = clock
|
||||
app.state.handle = handle
|
||||
app.state.started_at = started_at
|
||||
app.state.counters = counters
|
||||
|
||||
# ------------------------------------------------------------ 异常处理
|
||||
@app.exception_handler(GameError)
|
||||
async def _game_error(_request: Request, exc: GameError) -> JSONResponse:
|
||||
counters["errors"] += 1
|
||||
return JSONResponse(exc.to_dict(), status_code=exc.status)
|
||||
|
||||
@app.exception_handler(json.JSONDecodeError)
|
||||
async def _bad_json(_request: Request, exc: json.JSONDecodeError) -> JSONResponse:
|
||||
counters["errors"] += 1
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "bad_json", "message": f"请求体不是合法 JSON:{exc}"},
|
||||
status_code=400,
|
||||
)
|
||||
self.publisher.stop()
|
||||
# 兜底:万一是信号触发退出(没走 /api/admin/shutdown),也把长连接收掉
|
||||
self.hub.close_all()
|
||||
self.clock.stop()
|
||||
self.manager.shutdown()
|
||||
|
||||
# ------------------------------------------------------------ 鉴权与取牧场
|
||||
def authorized(supplied: str | None) -> bool:
|
||||
if not cfg.token:
|
||||
return True
|
||||
return supplied == cfg.token
|
||||
def authorized(self, supplied: str | None) -> bool:
|
||||
# 没配 token 就是谁都能进(本机自娱自乐的默认形态)
|
||||
return not self.cfg.token or supplied == self.cfg.token
|
||||
|
||||
@staticmethod
|
||||
def token_of(request: Request) -> str | None:
|
||||
return request.headers.get("x-api-token") or request.query_params.get("token")
|
||||
|
||||
def guard(request: Request) -> None:
|
||||
counters["requests"] += 1
|
||||
if not authorized(token_of(request)):
|
||||
def guard(self, request: Request) -> None:
|
||||
"""计数 + 鉴权。所有对外路由的第一句都调它。"""
|
||||
self.counters["requests"] += 1
|
||||
if not self.authorized(self.token_of(request)):
|
||||
raise GameError("unauthorized", "缺少或错误的 API Token", status=401)
|
||||
|
||||
def session_of(request: Request, owner: str = "") -> FarmSession:
|
||||
def session_of(self, request: Request, owner: str = "") -> FarmSession:
|
||||
farm = request.headers.get("x-farm-id") or request.query_params.get("farm") or DEFAULT_FARM
|
||||
return manager.get(farm, create=True, owner=owner)
|
||||
return self.manager.get(farm, create=True, owner=owner)
|
||||
|
||||
@staticmethod
|
||||
def full_requested(request: Request) -> bool:
|
||||
raw = (request.query_params.get("full") or "").lower()
|
||||
return raw in ("1", "true", "yes", "on")
|
||||
|
||||
@staticmethod
|
||||
async def body_of(request: Request) -> dict[str, Any]:
|
||||
raw = await request.body()
|
||||
if not raw:
|
||||
@@ -242,12 +283,14 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
raise GameError("bad_json", "请求体必须是 JSON 对象")
|
||||
return data
|
||||
|
||||
def execute_action(session: FarmSession, name: Any, params: Any) -> dict[str, Any]:
|
||||
# ------------------------------------------------------------ 动作执行
|
||||
def execute_action(self, session: FarmSession, name: Any, params: Any) -> dict[str, Any]:
|
||||
"""执行一个动作,返回引擎/会话级的结果字典。
|
||||
|
||||
``save`` 与 ``reset`` 必须由**牧场会话**处理而不是引擎:
|
||||
``engine.save()`` 只会写到默认存档路径,多牧场环境下会把别人的存档覆盖掉。
|
||||
这两个动作单独拦截,其余全部走统一的动作表。
|
||||
``save`` 与 ``reset`` 是**存档类动作**,刻意不在 farm.server.actions
|
||||
的通用动作表里(那张表只认识引擎方法,而 ``engine.save()`` 只会写到
|
||||
默认存档路径,多牧场环境下会把别人的存档覆盖掉)。它们只能走这里、
|
||||
由牧场会话处理;其余全部走统一的动作表。
|
||||
"""
|
||||
key = name.strip().lower() if isinstance(name, str) else ""
|
||||
if key == "save":
|
||||
@@ -264,48 +307,70 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
return actions_mod.run_action(session.engine, name, params)
|
||||
|
||||
def run_and_wrap(
|
||||
session: FarmSession, name: Any, params: Any, full: bool = False
|
||||
self, session: FarmSession, name: Any, params: Any, full: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""执行动作并拼装统一响应。HTTP 与 WS 共用。"""
|
||||
result = execute_action(session, name, params)
|
||||
counters["actions"] += 1
|
||||
"""执行动作并拼装统一响应。HTTP 各入口共用。"""
|
||||
result = self.execute_action(session, name, params)
|
||||
self.counters["actions"] += 1
|
||||
session.touch(count_action=True)
|
||||
payload: dict[str, Any] = dict(result)
|
||||
payload["ok"] = True
|
||||
payload["farm"] = session.farm_id
|
||||
payload["seq"] = next_seq()
|
||||
payload["state"] = brief(session, hub)
|
||||
payload["state"] = brief(session, self.hub)
|
||||
payload["log"] = session.engine.recent_log(limit=3)
|
||||
if full:
|
||||
payload["snapshot"] = session.snapshot()
|
||||
return payload
|
||||
|
||||
# ================================================================ 页面
|
||||
|
||||
# ---------------------------------------------------------------------- 路由分组
|
||||
# 每个 _register_* 负责一组关系紧密的接口。分组边界照 OpenAPI 的 tags 来切,
|
||||
# 所以"接口文档里在哪组"和"代码里在哪段"是对得上的。
|
||||
def _register_error_handlers(app: FastAPI, svc: _Services) -> None:
|
||||
@app.exception_handler(GameError)
|
||||
async def _game_error(_request: Request, exc: GameError) -> JSONResponse:
|
||||
svc.counters["errors"] += 1
|
||||
return JSONResponse(exc.to_dict(), status_code=exc.status)
|
||||
|
||||
@app.exception_handler(json.JSONDecodeError)
|
||||
async def _bad_json(_request: Request, exc: json.JSONDecodeError) -> JSONResponse:
|
||||
svc.counters["errors"] += 1
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "bad_json", "message": f"请求体不是合法 JSON:{exc}"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
def _register_pages(app: FastAPI, svc: _Services) -> None:
|
||||
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def index() -> HTMLResponse:
|
||||
return HTMLResponse(DASHBOARD_HTML)
|
||||
|
||||
@app.get("/text", response_class=PlainTextResponse, include_in_schema=False)
|
||||
async def text_index() -> PlainTextResponse:
|
||||
return PlainTextResponse(dashboard_text(cfg.host, cfg.port))
|
||||
return PlainTextResponse(dashboard_text(svc.cfg.host, svc.cfg.port))
|
||||
|
||||
# ================================================================ 元信息
|
||||
|
||||
def _register_meta_routes(app: FastAPI, svc: _Services) -> None:
|
||||
@app.get("/api/health", tags=["元信息"], summary="健康检查(无需鉴权)")
|
||||
async def health() -> dict[str, Any]:
|
||||
# 刻意不鉴权:探针要能在没有 token 的情况下判断"这个服务还活着吗"
|
||||
return {
|
||||
"ok": True,
|
||||
"service": SERVER_NAME,
|
||||
"version": API_VERSION,
|
||||
"uptime": round(time.time() - started_at, 1),
|
||||
"farms": manager.count(),
|
||||
"clients": hub.total(),
|
||||
"clock_ticks": clock.ticks,
|
||||
"saves_since_start": publisher.pushes,
|
||||
"uptime": round(time.time() - svc.started_at, 1),
|
||||
"farms": svc.manager.count(),
|
||||
"clients": svc.hub.total(),
|
||||
"clock_ticks": svc.clock.ticks,
|
||||
"saves_since_start": svc.publisher.pushes,
|
||||
"publish_errors": svc.publisher.errors,
|
||||
}
|
||||
|
||||
@app.get("/api/help", tags=["元信息"], summary="玩法规则与接口说明(给 AI 的说明书)")
|
||||
async def help_page(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
svc.guard(request)
|
||||
return {
|
||||
"ok": True,
|
||||
"service": SERVER_NAME,
|
||||
@@ -323,46 +388,48 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
'POST /api/actions {"action":"plant","params":{"plot":1,"crop":"wheat"}}'
|
||||
),
|
||||
"farms": 'GET /api/farms | POST /api/farms {"farm":"NAME"}',
|
||||
"realtime": "WS /ws?farm=NAME",
|
||||
"realtime": "GET /api/stream?farm=NAME(SSE 单向推送)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _register_world_routes(app: FastAPI, svc: _Services) -> None:
|
||||
@app.get("/api/world", tags=["世界"], summary="世界概览:排行榜 + 在线连接 + 时钟")
|
||||
async def world(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
svc.guard(request)
|
||||
return {
|
||||
"ok": True,
|
||||
"world": cfg.world_name,
|
||||
"uptime": round(time.time() - started_at, 1),
|
||||
"farms": manager.count(),
|
||||
"clients": hub.total(),
|
||||
"connections": hub.snapshot(),
|
||||
"leaderboard": manager.leaderboard(hub.online_map()),
|
||||
"tick_seconds": cfg.tick_seconds,
|
||||
"started_at": started_at,
|
||||
"world": svc.cfg.world_name,
|
||||
"uptime": round(time.time() - svc.started_at, 1),
|
||||
"farms": svc.manager.count(),
|
||||
"clients": svc.hub.total(),
|
||||
"connections": svc.hub.snapshot(),
|
||||
"leaderboard": svc.manager.leaderboard(svc.hub.online_map()),
|
||||
"tick_seconds": svc.cfg.tick_seconds,
|
||||
"started_at": svc.started_at,
|
||||
}
|
||||
|
||||
# ================================================================ 牧场
|
||||
@app.get("/api/farms", tags=["世界"], summary="牧场排行榜")
|
||||
async def list_farms(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
rows = manager.leaderboard(hub.online_map())
|
||||
svc.guard(request)
|
||||
rows = svc.manager.leaderboard(svc.hub.online_map())
|
||||
return {"ok": True, "count": len(rows), "farms": rows}
|
||||
|
||||
@app.post("/api/farms", tags=["世界"], summary="创建或进入一个牧场")
|
||||
async def create_farm(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
body = await body_of(request)
|
||||
svc.guard(request)
|
||||
body = await svc.body_of(request)
|
||||
farm = validate_farm_id(body.get("farm") or body.get("name"))
|
||||
owner = str(body.get("owner") or "")
|
||||
existed = manager.find(farm) is not None
|
||||
session = manager.get(farm, create=True, owner=owner)
|
||||
existed = svc.manager.find(farm) is not None
|
||||
session = svc.manager.get(farm, create=True, owner=owner)
|
||||
tick = body.get("tick")
|
||||
if tick is not None:
|
||||
try:
|
||||
tick_value = float(tick)
|
||||
except (TypeError, ValueError):
|
||||
raise GameError("bad_param", "tick 必须是数字") from None
|
||||
# 直接赋值绕过了引擎构造时的夹取,所以这里得自己夹一次
|
||||
session.engine.tick_seconds = max(
|
||||
config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, tick_value)
|
||||
)
|
||||
@@ -372,45 +439,46 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
"created": not existed,
|
||||
"message": ("牧场已创建" if not existed else "牧场已在运行") + f":{farm}",
|
||||
"farm": farm,
|
||||
"summary": session.summary(hub.online(farm)),
|
||||
"state": brief(session, hub),
|
||||
"summary": session.summary(svc.hub.online(farm)),
|
||||
"state": brief(session, svc.hub),
|
||||
}
|
||||
|
||||
# ================================================================ 单牧场只读
|
||||
|
||||
def _register_farm_routes(app: FastAPI, svc: _Services) -> None:
|
||||
@app.get("/api/state", tags=["牧场"], summary="完整状态快照")
|
||||
async def get_state(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
session = session_of(request)
|
||||
svc.guard(request)
|
||||
session = svc.session_of(request)
|
||||
return {
|
||||
"ok": True,
|
||||
"farm": session.farm_id,
|
||||
"state": session.snapshot(),
|
||||
"online": hub.online(session.farm_id),
|
||||
"online": svc.hub.online(session.farm_id),
|
||||
}
|
||||
|
||||
@app.get("/api/catalog", tags=["牧场"], summary="作物/牲畜目录与实时价格")
|
||||
async def get_catalog(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
session = session_of(request)
|
||||
svc.guard(request)
|
||||
session = svc.session_of(request)
|
||||
return {"ok": True, "farm": session.farm_id, "catalog": session.engine.catalog()}
|
||||
|
||||
@app.get("/api/actions", tags=["牧场"], summary="当前所有合法动作(AI 推荐入口)")
|
||||
async def get_actions(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
session = session_of(request)
|
||||
svc.guard(request)
|
||||
session = svc.session_of(request)
|
||||
items = session.engine.available_actions()
|
||||
return {
|
||||
"ok": True,
|
||||
"farm": session.farm_id,
|
||||
"count": len(items),
|
||||
"actions": items,
|
||||
"state": brief(session, hub),
|
||||
"state": brief(session, svc.hub),
|
||||
}
|
||||
|
||||
@app.get("/api/log", tags=["牧场"], summary="最近事件")
|
||||
async def get_log(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
session = session_of(request)
|
||||
svc.guard(request)
|
||||
session = svc.session_of(request)
|
||||
limit = _int(request.query_params.get("limit"), 20, 1, 200)
|
||||
since = _int(request.query_params.get("since"), 0, 0, 10**9)
|
||||
entries = session.engine.recent_log(limit=limit, since=since)
|
||||
@@ -422,18 +490,19 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
"last_seq": entries[-1]["seq"] if entries else since,
|
||||
}
|
||||
|
||||
# ================================================================ 写操作
|
||||
|
||||
def _register_action_routes(app: FastAPI, svc: _Services) -> None:
|
||||
@app.post("/api/actions", tags=["动作"], summary="通用动作入口(推荐 AI 使用)")
|
||||
async def post_action(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
body = await body_of(request)
|
||||
session = session_of(request)
|
||||
svc.guard(request)
|
||||
body = await svc.body_of(request)
|
||||
session = svc.session_of(request)
|
||||
name = body.get("action") or body.get("name")
|
||||
params = body.get("params")
|
||||
if params is None:
|
||||
# 允许把参数平铺在顶层:{"action":"plant","plot":1,"crop":"wheat"}
|
||||
params = {k: v for k, v in body.items() if k not in ("action", "name", "params")}
|
||||
return run_and_wrap(session, name, params, full_requested(request))
|
||||
return svc.run_and_wrap(session, name, params, svc.full_requested(request))
|
||||
|
||||
@app.post("/api/batch", tags=["动作"], summary="批量执行动作(一个请求干多件事)")
|
||||
async def post_batch(request: Request) -> dict[str, Any]:
|
||||
@@ -441,9 +510,9 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
|
||||
每个动作独立成败,失败的只记错误不中断后续动作。
|
||||
"""
|
||||
guard(request)
|
||||
body = await body_of(request)
|
||||
session = session_of(request)
|
||||
svc.guard(request)
|
||||
body = await svc.body_of(request)
|
||||
session = svc.session_of(request)
|
||||
items = body.get("actions")
|
||||
if not isinstance(items, list) or not items:
|
||||
raise GameError("bad_param", "actions 必须是至少含一个元素的数组")
|
||||
@@ -455,12 +524,13 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
results.append({"ok": False, "error": "bad_param", "message": "每个动作必须是对象"})
|
||||
continue
|
||||
try:
|
||||
payload = run_and_wrap(
|
||||
payload = svc.run_and_wrap(
|
||||
session, item.get("action"), item.get("params") or {}, full=False
|
||||
)
|
||||
# 逐个动作不必重复回精简状态:末尾统一给一份就够了
|
||||
results.append({k: v for k, v in payload.items() if k != "state"})
|
||||
except GameError as exc:
|
||||
counters["errors"] += 1
|
||||
svc.counters["errors"] += 1
|
||||
results.append({**exc.to_dict(), "action": item.get("action")})
|
||||
ok_count = sum(1 for r in results if r.get("ok"))
|
||||
payload: dict[str, Any] = {
|
||||
@@ -469,22 +539,24 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
"done": ok_count,
|
||||
"failed": len(results) - ok_count,
|
||||
"results": results,
|
||||
"state": brief(session, hub),
|
||||
"state": brief(session, svc.hub),
|
||||
}
|
||||
if full_requested(request):
|
||||
if svc.full_requested(request):
|
||||
payload["snapshot"] = session.snapshot()
|
||||
return payload
|
||||
|
||||
def make_action_route(action_name: str) -> Callable[[Request], Any]:
|
||||
async def route(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
body = await body_of(request)
|
||||
session = session_of(request)
|
||||
return run_and_wrap(session, action_name, body, full_requested(request))
|
||||
svc.guard(request)
|
||||
body = await svc.body_of(request)
|
||||
session = svc.session_of(request)
|
||||
return svc.run_and_wrap(session, action_name, body, svc.full_requested(request))
|
||||
|
||||
route.__name__ = f"post_{action_name}"
|
||||
return route
|
||||
|
||||
# 每个动作都额外挂一条自己的路径(POST /api/water 之类),方便直接 curl;
|
||||
# 语义与 POST /api/actions 完全等价。
|
||||
for _name, _spec in actions_mod.ACTIONS.items():
|
||||
app.add_api_route(
|
||||
f"/api/{_name}",
|
||||
@@ -495,12 +567,13 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
summary=f"{_spec.summary}(等价于 POST /api/actions)",
|
||||
)
|
||||
|
||||
# ================================================================ 管理
|
||||
|
||||
def _register_admin_routes(app: FastAPI, svc: _Services) -> None:
|
||||
@app.post("/api/admin/save", tags=["管理"], summary="把所有牧场写入存档")
|
||||
async def admin_save(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
svc.guard(request)
|
||||
saved: list[str] = []
|
||||
for session in manager.all():
|
||||
for session in svc.manager.all():
|
||||
try:
|
||||
session.save()
|
||||
saved.append(session.farm_id)
|
||||
@@ -512,210 +585,139 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
|
||||
@app.post("/api/admin/shutdown", tags=["管理"], summary="优雅停机(守护进程 stop 使用)")
|
||||
async def admin_shutdown(request: Request) -> dict[str, Any]:
|
||||
guard(request)
|
||||
if not cfg.allow_remote_control:
|
||||
svc.guard(request)
|
||||
if not svc.cfg.allow_remote_control:
|
||||
raise GameError("forbidden", "服务器未开放远程停机", status=403)
|
||||
manager.shutdown()
|
||||
accepted = handle.request_stop()
|
||||
svc.manager.shutdown()
|
||||
# 先让所有 SSE 长连接收尾:否则服务器的优雅停机要一直等它们,变成卡死
|
||||
svc.hub.close_all()
|
||||
accepted = svc.handle.request_stop()
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "服务器正在优雅停机,存档已写入" if accepted else "停机请求已受理(重复)",
|
||||
}
|
||||
|
||||
# ================================================================ WebSocket
|
||||
@app.websocket("/ws")
|
||||
async def ws_endpoint(websocket: WebSocket) -> None:
|
||||
params = websocket.query_params
|
||||
supplied = websocket.headers.get("x-api-token") or params.get("token")
|
||||
if not authorized(supplied):
|
||||
# 4401 是自定义关闭码,客户端据此提示"令牌不对"而不是"网络断了"
|
||||
await websocket.close(code=4401, reason="unauthorized")
|
||||
return
|
||||
|
||||
try:
|
||||
farm = validate_farm_id(params.get("farm") or DEFAULT_FARM)
|
||||
except GameError as exc:
|
||||
await websocket.close(code=4400, reason=exc.message[:120])
|
||||
return
|
||||
def _register_stream_route(app: FastAPI, svc: _Services) -> None:
|
||||
@app.get("/api/stream", tags=["实时"], summary="SSE 实时推送(服务器 → 客户端单向)")
|
||||
async def stream(request: Request) -> StreamingResponse:
|
||||
"""一条 HTTP 长连接持续推送状态;写操作请走 POST /api/actions。
|
||||
|
||||
消息类型:``welcome``(首帧全量)/ ``state``(状态变化)/ ``day``(跨天)/
|
||||
``notice``(有人来去)/ ``error``,每条都是一行 JSON 放在 ``data:`` 后面。
|
||||
客户端断开(关页面、退出 TUI、进程被杀)都会走到生成器的 finally。
|
||||
"""
|
||||
svc.guard(request)
|
||||
params = request.query_params
|
||||
farm = validate_farm_id(params.get("farm") or DEFAULT_FARM)
|
||||
# 牧场数达上限等情况会抛 GameError,交给全局处理器回 4xx JSON
|
||||
session = svc.manager.get(farm, create=True)
|
||||
|
||||
await websocket.accept()
|
||||
loop = asyncio.get_running_loop()
|
||||
client = Client(farm, loop, name=(params.get("name") or "")[:32])
|
||||
try:
|
||||
session = manager.get(farm, create=True)
|
||||
except GameError as exc:
|
||||
# 例如牧场数量达到上限:说清楚原因再关,别让客户端以为是网络问题
|
||||
await websocket.send_json({"type": "error", "error": exc.code, "message": exc.message})
|
||||
await websocket.close(code=1013, reason=exc.message[:100])
|
||||
return
|
||||
hub.register(client)
|
||||
svc.hub.register(client)
|
||||
session.touch()
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "welcome",
|
||||
"client_id": client.cid,
|
||||
"farm": farm,
|
||||
"server": {
|
||||
"name": SERVER_NAME,
|
||||
"version": API_VERSION,
|
||||
"world": cfg.world_name,
|
||||
"tick_seconds": session.engine.tick_seconds,
|
||||
},
|
||||
"state": session.snapshot(),
|
||||
"catalog": session.engine.catalog(),
|
||||
"actions": session.engine.available_actions(),
|
||||
"log": session.engine.recent_log(limit=12),
|
||||
"online": hub.online(farm),
|
||||
}
|
||||
)
|
||||
hub.broadcast(
|
||||
farm,
|
||||
{
|
||||
"type": "notice",
|
||||
"farm": farm,
|
||||
"text": f"{client.name or client.cid} 来到了牧场(在线 {hub.online(farm)})",
|
||||
},
|
||||
exclude=client,
|
||||
)
|
||||
# "来了/走了"是同一件事的两个方向,差别只有动词,所以共用一个信封
|
||||
who = client.name or client.cid
|
||||
|
||||
async def writer() -> None:
|
||||
while True:
|
||||
message = await client.next_message()
|
||||
await websocket.send_json(message)
|
||||
def notice(text: str, exclude: Client | None = None) -> None:
|
||||
svc.hub.broadcast(farm, {"type": "notice", "farm": farm, "text": text}, exclude=exclude)
|
||||
|
||||
async def reader() -> None:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
counters["ws_messages"] += 1
|
||||
await handle_ws_message(raw)
|
||||
|
||||
async def handle_ws_message(raw: str) -> None:
|
||||
"""处理一条客户端消息。
|
||||
|
||||
注意:这里只做「解析 + 派发」,真正的引擎调用丢给线程池,
|
||||
因此一条慢查询不会让同一个连接的心跳/推送卡住。
|
||||
"""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "error": "bad_json", "message": f"不是合法 JSON:{exc}"}
|
||||
)
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "error": "bad_json", "message": "消息必须是 JSON 对象"}
|
||||
)
|
||||
return
|
||||
|
||||
kind = str(data.get("type") or "").lower()
|
||||
rid = data.get("id")
|
||||
|
||||
if kind == "ping":
|
||||
await websocket.send_json({"type": "pong", "id": rid, "t": time.time()})
|
||||
return
|
||||
|
||||
if kind in ("sync", "refresh", "state"):
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "sync",
|
||||
"id": rid,
|
||||
"seq": next_seq(),
|
||||
"farm": session.farm_id,
|
||||
"state": session.snapshot(),
|
||||
"actions": session.engine.available_actions(),
|
||||
"log": session.engine.recent_log(limit=20),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if kind == "help":
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "help",
|
||||
"id": rid,
|
||||
"rules": rules_text(),
|
||||
"actions": actions_mod.describe_actions(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if kind == "action":
|
||||
try:
|
||||
# 引擎是同步的,丢到线程池里跑,别堵住事件循环
|
||||
result = await asyncio.to_thread(
|
||||
execute_action,
|
||||
session,
|
||||
data.get("action"),
|
||||
data.get("params") or {},
|
||||
)
|
||||
except GameError as exc:
|
||||
counters["errors"] += 1
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "result",
|
||||
"id": rid,
|
||||
"ok": False,
|
||||
"error": exc.code,
|
||||
"message": exc.message,
|
||||
"farm": session.farm_id,
|
||||
}
|
||||
)
|
||||
return
|
||||
counters["actions"] += 1
|
||||
session.touch(count_action=True)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "result",
|
||||
"id": rid,
|
||||
"ok": True,
|
||||
"farm": session.farm_id,
|
||||
"message": result.get("message", "完成"),
|
||||
"result": result,
|
||||
"state": brief(session, hub),
|
||||
# 回执里带上完整快照:客户端可以立刻刷新画面,不必等下一次推送
|
||||
"snapshot": session.snapshot(),
|
||||
"log": session.engine.recent_log(limit=3),
|
||||
"seq": next_seq(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
await websocket.send_json(
|
||||
async def events() -> AsyncIterator[str]:
|
||||
# 首帧等价于原来的 welcome:一次把状态、目录、合法动作、日志给全
|
||||
yield sse_frame(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "unknown_message",
|
||||
"message": f"不认识的消息类型 {kind!r}",
|
||||
"hint": "可用:action / ping / sync / help",
|
||||
"type": "welcome",
|
||||
"client_id": client.cid,
|
||||
"farm": farm,
|
||||
"server": {
|
||||
"name": SERVER_NAME,
|
||||
"version": API_VERSION,
|
||||
"world": svc.cfg.world_name,
|
||||
"tick_seconds": session.engine.tick_seconds,
|
||||
},
|
||||
"state": session.snapshot(),
|
||||
"catalog": session.engine.catalog(),
|
||||
"actions": session.engine.available_actions(),
|
||||
"log": session.engine.recent_log(limit=12),
|
||||
"online": svc.hub.online(farm),
|
||||
}
|
||||
)
|
||||
|
||||
writer_task = asyncio.create_task(writer())
|
||||
reader_task = asyncio.create_task(reader())
|
||||
try:
|
||||
await asyncio.wait({writer_task, reader_task}, return_when=asyncio.FIRST_COMPLETED)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
counters["errors"] += 1
|
||||
finally:
|
||||
for task in (writer_task, reader_task):
|
||||
task.cancel()
|
||||
hub.unregister(client)
|
||||
session.touch()
|
||||
hub.broadcast(
|
||||
session.farm_id,
|
||||
{
|
||||
"type": "notice",
|
||||
"farm": session.farm_id,
|
||||
"text": (
|
||||
f"{client.name or client.cid} 离开了牧场"
|
||||
f"(在线 {hub.online(session.farm_id)})"
|
||||
),
|
||||
},
|
||||
notice(
|
||||
f"{who} 来到了牧场(在线 {svc.hub.online(farm)})",
|
||||
exclude=client,
|
||||
)
|
||||
try:
|
||||
last_beat = time.time()
|
||||
while True:
|
||||
if svc.handle.stopping:
|
||||
# 服务器要退了:主动收尾,别让长连接拖住优雅停机
|
||||
yield sse_frame(dict(CLOSE_MESSAGE))
|
||||
break
|
||||
try:
|
||||
message = await asyncio.wait_for(client.next_message(), timeout=IDLE_TICK)
|
||||
except asyncio.TimeoutError:
|
||||
# 空转一秒只是为了让上面的停机检查有机会跑;
|
||||
# 心跳按自己的间隔发,别刷屏
|
||||
if time.time() - last_beat >= HEARTBEAT_SECONDS:
|
||||
last_beat = time.time()
|
||||
yield sse_heartbeat()
|
||||
continue
|
||||
if message.get("type") == "bye":
|
||||
yield sse_frame(message)
|
||||
break
|
||||
yield sse_frame(message)
|
||||
finally:
|
||||
svc.hub.unregister(client)
|
||||
session.touch()
|
||||
notice(f"{who} 离开了牧场(在线 {svc.hub.online(farm)})")
|
||||
|
||||
return StreamingResponse(
|
||||
events(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
# 让 nginx 之类的反代不要缓冲,否则推送会攒着一次性发出来
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- 应用
|
||||
def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
"""装配应用:依赖 → 生命周期 → 各路由分组。
|
||||
|
||||
路由各自藏在 ``_register_*`` 里,所以这里读起来就是一份目录:
|
||||
想找哪个接口,按分组名翻就行,不必在几百行里爬。
|
||||
"""
|
||||
cfg = cfg or ServerConfig()
|
||||
svc = _Services.create(cfg)
|
||||
|
||||
app = FastAPI(
|
||||
title=f"{SERVER_NAME} 游戏服务器",
|
||||
version=API_VERSION,
|
||||
description="命令行农场游戏的服务端:HTTP 给 AI 用,SSE 给实时界面用。",
|
||||
lifespan=svc.lifespan,
|
||||
)
|
||||
app.state.cfg = cfg
|
||||
app.state.manager = svc.manager
|
||||
app.state.hub = svc.hub
|
||||
app.state.publisher = svc.publisher
|
||||
app.state.clock = svc.clock
|
||||
app.state.handle = svc.handle
|
||||
app.state.started_at = svc.started_at
|
||||
app.state.counters = svc.counters
|
||||
|
||||
_register_error_handlers(app, svc)
|
||||
_register_pages(app, svc)
|
||||
_register_meta_routes(app, svc)
|
||||
_register_world_routes(app, svc)
|
||||
_register_farm_routes(app, svc)
|
||||
_register_action_routes(app, svc)
|
||||
_register_admin_routes(app, svc)
|
||||
_register_stream_route(app, svc)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
+40
-22
@@ -19,6 +19,7 @@ import contextlib
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -66,6 +67,27 @@ def pid_alive(pid: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def port_busy(host: str, port: int, timeout: float = 0.6) -> bool:
|
||||
"""端口上已经有程序在监听了吗。
|
||||
|
||||
用"能不能连上"判断,而不是"能不能 bind":Windows 上只要两边都设了
|
||||
``SO_REUSEADDR``(uvicorn 会设),第二个进程也能绑上同一个
|
||||
端口、然后抢走新连接——于是 bind 探测会给出一个自信的错误答案。
|
||||
"""
|
||||
|
||||
if host in ("0.0.0.0", ""):
|
||||
target = "127.0.0.1"
|
||||
elif host == "::":
|
||||
target = "::1"
|
||||
else:
|
||||
target = host
|
||||
try:
|
||||
with socket.create_connection((target, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class DaemonManager:
|
||||
"""负责在后台拉起/停止 ``farm.server`` 进程。"""
|
||||
|
||||
@@ -118,39 +140,35 @@ class DaemonManager:
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------ 探测
|
||||
def probe(self, timeout: float = 1.5) -> dict[str, Any] | None:
|
||||
"""访问 /api/health,成功说明服务真的在监听。
|
||||
def _get_json(self, path: str, timeout: float, token: bool = False) -> dict[str, Any] | None:
|
||||
"""访问自己这个服务的某个接口;连不上或回包不是 JSON 就返回 None。
|
||||
|
||||
用 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走,
|
||||
一律走 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走,
|
||||
否则在设了 ``HTTP_PROXY`` 的机器上会得到莫名其妙的 502。
|
||||
"""
|
||||
url = f"http://{self.host}:{self.port}/api/health"
|
||||
try:
|
||||
with net.open_url(url, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
def world(self, timeout: float = 2.0) -> dict[str, Any] | None:
|
||||
url = f"http://{self.host}:{self.port}/api/world"
|
||||
if self.token:
|
||||
末尾那道 ``isinstance`` 是为了让返回类型名副其实——回包万一是个数组,
|
||||
调用方的 ``.get(...)`` 会直接崩在属性访问上。
|
||||
"""
|
||||
url = f"http://{self.host}:{self.port}{path}"
|
||||
if token and self.token:
|
||||
url += "?token=" + urllib.parse.quote(self.token)
|
||||
try:
|
||||
with net.open_url(url, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
def probe(self, timeout: float = 1.5) -> dict[str, Any] | None:
|
||||
"""访问 /api/health,成功说明服务真的在监听。"""
|
||||
return self._get_json("/api/health", timeout)
|
||||
|
||||
def world(self, timeout: float = 2.0) -> dict[str, Any] | None:
|
||||
return self._get_json("/api/world", timeout, token=True)
|
||||
|
||||
def port_busy(self) -> bool:
|
||||
"""端口被别的程序占了吗(用于给出"换端口"的提示)。"""
|
||||
import socket
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(0.6)
|
||||
return (
|
||||
sock.connect_ex((self.host if self.host != "0.0.0.0" else "127.0.0.1", self.port))
|
||||
== 0
|
||||
)
|
||||
return port_busy(self.host, self.port)
|
||||
|
||||
# ------------------------------------------------------------ 启动
|
||||
def command(self) -> list[str]:
|
||||
|
||||
+71
-28
@@ -1,20 +1,23 @@
|
||||
"""实时通道:WebSocket 连接池 + 状态推送线程。
|
||||
"""实时通道:SSE 订阅池 + 状态推送线程。
|
||||
|
||||
为什么选 WebSocket 而不是 SSE
|
||||
-----------------------------
|
||||
这个游戏是**双向**的:服务器要往下推状态(作物成熟、别人来串门),
|
||||
客户端也要往上推动作(播种、喂食)。SSE 只能单向下行,上行还得再开一个
|
||||
HTTP 通道,等于把一条逻辑连接拆成两套鉴权、两套重连、两套错误处理。
|
||||
WebSocket 一次握手就能双向跑,还能带上 ``action`` 回执,所以这里选它。
|
||||
为什么是 SSE
|
||||
------------
|
||||
这个游戏的下行(作物成熟、别人来串门)需要服务器**主动**推给客户端,
|
||||
而上行(播种、喂食)本来就是普通的 HTTP 写请求。SSE(Server-Sent Events)
|
||||
正好是"一条 HTTP 长连接单向推流",上行复用 REST,两边各用最自然的协议:
|
||||
|
||||
* 纯 HTTP 语义:`curl`、浏览器 `EventSource`、任何 HTTP 客户端都能读;
|
||||
* 不用额外协议栈:客户端只需 urllib 流式读,连第三方库都不用装;
|
||||
* 天然支持重连(浏览器 EventSource 自动重连,本项目的客户端自己重连)。
|
||||
|
||||
线程模型(重点)
|
||||
----------------
|
||||
uvicorn 跑在 asyncio 事件循环里,而游戏引擎是同步的、跑在工作线程里。
|
||||
两者之间不能直接调用对方的 API,所以:
|
||||
|
||||
* 每个连接持有一个 :class:`asyncio.Queue` 作为**出口缓冲区**;
|
||||
* 每条订阅持有一个 :class:`asyncio.Queue` 作为**出口缓冲区**;
|
||||
* 任意线程想发消息,就 ``loop.call_soon_threadsafe()`` 把消息塞进队列;
|
||||
* 每条连接有自己的写协程,只负责「从队列取出 → ws.send_json」。
|
||||
* 每条订阅有一个生成器协程,只负责「从队列取出 → yield 成 SSE 帧」。
|
||||
|
||||
这样引擎线程永远不会阻塞在网络上,事件循环也永远不会被磁盘/锁卡住。
|
||||
"""
|
||||
@@ -24,13 +27,19 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import itertools
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
STATE_INTERVAL = 0.25 # 状态推送的最小间隔(秒),天然把高频操作合并成一次
|
||||
QUEUE_SIZE = 32 # 单连接出口缓冲区;满了就丢最旧的(状态类消息可丢)
|
||||
QUEUE_SIZE = 32 # 单订阅出口缓冲区;满了就丢最旧的(状态类消息可丢)
|
||||
HEARTBEAT_SECONDS = 15.0 # 无消息时的 SSE 心跳间隔,防中间代理掐断空闲连接
|
||||
IDLE_TICK = 1.0 # 生成器空转的唤醒间隔:用于发现"服务器要停机了"
|
||||
|
||||
# 停机时推给每个订阅者的收尾消息:客户端据此知道是服务器主动断开的
|
||||
CLOSE_MESSAGE: dict[str, Any] = {"type": "bye", "message": "服务器正在停机"}
|
||||
|
||||
_seq_counter = itertools.count(1)
|
||||
|
||||
@@ -44,8 +53,22 @@ def next_seq() -> int:
|
||||
return next(_seq_counter)
|
||||
|
||||
|
||||
def sse_frame(message: dict[str, Any]) -> str:
|
||||
"""把一条消息编码成 SSE 帧。
|
||||
|
||||
SSE 的格式很简单:``data: <一行文本>`` 加一个空行。
|
||||
JSON 本身不含裸换行(``json.dumps`` 会把换行转义),所以一帧一行就够。
|
||||
"""
|
||||
return f"data: {json.dumps(message, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def sse_heartbeat() -> str:
|
||||
"""SSE 注释行心跳:客户端会忽略,但能让代理/防火墙看到连接还活着。"""
|
||||
return ": keep-alive\n\n"
|
||||
|
||||
|
||||
class Client:
|
||||
"""一条 WebSocket 连接。"""
|
||||
"""一条 SSE 订阅(一个浏览器标签页 / 一个 TUI / 一个脚本)。"""
|
||||
|
||||
_ids = itertools.count(1)
|
||||
|
||||
@@ -74,7 +97,7 @@ class Client:
|
||||
|
||||
# ------------------------------------------------------------ 线程安全投递
|
||||
def push(self, message: dict[str, Any]) -> None:
|
||||
"""从任意线程把消息排队到该连接的写协程。"""
|
||||
"""从任意线程把消息排队到该订阅的生成器协程。"""
|
||||
if not self.alive:
|
||||
return
|
||||
try:
|
||||
@@ -110,7 +133,7 @@ class Client:
|
||||
|
||||
|
||||
class ConnectionHub:
|
||||
"""按牧场分组的连接登记处。"""
|
||||
"""按牧场分组的 SSE 订阅登记处。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
@@ -154,21 +177,27 @@ class ConnectionHub:
|
||||
with self._lock:
|
||||
return [c.stats() for c in sorted(self._all, key=lambda c: c.cid)]
|
||||
|
||||
# ------------------------------------------------------------ 广播
|
||||
def broadcast(
|
||||
self, farm_id: str, message: dict[str, Any], exclude: Client | None = None
|
||||
) -> int:
|
||||
sent = 0
|
||||
for client in self.clients(farm_id):
|
||||
if client is exclude:
|
||||
continue
|
||||
client.push(message)
|
||||
sent += 1
|
||||
return sent
|
||||
# ------------------------------------------------------------ 收尾
|
||||
def close_all(self) -> int:
|
||||
"""让所有 SSE 流主动收尾,返回受影响的订阅数。
|
||||
|
||||
def broadcast_all(self, message: dict[str, Any], exclude: Client | None = None) -> int:
|
||||
停机时必须做这件事:SSE 是**永不自己结束**的长连接,只要还有一条挂着,
|
||||
uvicorn 的优雅停机就要一直等它,`stop` 会卡到超时。发一条 ``bye``
|
||||
让每个流的生成器自己 break,连接随之正常关闭。
|
||||
"""
|
||||
with self._lock:
|
||||
targets = list(self._all)
|
||||
return self._push_many(targets, dict(CLOSE_MESSAGE), None)
|
||||
|
||||
# ------------------------------------------------------------ 广播
|
||||
def _push_many(
|
||||
self, targets: list[Client], message: dict[str, Any], exclude: Client | None
|
||||
) -> int:
|
||||
"""给一批订阅者推送同一条消息,返回送出的条数。
|
||||
|
||||
``Client.push`` 只是把消息排进队列、不改动它,所以这里可以放心让所有
|
||||
订阅者共用同一个 dict(不必每人拷一份)。
|
||||
"""
|
||||
sent = 0
|
||||
for client in targets:
|
||||
if client is exclude:
|
||||
@@ -177,9 +206,21 @@ class ConnectionHub:
|
||||
sent += 1
|
||||
return sent
|
||||
|
||||
def broadcast(
|
||||
self, farm_id: str, message: dict[str, Any], exclude: Client | None = None
|
||||
) -> int:
|
||||
"""推给某个牧场里的所有订阅者。"""
|
||||
return self._push_many(self.clients(farm_id), message, exclude)
|
||||
|
||||
def broadcast_all(self, message: dict[str, Any], exclude: Client | None = None) -> int:
|
||||
"""推给所有牧场的所有订阅者。"""
|
||||
with self._lock:
|
||||
targets = list(self._all)
|
||||
return self._push_many(targets, message, exclude)
|
||||
|
||||
|
||||
class Publisher(threading.Thread):
|
||||
"""后台推送线程:把「状态变了」这件事节流后送给在线的客户端。
|
||||
"""后台推送线程:把「状态变了」这件事节流后送给在线的订阅者。
|
||||
|
||||
引擎的 ``_notify()`` 只在 session 上打一个 dirty 标记,真正的推送由这里
|
||||
按 :data:`STATE_INTERVAL` 合并发送。好处是玩家连点十次浇水也只推一帧,
|
||||
@@ -193,6 +234,7 @@ class Publisher(threading.Thread):
|
||||
self.autosave_interval = autosave_interval
|
||||
self._stop = threading.Event()
|
||||
self.pushes = 0
|
||||
self.errors = 0
|
||||
self._last_autosave = time.time()
|
||||
self._started_at = time.time()
|
||||
|
||||
@@ -213,8 +255,9 @@ class Publisher(threading.Thread):
|
||||
session.dirty = False
|
||||
self._push_state(session, now)
|
||||
except Exception:
|
||||
# 推送线程绝不能因为单个牧场出错而退出
|
||||
pass
|
||||
# 推送线程绝不能因为单个牧场出错而退出;
|
||||
# 但也不能完全无声——计数进 self.errors,/api/health 可观测
|
||||
self.errors += 1
|
||||
|
||||
if now - self._last_autosave >= self.autosave_interval:
|
||||
self._last_autosave = now
|
||||
|
||||
+25
-21
@@ -23,7 +23,7 @@ import time
|
||||
from typing import Any
|
||||
|
||||
from .. import config
|
||||
from ..engine import FarmEngine, GameError
|
||||
from ..engine import FarmEngine, GameError, tick_decision
|
||||
from ..storage import load_state, save_state
|
||||
|
||||
DEFAULT_FARM = "default"
|
||||
@@ -72,6 +72,10 @@ class FarmSession:
|
||||
self._lock = threading.RLock()
|
||||
|
||||
self.engine = FarmEngine(tick_seconds=tick_seconds)
|
||||
# 立刻排好第一次推进:否则新建牧场要等时钟线程下一轮(≤ accuracy 秒)
|
||||
# 才有 deadline,这段时间 snapshot 里的倒计时是 None,
|
||||
# 客户端会误以为"已暂停 / 手动模式"。
|
||||
self.engine.next_tick_at = time.time() + self.engine.tick_seconds
|
||||
# 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写
|
||||
self.dirty = True
|
||||
self.published_day = 0
|
||||
@@ -153,19 +157,24 @@ class FarmSession:
|
||||
return data
|
||||
|
||||
def summary(self, online: int = 0) -> dict[str, Any]:
|
||||
"""给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。"""
|
||||
snap = self.engine.snapshot()
|
||||
"""给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。
|
||||
|
||||
走 :meth:`FarmEngine.brief_source` 而**不是** ``snapshot()``:
|
||||
排行榜会对每个牧场调一次,而这里只用得上十几个标量——为它序列化全量
|
||||
地块与牲畜,成本会随牧场数线性放大(32 个牧场实测 ~1.9ms)。
|
||||
"""
|
||||
brief = self.engine.brief_source()
|
||||
return {
|
||||
"farm": self.farm_id,
|
||||
"owner": self.owner,
|
||||
"day": snap["day"],
|
||||
"gold": snap["gold"],
|
||||
"level": snap["level"],
|
||||
"net_worth": snap["net_worth"],
|
||||
"plots": f"{snap['plot_count']}/{snap['max_plots']}",
|
||||
"animals": snap["animal_count"],
|
||||
"goal_index": snap["goal_index"],
|
||||
"paused": snap["paused"],
|
||||
"day": brief["day"],
|
||||
"gold": brief["gold"],
|
||||
"level": brief["level"],
|
||||
"net_worth": brief["net_worth"],
|
||||
"plots": f"{brief['plot_count']}/{brief['max_plots']}",
|
||||
"animals": brief["animal_count"],
|
||||
"goal_index": brief["goal_index"],
|
||||
"paused": brief["paused"],
|
||||
"online": online,
|
||||
"actions": self.actions_taken,
|
||||
"idle_seconds": round(time.time() - self.last_active, 1),
|
||||
@@ -292,17 +301,12 @@ class FarmClock(threading.Thread):
|
||||
for session in self.manager.all():
|
||||
engine = session.engine
|
||||
try:
|
||||
if engine.paused:
|
||||
# 暂停时把下一次推进顺延,恢复后仍有完整的一个 tick
|
||||
engine.next_tick_at = now + engine.tick_seconds
|
||||
continue
|
||||
deadline = engine.next_tick_at
|
||||
if deadline is None:
|
||||
engine.next_tick_at = now + engine.tick_seconds
|
||||
continue
|
||||
if now >= deadline:
|
||||
# tick 决策与单机 Scheduler 共用同一份纯函数,避免两处逻辑漂移
|
||||
advance, engine.next_tick_at = tick_decision(
|
||||
engine.paused, engine.next_tick_at, engine.tick_seconds, now
|
||||
)
|
||||
if advance:
|
||||
session.advance_day("时间流逝")
|
||||
engine.next_tick_at = now + engine.tick_seconds
|
||||
self.ticks += 1
|
||||
except Exception:
|
||||
# 单个牧场出错不影响其它牧场的时间
|
||||
|
||||
+25
-30
@@ -3,7 +3,7 @@
|
||||
为什么要内嵌而不是写一堆静态文件
|
||||
--------------------------------
|
||||
服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理
|
||||
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 WebSocket
|
||||
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 SSE
|
||||
协议的一份"活文档":想接自己的客户端时,照着这里的 JS 抄就行。
|
||||
|
||||
注意:这个字符串里既有 CSS 又有 JS,花括号很多,所以**不要**用 ``f-string``
|
||||
@@ -132,7 +132,6 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
<button class="primary" data-act="harvest_all">一键收获</button>
|
||||
<button data-act="buy_plot">买地</button>
|
||||
<button data-act="buy_feed">买饲料x10</button>
|
||||
<button data-act="sleep">休息一天</button>
|
||||
<button data-act="pause">暂停/继续</button>
|
||||
<button data-act="save">存档</button>
|
||||
<button data-act="reset">重置</button>
|
||||
@@ -159,7 +158,7 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let ws = null, state = null, catalog = null, pollTimer = null, reconnectTimer = null, pending = 0;
|
||||
let es = null, state = null, catalog = null, pollTimer = null;
|
||||
|
||||
const cropNames = {
|
||||
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
|
||||
@@ -218,40 +217,34 @@ function refreshFromBrief(brief) {
|
||||
}
|
||||
|
||||
function syncNow() {
|
||||
if (ws && ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: "sync" }));
|
||||
return;
|
||||
}
|
||||
api("/api/state?farm=" + encodeURIComponent(farmName()) +
|
||||
(token() ? "&token=" + encodeURIComponent(token()) : ""), { headers: headers() })
|
||||
.then((d) => { if (d) { state = d.state; render(); } });
|
||||
}
|
||||
|
||||
/* 实时通道:SSE 长连接(EventSource 自带断线重连,不用自己写重连逻辑) */
|
||||
function connect() {
|
||||
if (ws) { try { ws.close(); } catch (e) {} }
|
||||
clearTimeout(reconnectTimer);
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = proto + "//" + location.host + "/ws?farm=" + encodeURIComponent(farmName()) +
|
||||
if (es) { try { es.close(); } catch (e) {} }
|
||||
clearInterval(pollTimer); pollTimer = null;
|
||||
const url = "/api/stream?farm=" + encodeURIComponent(farmName()) +
|
||||
(token() ? "&token=" + encodeURIComponent(token()) : "") + "&name=web";
|
||||
setStatus("连接中…", "");
|
||||
ws = new WebSocket(url);
|
||||
ws.onopen = () => {
|
||||
setStatus("已连接(WebSocket)", "on");
|
||||
es = new EventSource(url);
|
||||
es.onopen = () => {
|
||||
setStatus("已连接(SSE 实时)", "on");
|
||||
clearInterval(pollTimer); pollTimer = null;
|
||||
api("/api/farms", { headers: headers() });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
es.onmessage = (ev) => {
|
||||
let msg = null;
|
||||
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||||
handle(msg);
|
||||
};
|
||||
ws.onclose = (ev) => {
|
||||
if (ev.code === 4401) { setStatus("令牌不正确", "bad"); }
|
||||
else setStatus("已断开,3 秒后重连", "bad");
|
||||
if (!pollTimer) pollTimer = setInterval(syncNow, 1500); // 降级为轮询
|
||||
reconnectTimer = setTimeout(connect, 3000);
|
||||
es.onerror = () => {
|
||||
/* EventSource 会自己重连;这期间先用轮询顶上,体验不中断 */
|
||||
setStatus("推送断开,轮询中…", "bad");
|
||||
if (!pollTimer) pollTimer = setInterval(syncNow, 1500);
|
||||
};
|
||||
ws.onerror = () => setStatus("连接异常", "bad");
|
||||
}
|
||||
|
||||
function handle(msg) {
|
||||
@@ -263,11 +256,6 @@ function handle(msg) {
|
||||
state = msg.state; if (msg.catalog) catalog = msg.catalog;
|
||||
renderLog(msg.log);
|
||||
render();
|
||||
} else if (msg.type === "result") {
|
||||
toast(msg.message);
|
||||
if (msg.snapshot) { state = msg.snapshot; render(); }
|
||||
else if (msg.state) { refreshFromBrief(msg.state); }
|
||||
renderLog(msg.log);
|
||||
} else if (msg.type === "day") {
|
||||
renderLog(null);
|
||||
} else if (msg.type === "notice") {
|
||||
@@ -277,6 +265,14 @@ function handle(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 一天 40 分钟,直接显示秒数是一串大数,换算成 m:ss */
|
||||
function fmtLeft(sec) {
|
||||
if (sec === null || sec === undefined) return "已暂停";
|
||||
const t = Math.max(0, Math.round(sec));
|
||||
if (t < 60) return t + "s";
|
||||
return Math.floor(t / 60) + ":" + String(t % 60).padStart(2, "0");
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state) return;
|
||||
const s = state;
|
||||
@@ -284,7 +280,7 @@ function render() {
|
||||
["第几天", s.day], ["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
|
||||
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed], ["净资产", Math.round(s.net_worth)],
|
||||
["在线", s.online !== undefined ? s.online : "—"],
|
||||
["下一天", s.seconds_to_tick === null ? "已暂停" : s.seconds_to_tick + "s"]
|
||||
["下一天", fmtLeft(s.seconds_to_tick)]
|
||||
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join("");
|
||||
|
||||
/* 农田 */
|
||||
@@ -409,7 +405,6 @@ $("farm").value = localStorage.getItem("sf.farm") || "default";
|
||||
$("token").value = localStorage.getItem("sf.token") || "";
|
||||
connect();
|
||||
setInterval(renderBoard, 5000);
|
||||
setInterval(() => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" })); }, 20000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -424,7 +419,7 @@ def dashboard_text(host: str, port: int) -> str:
|
||||
f"网页客户端:http://{host}:{port}/\n"
|
||||
f"接口文档 :http://{host}:{port}/docs\n"
|
||||
f"健康检查 :http://{host}:{port}/api/health\n"
|
||||
f"实时通道 :ws://{host}:{port}/ws?farm=<牧场名>\n"
|
||||
f"实时通道 :http://{host}:{port}/api/stream?farm=<牧场名>(SSE 单向推送)\n"
|
||||
"\n"
|
||||
"给 AI / 脚本的最短路径\n"
|
||||
"----------------------\n"
|
||||
@@ -448,7 +443,7 @@ def dashboard_text(host: str, port: int) -> str:
|
||||
'POST /api/actions {"action":"...","params":{...}} 通用入口\n'
|
||||
'POST /api/batch {"actions":[{...},{...}]} 批量\n'
|
||||
"POST /api/plant|water|harvest|harvest_all|buy_animal|feed|\n"
|
||||
" collect|buy_feed|buy_plot|sleep|pause|save|reset 便捷入口\n"
|
||||
" collect|buy_feed|buy_plot|pause|save|reset 便捷入口\n"
|
||||
'POST /api/farms {"farm":"名字"} 创建/进入牧场\n'
|
||||
"POST /api/admin/save 全部存档\n"
|
||||
"POST /api/admin/shutdown 优雅停机\n"
|
||||
|
||||
Reference in new issue
Block a user