多用户之前是「知道牧场名就能玩」:?farm=boss 直接进别人家里。 现在网页端用用户名登录(用户选择:不设密码、一账号一个牧场, 用户名即身份——互信小圈子模型,防的是随手改 URL,不是蓄意攻击)。 * 新增 farm/server/auth.py:HMAC-SHA256 签名的 HttpOnly Cookie (sf_auth,30 天,恒时比较防篡改)。密钥存 <save_dir>/secret.key, 重启不掉线,也不用建表存 token。 * app.py 三个认 ?farm= 的地方(session_of / SSE / POST /api/farms) 统一规则:有 Cookie 强制本人的牧场(?farm=/X-Farm-Id 被忽略), 无 Cookie 保持旧语义——bot/TUI/CLI 脚本零改动照常工作。 * 新路由:POST /api/login(全局 token 照常把它挡在门外)、 POST /api/logout、GET /api/me(刻意不 guard:首屏只问"登录没")。 * 网页:移除牧场名输入框,改为登录遮罩 + 用户徽章 + 退出按钮; 首屏先 whoami() 再决定连接还是弹登录。 测试 +13:Cookie 伪造/过期/跨密钥/垃圾串/非法用户名/密钥跨重启复用/ session_of 优先级(starlette Request 用 scope dict 直接构造,不需要 httpx)9 项纯逻辑档 + 4 项 TestClient 档。528 项 OK(skip 39)。 真机验收 16/16:登录/HttpOnly/?farm= 被忽略/动作落自己牧场/篡改 退回未登录/退出/非法名 400;跨重启 Cookie 仍有效且进度保留。
809 lines
35 KiB
Python
809 lines
35 KiB
Python
"""FastAPI 应用:REST + SSE + 网页客户端。
|
||
|
||
接口设计原则
|
||
------------
|
||
1. **一切皆 JSON**:每个响应都带 ``ok`` 字段,出错时带机器可读的 ``error``
|
||
与人类可读的 ``message``,AI 可以直接拿 ``error`` 分支决策。
|
||
2. **状态紧随结果**:每个写操作都会在响应里附一份精简状态(``state``),
|
||
这样 AI 一次请求就能同时"做动作 + 看结果",省一半往返。
|
||
3. **多牧场隔离**:网页用用户名登录(Cookie),登录后只能操作自己的牧场;
|
||
脚本/AI 用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家,
|
||
每个牧场有自己的引擎、时钟与存档;互不影响。
|
||
4. **实时通道**:``GET /api/stream`` 是 SSE 长连接,只负责**服务器 → 客户端**
|
||
的推送(状态、跨天、有人来串门);写操作统一走 ``POST /api/actions``。
|
||
两者改的是同一个引擎,所以「网页里点一下,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, Response
|
||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
|
||
|
||
from .. import config
|
||
from ..engine import GameError
|
||
from ..storage import StorageError
|
||
from . import actions as actions_mod
|
||
from .auth import COOKIE_NAME, TTL_SECONDS, LoginManager
|
||
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
|
||
|
||
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``:ASGI 生命周期启动时触发(用于写 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
|
||
|
||
|
||
# ---------------------------------------------------------------------- 工具
|
||
# brief() 从精简状态里挑选的字段白名单:够 AI 做下一步决策,又不至于太吵。
|
||
# 数据源是 FarmEngine.brief_source()(**不是**完整快照),加字段时两处一起改。
|
||
BRIEF_KEYS = (
|
||
"day",
|
||
"weather",
|
||
"weather_name",
|
||
"forecast",
|
||
"forecast_name",
|
||
"gold",
|
||
"level",
|
||
"exp",
|
||
"exp_needed",
|
||
"ap",
|
||
"max_ap",
|
||
"feed",
|
||
"plot_count",
|
||
"max_plots",
|
||
"next_plot_price",
|
||
"animal_count",
|
||
"max_animals",
|
||
"storage_used",
|
||
"granary_capacity",
|
||
"granary_free",
|
||
"building_count",
|
||
"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]:
|
||
"""操作后回给调用者的精简状态。
|
||
|
||
只读引擎直接给出的那十几个标量,**不做完整快照**。以前这里是
|
||
``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 / 60:.0f} 分钟"
|
||
f"({config.DEFAULT_TICK_SECONDS:.0f} 秒)自动推进一天,可配置。"
|
||
f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n"
|
||
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天——"
|
||
"时间只能自然流动,没有任何「手动推进一天」的接口,别白费请求。\n"
|
||
f"3. 天气与水分:每天随机一种天气(晴/多云/雨/干旱/暴风雨)。作物每天按天气蒸发"
|
||
f" 0~2 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长;"
|
||
f"播种自带 {config.PLANT_INITIAL_MOISTURE} 点。雨天与暴风雨会自动补满水分,"
|
||
"暴风雨还可能毁掉未成熟的作物(温室可免疫)。state.forecast 是明天的预报——"
|
||
"提前一天浇水就能避免断水停长。\n"
|
||
"4. 收获与谷仓:harvest / collect 的产出**先进谷仓,不直接换钱**,再用 sell "
|
||
"按当时的市价卖出。仓容 = 基础 "
|
||
f"{config.GRANARY_BASE} + 等级成长 + 谷仓建筑,装不下时收获会被拒绝"
|
||
"(granary_full),先卖货或者建谷仓。\n"
|
||
"5. 牲畜:每天必须喂食才会累计产出周期;连续 3 天未进食会永久离开农场。\n"
|
||
f"6. 市场:作物与畜产品价格每天在基础价 ±{config.PRICE_VOLATILITY:.0%} 内波动。"
|
||
'有了谷仓之后,"什么时候卖"才真正影响收益:囤到高位再 sell。\n'
|
||
f"7. 土地:初始 {config.BASE_PLOTS} 块,每升 1 级多 {config.PLOTS_PER_LEVEL} 块,"
|
||
f"上限 {config.MAX_PLOTS} 块;购地价格每次递增。\n"
|
||
"8. 建筑:build 建造 granary / well / feeder / greenhouse。谷仓加仓容、"
|
||
"水井每天自动浇 1 块干透的地、喂食器每天自动喂 1 只牲畜(只吃库存饲料,"
|
||
"不会替你花钱)、温室免疫暴风雨且水分蒸发减半。同类建筑越造越贵。\n"
|
||
f"9. 目标:把净资产(金币 + 土地 + 牲畜 + 饲料 + 地里作物 + **谷仓存货**)"
|
||
f"做到 {config.GOALS[-1][0]} 金币。\n"
|
||
'10. 多人:网页用用户名登录(POST /api/login "username",Cookie 绑定同名牧场,'
|
||
"登录后只能操作自己的);脚本/AI 无 Cookie 时用 ?farm=名字 区分,互不影响;"
|
||
"排行榜见 GET /api/world。\n"
|
||
"\n推荐决策循环(AI 最短路径):\n"
|
||
" GET /api/actions?farm=me → 拿到当前所有**合法**动作(含开销与理由)\n"
|
||
' POST /api/actions → {"action":"<上一步里的 action>",'
|
||
'"params":{...}} 直接执行\n'
|
||
" 响应里的 state 就是执行后的最新状态,无需再查一次。\n"
|
||
"\n实时通道(单向推送,服务器 → 客户端):GET /api/stream?farm=me 是一条 SSE "
|
||
'长连接,服务器在状态变化时推 {"type":"state",...};\n'
|
||
"想主动拉一次用 GET /api/state;写操作一律 POST /api/actions。\n"
|
||
)
|
||
|
||
|
||
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
|
||
auth: LoginManager
|
||
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,
|
||
auth=LoginManager(cfg.save_dir),
|
||
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},
|
||
)
|
||
|
||
@contextlib.asynccontextmanager
|
||
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:
|
||
self.publisher.stop()
|
||
# 兜底:万一是信号触发退出(没走 /api/admin/shutdown),也把长连接收掉
|
||
self.hub.close_all()
|
||
self.clock.stop()
|
||
self.manager.shutdown()
|
||
|
||
# ------------------------------------------------------------ 鉴权与取牧场
|
||
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(self, request: Request) -> None:
|
||
"""计数 + 鉴权。所有对外路由的第一句都调它。"""
|
||
self.counters["requests"] += 1
|
||
if not self.authorized(self.token_of(request)):
|
||
raise GameError("unauthorized", "缺少或错误的 API Token", status=401)
|
||
|
||
def user_of(self, request: Request) -> str | None:
|
||
"""登录 Cookie 里的用户名;没登录返回 None。"""
|
||
return self.auth.verify(request.cookies.get(COOKIE_NAME))
|
||
|
||
def session_of(self, request: Request, owner: str = "") -> FarmSession:
|
||
user = self.user_of(request)
|
||
if user:
|
||
# 登录后只能玩自己的牧场:?farm= / X-Farm-Id 被忽略,
|
||
# 改 URL 碰不到别人的档。没有 Cookie 的脚本保持旧语义(互信小圈子)。
|
||
farm = user
|
||
else:
|
||
farm = (
|
||
request.headers.get("x-farm-id") or request.query_params.get("farm") or DEFAULT_FARM
|
||
)
|
||
return self.manager.get(farm, create=True, owner=user or 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:
|
||
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(self, session: FarmSession, name: Any, params: Any) -> dict[str, Any]:
|
||
"""执行一个动作,返回引擎/会话级的结果字典。
|
||
|
||
``save`` 与 ``reset`` 是**存档类动作**,刻意不在 farm.server.actions
|
||
的通用动作表里(那张表只认识引擎方法,而 ``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(
|
||
self, session: FarmSession, name: Any, params: Any, full: bool = False
|
||
) -> dict[str, Any]:
|
||
"""执行动作并拼装统一响应。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, 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(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() - 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]:
|
||
svc.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,
|
||
"buildings": config.BUILDINGS,
|
||
"weather": config.WEATHER,
|
||
"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": "GET /api/stream?farm=NAME(SSE 单向推送)",
|
||
},
|
||
}
|
||
|
||
|
||
def _register_auth_routes(app: FastAPI, svc: _Services) -> None:
|
||
@app.post("/api/login", tags=["登录"], summary="用户名登录(设置 HttpOnly Cookie)")
|
||
async def login(request: Request, response: Response) -> dict[str, Any]:
|
||
"""用户名即身份(互信小圈子模型):登录后 Cookie 绑定同名牧场。"""
|
||
svc.guard(request)
|
||
body = await svc.body_of(request)
|
||
user = validate_farm_id(body.get("username") or body.get("user") or body.get("name"))
|
||
response.set_cookie(
|
||
COOKIE_NAME,
|
||
svc.auth.issue(user),
|
||
max_age=TTL_SECONDS,
|
||
httponly=True,
|
||
samesite="lax",
|
||
path="/",
|
||
)
|
||
return {"ok": True, "user": user, "farm": user, "message": f"欢迎回来,{user}!"}
|
||
|
||
@app.post("/api/logout", tags=["登录"], summary="退出登录(清除 Cookie)")
|
||
async def logout(request: Request, response: Response) -> dict[str, Any]:
|
||
svc.guard(request)
|
||
response.delete_cookie(COOKIE_NAME, path="/")
|
||
return {"ok": True, "message": "已退出登录"}
|
||
|
||
@app.get("/api/me", tags=["登录"], summary="当前登录的用户")
|
||
async def me(request: Request) -> dict[str, Any]:
|
||
# 刻意不 guard:只回答"登录了没有",不泄露任何牧场数据;
|
||
# 网页首屏第一件事就是问它。
|
||
return {"ok": True, "user": svc.user_of(request)}
|
||
|
||
|
||
def _register_world_routes(app: FastAPI, svc: _Services) -> None:
|
||
@app.get("/api/world", tags=["世界"], summary="世界概览:排行榜 + 在线连接 + 时钟")
|
||
async def world(request: Request) -> dict[str, Any]:
|
||
svc.guard(request)
|
||
return {
|
||
"ok": True,
|
||
"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]:
|
||
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]:
|
||
svc.guard(request)
|
||
body = await svc.body_of(request)
|
||
# 登录后牧场固定为本人:改 body 也进不了别人的牧场
|
||
user = svc.user_of(request)
|
||
farm = user or validate_farm_id(body.get("farm") or body.get("name"))
|
||
owner = user or str(body.get("owner") or "")
|
||
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)
|
||
)
|
||
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(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]:
|
||
svc.guard(request)
|
||
session = svc.session_of(request)
|
||
return {
|
||
"ok": True,
|
||
"farm": session.farm_id,
|
||
"state": session.snapshot(),
|
||
"online": svc.hub.online(session.farm_id),
|
||
}
|
||
|
||
@app.get("/api/catalog", tags=["牧场"], summary="作物/牲畜目录与实时价格")
|
||
async def get_catalog(request: Request) -> dict[str, Any]:
|
||
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]:
|
||
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, svc.hub),
|
||
}
|
||
|
||
@app.get("/api/log", tags=["牧场"], summary="最近事件")
|
||
async def get_log(request: Request) -> dict[str, Any]:
|
||
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)
|
||
return {
|
||
"ok": True,
|
||
"farm": session.farm_id,
|
||
"count": len(entries),
|
||
"entries": entries,
|
||
"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]:
|
||
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 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]:
|
||
"""给 AI 的省流入口:一次提交多个动作,按顺序执行。
|
||
|
||
每个动作独立成败,失败的只记错误不中断后续动作。
|
||
"""
|
||
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 必须是至少含一个元素的数组")
|
||
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 = 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:
|
||
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] = {
|
||
"ok": True,
|
||
"farm": session.farm_id,
|
||
"done": ok_count,
|
||
"failed": len(results) - ok_count,
|
||
"results": results,
|
||
"state": brief(session, svc.hub),
|
||
}
|
||
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]:
|
||
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}",
|
||
make_action_route(_name),
|
||
methods=["POST"],
|
||
name=f"action_{_name}",
|
||
tags=["动作"],
|
||
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]:
|
||
svc.guard(request)
|
||
saved: list[str] = []
|
||
for session in svc.manager.all():
|
||
try:
|
||
session.save()
|
||
saved.append(session.farm_id)
|
||
except (OSError, StorageError) 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]:
|
||
svc.guard(request)
|
||
if not svc.cfg.allow_remote_control:
|
||
raise GameError("forbidden", "服务器未开放远程停机", status=403)
|
||
svc.manager.shutdown()
|
||
# 先让所有 SSE 长连接收尾:否则服务器的优雅停机要一直等它们,变成卡死
|
||
svc.hub.close_all()
|
||
accepted = svc.handle.request_stop()
|
||
return {
|
||
"ok": True,
|
||
"message": "服务器正在优雅停机,存档已写入" if accepted else "停机请求已受理(重复)",
|
||
}
|
||
|
||
|
||
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
|
||
# 登录用户只能听自己的牧场;没有 Cookie(脚本/TUI)用 ?farm=
|
||
farm = svc.user_of(request) or validate_farm_id(params.get("farm") or DEFAULT_FARM)
|
||
# 牧场数达上限等情况会抛 GameError,交给全局处理器回 4xx JSON
|
||
session = svc.manager.get(farm, create=True)
|
||
|
||
loop = asyncio.get_running_loop()
|
||
client = Client(farm, loop, name=(params.get("name") or "")[:32])
|
||
svc.hub.register(client)
|
||
session.touch()
|
||
|
||
# "来了/走了"是同一件事的两个方向,差别只有动词,所以共用一个信封
|
||
who = client.name or client.cid
|
||
|
||
def notice(text: str, exclude: Client | None = None) -> None:
|
||
svc.hub.broadcast(farm, {"type": "notice", "farm": farm, "text": text}, exclude=exclude)
|
||
|
||
async def events() -> AsyncIterator[str]:
|
||
# 首帧等价于原来的 welcome:一次把状态、目录、合法动作、日志给全
|
||
yield sse_frame(
|
||
{
|
||
"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),
|
||
}
|
||
)
|
||
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_auth_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
|
||
|
||
|
||
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))
|