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

+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