"""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 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} async def lifespan(_app: FastAPI) -> Any: 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))