style: 引入 ruff,474 项告警清零

配置见 pyproject.toml:
- line-length=100:贴着现有代码定。若用默认的 88 会重排 122 行,
  噪音淹没真正有价值的修复。
- 启用 E/W/F/I/UP/B/C4/SIM/RET/PERF/RUF;**不**启用 PTH。因为本项目的路径
  多是"字符串身份"——要塞进 subprocess 命令行、写进 JSON 存档、打印给用户,
  全换 pathlib 只是包一层再拆一层,换来 70 处纯风格改动。
- 忽略 RUF001/002/003:中文全角标点是正确写法,不是"歧义字符"。
- 忽略 PERF203:剩下两处都是"逐项容错"语义(逐个牧场存档要立刻上报失败、
  收获全部要跳过失败地块)。把 try 提到循环外会毁掉"一项失败不影响其它项"
  这个行为,是错的优化。

自动修复 358 项:Dict/List/Optional 换成 PEP 585/604 写法、导入排序、
失效的 noqa、空占位 f-string 等。

手工修复的(这些是真问题,不是风格偏好):
- B904 x9:except 中 raise 补异常链。刻意区分——网络/IO 失败用 from exc 保留
  根因;参数解析失败用 from None,因为底层 ValueError 对玩家只是噪音。
- RUF012 x2:StateView 的 plots/animals 原本是**类级共享可变列表**,
  改成在 __init__ 里初始化(apply({}) 会提前返回,所以必须显式摆好)。
- F841:TUI 里一个赋值后再没用过的死变量。
- SIM105 x11:try/except/pass 统一改成 contextlib.suppress。
- SIM115:日志句柄改用 with。Popen 已把 fd 复制给子进程,父进程可安全关闭。
- E501 x7:拆超长行;main.py 那处顺手提取变量,避免超长嵌套表达式。
- E741:把 l/r 改成 left_line/right_line。
- 其余:RUF046 / PERF401 / RET504 / RUF005 / UP031 / B007。

ruff format 重排 24 个文件。

验证:97 项测试全绿。
This commit is contained in:
snowy-yang committed 2026-09-18 21:18:22 +08:00
1 parent c7c8a6308a
commit fcd4cb4649
25 files changed
+1204 -794

No files matched your search

+154 -111
View File
@@ -15,10 +15,12 @@
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, Callable, Dict, List, Optional
from typing import Any
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
@@ -40,7 +42,7 @@ class ServerConfig:
host: str = "127.0.0.1"
port: int = 8765
token: Optional[str] = None
token: str | None = None
tick_seconds: float = config.DEFAULT_TICK_SECONDS
save_dir: str = "saves"
max_sessions: int = 64
@@ -48,7 +50,7 @@ class ServerConfig:
autosave_interval: float = 60.0
allow_remote_control: bool = True
world_name: str = "SimpleFarm 公共农场"
extra: Dict[str, Any] = field(default_factory=dict)
extra: dict[str, Any] = field(default_factory=dict)
class ServerHandle:
@@ -59,8 +61,8 @@ class ServerHandle:
"""
def __init__(self) -> None:
self._start_callbacks: List[Callable[[], None]] = []
self._stop_callbacks: List[Callable[[], None]] = []
self._start_callbacks: list[Callable[[], None]] = []
self._stop_callbacks: list[Callable[[], None]] = []
self.stopping = False
def on_start(self, callback: Callable[[], None]) -> None:
@@ -71,25 +73,23 @@ class ServerHandle:
def notify_start(self) -> None:
for callback in list(self._start_callbacks):
try:
# 单个启动钩子失败不该拖垮整个服务
with contextlib.suppress(Exception):
callback()
except Exception:
pass
def request_stop(self) -> bool:
if self.stopping:
return False
self.stopping = True
for callback in list(self._stop_callbacks):
try:
# 停机要尽力而为:一个钩子抛异常不能拦住其它钩子
with contextlib.suppress(Exception):
callback()
except Exception:
pass
return True
# ---------------------------------------------------------------------- 工具
def brief(session: FarmSession, hub: ConnectionHub) -> Dict[str, Any]:
def brief(session: FarmSession, hub: ConnectionHub) -> dict[str, Any]:
"""操作后回给调用者的精简状态:够做下一步决策,又不至于太吵。"""
snap = session.snapshot()
return {
@@ -125,7 +125,7 @@ def rules_text() -> str:
f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS:.0f} 秒自动推进一天(可配置)。"
f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n"
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天"
"(POST /api/actions {\"action\":\"sleep\"} 可主动推进一天,有 2 秒冷却)。\n"
'(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"
@@ -139,17 +139,17 @@ def rules_text() -> str:
"排行榜见 GET /api/world。\n"
"\n推荐决策循环(AI 最短路径):\n"
" GET /api/actions?farm=me → 拿到当前所有**合法**动作(含开销与理由)\n"
" POST /api/actions → {\"action\":\"<上一步里的 action>\","
"\"params\":{...}} 直接执行\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"
'也可以直接在 WS 上发送 {"type":"action","action":"plant",'
'"params":{"plot":1,"crop":"wheat"},"id":1} 并等待 result 回执。\n'
)
# ---------------------------------------------------------------------- 应用
def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
def create_app(cfg: ServerConfig | None = None) -> FastAPI:
cfg = cfg or ServerConfig()
manager = SessionManager(
save_dir=cfg.save_dir,
@@ -206,12 +206,12 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
)
# ------------------------------------------------------------ 鉴权与取牧场
def authorized(supplied: Optional[str]) -> bool:
def authorized(supplied: str | None) -> bool:
if not cfg.token:
return True
return supplied == cfg.token
def token_of(request: Request) -> Optional[str]:
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:
@@ -227,19 +227,19 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
raw = (request.query_params.get("full") or "").lower()
return raw in ("1", "true", "yes", "on")
async def body_of(request: Request) -> Dict[str, Any]:
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}")
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]:
def execute_action(session: FarmSession, name: Any, params: Any) -> dict[str, Any]:
"""执行一个动作,返回引擎/会话级的结果字典。
``save`` 与 ``reset`` 必须由**牧场会话**处理而不是引擎:
@@ -250,21 +250,24 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
if key == "save":
path = session.save()
session.engine.add_log(f"已存档到 {path}", "info")
return {"ok": True, "action": "save", "path": path,
"message": f"已存档到 {path}"}
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 {
"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]:
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: dict[str, Any] = dict(result)
payload["ok"] = True
payload["farm"] = session.farm_id
payload["seq"] = next_seq()
@@ -285,7 +288,7 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
# ================================================================ 元信息
@app.get("/api/health", tags=["元信息"], summary="健康检查(无需鉴权)")
async def health() -> Dict[str, Any]:
async def health() -> dict[str, Any]:
return {
"ok": True,
"service": SERVER_NAME,
@@ -298,7 +301,7 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
}
@app.get("/api/help", tags=["元信息"], summary="玩法规则与接口说明(给 AI 的说明书)")
async def help_page(request: Request) -> Dict[str, Any]:
async def help_page(request: Request) -> dict[str, Any]:
guard(request)
return {
"ok": True,
@@ -313,14 +316,16 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
"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\"}",
"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]:
async def world(request: Request) -> dict[str, Any]:
guard(request)
return {
"ok": True,
@@ -336,13 +341,13 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
# ================================================================ 牧场
@app.get("/api/farms", tags=["世界"], summary="牧场排行榜")
async def list_farms(request: Request) -> Dict[str, Any]:
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]:
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"))
@@ -354,7 +359,7 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
try:
tick_value = float(tick)
except (TypeError, ValueError):
raise GameError("bad_param", "tick 必须是数字")
raise GameError("bad_param", "tick 必须是数字") from None
session.engine.tick_seconds = max(
config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, tick_value)
)
@@ -370,7 +375,7 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
# ================================================================ 单牧场只读
@app.get("/api/state", tags=["牧场"], summary="完整状态快照")
async def get_state(request: Request) -> Dict[str, Any]:
async def get_state(request: Request) -> dict[str, Any]:
guard(request)
session = session_of(request)
return {
@@ -381,13 +386,13 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
}
@app.get("/api/catalog", tags=["牧场"], summary="作物/牲畜目录与实时价格")
async def get_catalog(request: Request) -> Dict[str, Any]:
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]:
async def get_actions(request: Request) -> dict[str, Any]:
guard(request)
session = session_of(request)
items = session.engine.available_actions()
@@ -400,11 +405,11 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
}
@app.get("/api/log", tags=["牧场"], summary="最近事件")
async def get_log(request: Request) -> Dict[str, Any]:
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)
since = _int(request.query_params.get("since"), 0, 0, 10**9)
entries = session.engine.recent_log(limit=limit, since=since)
return {
"ok": True,
@@ -416,7 +421,7 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
# ================================================================ 写操作
@app.post("/api/actions", tags=["动作"], summary="通用动作入口(推荐 AI 使用)")
async def post_action(request: Request) -> Dict[str, Any]:
async def post_action(request: Request) -> dict[str, Any]:
guard(request)
body = await body_of(request)
session = session_of(request)
@@ -428,7 +433,7 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
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]:
async def post_batch(request: Request) -> dict[str, Any]:
"""给 AI 的省流入口:一次提交多个动作,按顺序执行。
每个动作独立成败,失败的只记错误不中断后续动作。
@@ -441,7 +446,7 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
raise GameError("bad_param", "actions 必须是至少含一个元素的数组")
if len(items) > 50:
raise GameError("too_many", "单次批量最多 50 个动作")
results: List[Dict[str, Any]] = []
results: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
results.append({"ok": False, "error": "bad_param", "message": "每个动作必须是对象"})
@@ -455,7 +460,7 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
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] = {
payload: dict[str, Any] = {
"ok": True,
"farm": session.farm_id,
"done": ok_count,
@@ -468,7 +473,7 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
return payload
def make_action_route(action_name: str) -> Callable[[Request], Any]:
async def route(request: Request) -> Dict[str, Any]:
async def route(request: Request) -> dict[str, Any]:
guard(request)
body = await body_of(request)
session = session_of(request)
@@ -489,19 +494,21 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
# ================================================================ 管理
@app.post("/api/admin/save", tags=["管理"], summary="把所有牧场写入存档")
async def admin_save(request: Request) -> Dict[str, Any]:
async def admin_save(request: Request) -> dict[str, Any]:
guard(request)
saved: List[str] = []
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)
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]:
async def admin_shutdown(request: Request) -> dict[str, Any]:
guard(request)
if not cfg.allow_remote_control:
raise GameError("forbidden", "服务器未开放远程停机", status=403)
@@ -535,33 +542,37 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
session = manager.get(farm, create=True)
except GameError as exc:
# 例如牧场数量达到上限:说清楚原因再关,别让客户端以为是网络问题
await websocket.send_json({"type": "error", "error": exc.code,
"message": exc.message})
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),
})
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)})"},
{
"type": "notice",
"farm": farm,
"text": f"{client.name or client.cid} 来到了牧场(在线 {hub.online(farm)})",
},
exclude=client,
)
@@ -585,12 +596,14 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
await websocket.send_json({"type": "error", "error": "bad_json",
"message": f"不是合法 JSON:{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 对象"})
await websocket.send_json(
{"type": "error", "error": "bad_json", "message": "消息必须是 JSON 对象"}
)
return
kind = str(data.get("type") or "").lower()
@@ -601,55 +614,79 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
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),
})
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()})
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"),
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})
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(),
})
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"})
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())
@@ -666,20 +703,26 @@ def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
session.touch()
hub.broadcast(
session.farm_id,
{"type": "notice", "farm": session.farm_id,
"text": f"{client.name or client.cid} 离开了牧场(在线 {hub.online(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: Optional[str], default: int, low: int, high: int) -> int:
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}")
raise GameError("bad_param", f"参数必须是整数,收到 {raw!r}") from None
return max(low, min(high, value))