Files
SimpleFarm/farm/server/app.py
T
snowy-yang d7a7107b0a chore: 引入 pyright 类型检查清零 + README 工程化章节
pyright(basic 模式)从 15 errors / 10 warnings 降到 0/0:

- pyproject: pythonPlatform="All"(termios/msvcrt 双平台分支不再误报),
  reportUnsupportedDunderAll=false(__init__ 惰性导出属正常写法)
- keys.py: 捕获 sys.stdin 局部变量 + 提前返回,消除 fileno() 的 None 收窄告警
- server/app.py: lifespan 改用 @asynccontextmanager 并标注 AsyncIterator[None]
- server/actions.py: 显式标注 result: dict[str, Any],消除 isinstance 冗余报错
- server/__main__.py: 用 importlib.util.find_spec 探测 websockets
- tests/test_server.py: 同上,uvicorn 探测改为 find_spec
- tools/preview_tui.py: TYPE_CHECKING 下引入 RemoteFarm,修正返回类型标注
- farm/tui.py: 取消未使用变量,统一 round/取整写法

其他:
- README: 新增第 11 节「工程化:uv + ruff + pyright」,快速开始全部改用
  uv sync / uv run;原第 11 节起顺延为 12
- requirements.txt: 由 uv export --no-dev --no-hashes 重新生成(锁定版本)
- 清理临时日志文件

验收:ruff check 全通过 / ruff format 26 文件无改动 / pyright 0 错误 /
unittest 97 项全部通过
2026-09-18 21:24:30 +08:00

735 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FastAPI 应用:REST + WebSocket + 网页客户端。
接口设计原则
------------
1. **一切皆 JSON**:每个响应都带 ``ok`` 字段,出错时带机器可读的 ``error``
与人类可读的 ``message``,AI 可以直接拿 ``error`` 分支决策。
2. **状态紧随结果**:每个写操作都会在响应里附一份精简状态(``state``),
这样 AI 一次请求就能同时"做动作 + 看结果",省一半往返。
3. **多牧场隔离**:用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家,
每个牧场有自己的引擎、时钟与存档;互不影响。
4. **实时通道**:``/ws`` 推送状态,人和 AI 都能订阅;HTTP 与 WS 改的是
同一个引擎,所以「网页里点一下,AI 那边立刻看到」。
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import time
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 .. import config
from ..engine import GameError
from . import actions as actions_mod
from .hub import Client, ConnectionHub, Publisher, next_seq
from .session import DEFAULT_FARM, FarmClock, FarmSession, SessionManager, validate_farm_id
from .webui import DASHBOARD_HTML, dashboard_text
API_VERSION = "2.0"
SERVER_NAME = "SimpleFarm"
@dataclass
class ServerConfig:
"""服务器配置(由命令行解析而来)。"""
host: str = "127.0.0.1"
port: int = 8765
token: str | None = None
tick_seconds: float = config.DEFAULT_TICK_SECONDS
save_dir: str = "saves"
max_sessions: int = 64
idle_timeout: float = 1800.0
autosave_interval: float = 60.0
allow_remote_control: bool = True
world_name: str = "SimpleFarm 公共农场"
extra: dict[str, Any] = field(default_factory=dict)
class ServerHandle:
"""让 HTTP 接口能够优雅地要求本进程退出,并暴露进程生命周期的钩子。
* ``on_start``:监听端口成功后触发(用于写 pid 文件等);
* ``on_stop``:请求关闭时触发(uvicorn 用它设置 ``should_exit``)。
"""
def __init__(self) -> None:
self._start_callbacks: list[Callable[[], None]] = []
self._stop_callbacks: list[Callable[[], None]] = []
self.stopping = False
def on_start(self, callback: Callable[[], None]) -> None:
self._start_callbacks.append(callback)
def on_stop(self, callback: Callable[[], None]) -> None:
self._stop_callbacks.append(callback)
def notify_start(self) -> None:
for callback in list(self._start_callbacks):
# 单个启动钩子失败不该拖垮整个服务
with contextlib.suppress(Exception):
callback()
def request_stop(self) -> bool:
if self.stopping:
return False
self.stopping = True
for callback in list(self._stop_callbacks):
# 停机要尽力而为:一个钩子抛异常不能拦住其它钩子
with contextlib.suppress(Exception):
callback()
return True
# ---------------------------------------------------------------------- 工具
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),
}
def rules_text() -> str:
return (
f"{SERVER_NAME} 规则速览(给 AI 玩家的上下文)\n"
"==========================================\n"
f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS:.0f} 秒自动推进一天(可配置)。"
f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n"
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天"
'(POST /api/actions {"action":"sleep"} 可主动推进一天,有 2 秒冷却)。\n'
f"3. 水分:作物每天消耗 1 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长。"
f"播种时自带 {config.PLANT_INITIAL_MOISTURE} 点水分。\n"
f"4. 收获:成熟后 harvest 获得金币与经验,有 {config.LUCKY_CHANCE:.0%} 概率丰收翻倍。\n"
"5. 牲畜:每天必须喂食才会累计产出周期;连续 3 天未进食会永久离开农场。\n"
f"6. 市场:作物与畜产品价格每天在基础价 ±{config.PRICE_VOLATILITY:.0%} 内波动,"
"高价卖出收益更高。\n"
f"7. 土地:初始 {config.BASE_PLOTS} 块,每升 1 级多 {config.PLOTS_PER_LEVEL} 块,"
f"上限 {config.MAX_PLOTS} 块;购地价格每次递增。\n"
"8. 目标:把净资产(金币 + 土地 + 牲畜 + 库存 + 地里作物)做到 10000 金币。\n"
"9. 多人:每个 farm 是一个独立牧场,用 ?farm=名字 区分,互不影响;"
"排行榜见 GET /api/world。\n"
"\n推荐决策循环(AI 最短路径):\n"
" GET /api/actions?farm=me → 拿到当前所有**合法**动作(含开销与理由)\n"
' 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'
)
# ---------------------------------------------------------------------- 应用
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}
# 标注成 asynccontextmanager:FastAPI 的 lifespan 参数要的就是
# "返回异步上下文管理器",裸 async generator 在类型上并不匹配
@contextlib.asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
clock.start()
publisher.start()
# 端口已经绑定成功(uvicorn 先 bind 再跑 lifespan),可以对外宣告"我在了"
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,
)
# ------------------------------------------------------------ 鉴权与取牧场
def authorized(supplied: str | None) -> bool:
if not cfg.token:
return True
return supplied == cfg.token
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)):
raise GameError("unauthorized", "缺少或错误的 API Token", status=401)
def session_of(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)
def full_requested(request: Request) -> bool:
raw = (request.query_params.get("full") or "").lower()
return raw in ("1", "true", "yes", "on")
async def body_of(request: Request) -> dict[str, Any]:
raw = await request.body()
if not raw:
return {}
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise GameError("bad_json", f"请求体不是合法 JSON:{exc}") from exc
if not isinstance(data, dict):
raise GameError("bad_json", "请求体必须是 JSON 对象")
return data
def execute_action(session: FarmSession, name: Any, params: Any) -> dict[str, Any]:
"""执行一个动作,返回引擎/会话级的结果字典。
``save`` 与 ``reset`` 必须由**牧场会话**处理而不是引擎:
``engine.save()`` 只会写到默认存档路径,多牧场环境下会把别人的存档覆盖掉。
这两个动作单独拦截,其余全部走统一的动作表。
"""
key = name.strip().lower() if isinstance(name, str) else ""
if key == "save":
path = session.save()
session.engine.add_log(f"已存档到 {path}", "info")
return {"ok": True, "action": "save", "path": path, "message": f"已存档到 {path}"}
if key == "reset":
session.reset()
return {
"ok": True,
"action": "reset",
"message": f"牧场「{session.farm_id}」已重置,重新开始经营",
}
return actions_mod.run_action(session.engine, name, params)
def run_and_wrap(
session: FarmSession, name: Any, params: Any, full: bool = False
) -> dict[str, Any]:
"""执行动作并拼装统一响应。HTTP 与 WS 共用。"""
result = execute_action(session, name, params)
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["log"] = session.engine.recent_log(limit=3)
if full:
payload["snapshot"] = session.snapshot()
return payload
# ================================================================ 页面
@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))
# ================================================================ 元信息
@app.get("/api/health", tags=["元信息"], summary="健康检查(无需鉴权)")
async def health() -> dict[str, Any]:
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,
}
@app.get("/api/help", tags=["元信息"], summary="玩法规则与接口说明(给 AI 的说明书)")
async def help_page(request: Request) -> dict[str, Any]:
guard(request)
return {
"ok": True,
"service": SERVER_NAME,
"version": API_VERSION,
"rules": rules_text(),
"actions": actions_mod.describe_actions(),
"crops": config.CROPS,
"animals": config.ANIMALS,
"endpoints": {
"state": "GET /api/state?farm=NAME",
"catalog": "GET /api/catalog?farm=NAME",
"actions": "GET /api/actions?farm=NAME",
"log": "GET /api/log?farm=NAME&limit=20&since=0",
"execute": (
'POST /api/actions {"action":"plant","params":{"plot":1,"crop":"wheat"}}'
),
"farms": 'GET /api/farms | POST /api/farms {"farm":"NAME"}',
"realtime": "WS /ws?farm=NAME",
},
}
@app.get("/api/world", tags=["世界"], summary="世界概览:排行榜 + 在线连接 + 时钟")
async def world(request: Request) -> dict[str, Any]:
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,
}
# ================================================================ 牧场
@app.get("/api/farms", tags=["世界"], summary="牧场排行榜")
async def list_farms(request: Request) -> dict[str, Any]:
guard(request)
rows = manager.leaderboard(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)
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)
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)
)
session.engine.next_tick_at = time.time() + session.engine.tick_seconds
return {
"ok": True,
"created": not existed,
"message": ("牧场已创建" if not existed else "牧场已在运行") + f":{farm}",
"farm": farm,
"summary": session.summary(hub.online(farm)),
"state": brief(session, hub),
}
# ================================================================ 单牧场只读
@app.get("/api/state", tags=["牧场"], summary="完整状态快照")
async def get_state(request: Request) -> dict[str, Any]:
guard(request)
session = session_of(request)
return {
"ok": True,
"farm": session.farm_id,
"state": session.snapshot(),
"online": 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)
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)
items = session.engine.available_actions()
return {
"ok": True,
"farm": session.farm_id,
"count": len(items),
"actions": items,
"state": brief(session, hub),
}
@app.get("/api/log", tags=["牧场"], summary="最近事件")
async def get_log(request: Request) -> dict[str, Any]:
guard(request)
session = 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)
return {
"ok": True,
"farm": session.farm_id,
"count": len(entries),
"entries": entries,
"last_seq": entries[-1]["seq"] if entries else since,
}
# ================================================================ 写操作
@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)
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))
@app.post("/api/batch", tags=["动作"], summary="批量执行动作(一个请求干多件事)")
async def post_batch(request: Request) -> dict[str, Any]:
"""给 AI 的省流入口:一次提交多个动作,按顺序执行。
每个动作独立成败,失败的只记错误不中断后续动作。
"""
guard(request)
body = await body_of(request)
session = session_of(request)
items = body.get("actions")
if not isinstance(items, list) or not items:
raise GameError("bad_param", "actions 必须是至少含一个元素的数组")
if len(items) > 50:
raise GameError("too_many", "单次批量最多 50 个动作")
results: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
results.append({"ok": False, "error": "bad_param", "message": "每个动作必须是对象"})
continue
try:
payload = 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
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] = {
"ok": True,
"farm": session.farm_id,
"done": ok_count,
"failed": len(results) - ok_count,
"results": results,
"state": brief(session, hub),
}
if 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))
route.__name__ = f"post_{action_name}"
return route
for _name, _spec in actions_mod.ACTIONS.items():
app.add_api_route(
f"/api/{_name}",
make_action_route(_name),
methods=["POST"],
name=f"action_{_name}",
tags=["动作"],
summary=f"{_spec.summary}(等价于 POST /api/actions)",
)
# ================================================================ 管理
@app.post("/api/admin/save", tags=["管理"], summary="把所有牧场写入存档")
async def admin_save(request: Request) -> dict[str, Any]:
guard(request)
saved: list[str] = []
for session in manager.all():
try:
session.save()
saved.append(session.farm_id)
except OSError as exc:
raise GameError(
"save_failed", f"{session.farm_id} 存档失败:{exc}", status=500
) from exc
return {"ok": True, "message": f"已保存 {len(saved)} 个牧场", "farms": saved}
@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:
raise GameError("forbidden", "服务器未开放远程停机", status=403)
manager.shutdown()
accepted = 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
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)
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,
)
async def writer() -> None:
while True:
message = await client.next_message()
await websocket.send_json(message)
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(
{
"type": "error",
"error": "unknown_message",
"message": f"不认识的消息类型 {kind!r}",
"hint": "可用:action / ping / sync / help",
}
)
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)})"
),
},
)
return app
def _int(raw: str | None, default: int, low: int, high: int) -> int:
if raw is None or raw == "":
return default
try:
value = int(raw)
except (TypeError, ValueError):
raise GameError("bad_param", f"参数必须是整数,收到 {raw!r}") from None
return max(low, min(high, value))
def build_app(host: str = "127.0.0.1", port: int = 8765, **kwargs: Any) -> FastAPI:
"""给外部(测试、嵌入式调用)用的便捷工厂。"""
return create_app(ServerConfig(host=host, port=port, **kwargs))