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

+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"