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

+29 -12
View File
@@ -22,19 +22,38 @@ from __future__ import annotations
from typing import Any
_LAZY = {
"API_VERSION", "SERVER_NAME", "ServerConfig", "ServerHandle",
"build_app", "create_app", "rules_text", "brief",
"Action", "Param", "ACTIONS", "run_action", "describe_actions", "action_names",
"FarmSession", "SessionManager", "FarmClock", "validate_farm_id", "DEFAULT_FARM",
"Client", "ConnectionHub", "Publisher",
"DaemonManager", "pid_alive",
"DASHBOARD_HTML", "dashboard_text",
"API_VERSION",
"SERVER_NAME",
"ServerConfig",
"ServerHandle",
"build_app",
"create_app",
"rules_text",
"brief",
"Action",
"Param",
"ACTIONS",
"run_action",
"describe_actions",
"action_names",
"FarmSession",
"SessionManager",
"FarmClock",
"validate_farm_id",
"DEFAULT_FARM",
"Client",
"ConnectionHub",
"Publisher",
"DaemonManager",
"pid_alive",
"DASHBOARD_HTML",
"dashboard_text",
}
__all__ = sorted(_LAZY)
def __getattr__(name: str) -> Any: # PEP 562:模块级惰性属性
def __getattr__(name: str) -> Any: # PEP 562:模块级惰性属性
if name not in _LAZY:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
@@ -43,11 +62,9 @@ def __getattr__(name: str) -> Any: # PEP 562:模块级惰性属性
module = import_module("farm.server.daemon")
elif name in ("DASHBOARD_HTML", "dashboard_text"):
module = import_module("farm.server.webui")
elif name in ("Action", "Param", "ACTIONS", "run_action", "describe_actions",
"action_names"):
elif name in ("Action", "Param", "ACTIONS", "run_action", "describe_actions", "action_names"):
module = import_module("farm.server.actions")
elif name in ("FarmSession", "SessionManager", "FarmClock", "validate_farm_id",
"DEFAULT_FARM"):
elif name in ("FarmSession", "SessionManager", "FarmClock", "validate_farm_id", "DEFAULT_FARM"):
module = import_module("farm.server.session")
elif name in ("Client", "ConnectionHub", "Publisher"):
module = import_module("farm.server.hub")
+43 -23
View File
@@ -20,28 +20,40 @@ def build_parser() -> argparse.ArgumentParser:
description="SimpleFarm 游戏服务器:HTTP 接口给 AI,WebSocket 给实时界面。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--host", default="127.0.0.1",
help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0")
parser.add_argument(
"--host", default="127.0.0.1", help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0"
)
parser.add_argument("--port", type=int, default=8765, help="监听端口,默认 8765")
parser.add_argument("--token", default=None, help="访问令牌,设置后所有接口都要带")
parser.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS,
help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒")
parser.add_argument(
"--tick",
type=float,
default=config.DEFAULT_TICK_SECONDS,
help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒",
)
parser.add_argument("--save-dir", default=None, help="存档目录,默认 <项目根>/saves")
parser.add_argument("--save", dest="save_file", default=None,
help="兼容旧参数:指定 default 牧场的存档文件")
parser.add_argument(
"--save", dest="save_file", default=None, help="兼容旧参数:指定 default 牧场的存档文件"
)
parser.add_argument("--max-sessions", type=int, default=64, help="同时在线的牧场上限")
parser.add_argument("--idle-timeout", type=float, default=1800.0,
help="无人访问多少秒后把牧场移出内存(存档保留),默认 1800")
parser.add_argument(
"--idle-timeout",
type=float,
default=1800.0,
help="无人访问多少秒后把牧场移出内存(存档保留),默认 1800",
)
parser.add_argument("--world", default="SimpleFarm 公共农场", help="世界名称")
parser.add_argument("--log-level", default="info",
choices=["critical", "error", "warning", "info", "debug"])
parser.add_argument(
"--log-level", default="info", choices=["critical", "error", "warning", "info", "debug"]
)
parser.add_argument("--access-log", action="store_true", help="打印每个 HTTP 请求")
parser.add_argument("--no-remote-control", action="store_true",
help="禁止通过接口停机")
parser.add_argument("--daemon", action="store_true",
help="转到后台运行(等价于 python main.py serve --daemon)")
parser.add_argument("--pid-file", default=None,
help="把本进程 pid 写到该文件(守护进程模式会自动指定)")
parser.add_argument("--no-remote-control", action="store_true", help="禁止通过接口停机")
parser.add_argument(
"--daemon", action="store_true", help="转到后台运行(等价于 python main.py serve --daemon)"
)
parser.add_argument(
"--pid-file", default=None, help="把本进程 pid 写到该文件(守护进程模式会自动指定)"
)
parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help=argparse.SUPPRESS)
return parser
@@ -68,8 +80,11 @@ def main(argv=None) -> int:
if args.daemon:
manager = DaemonManager(
host=args.host, port=args.port, token=args.token,
tick=clamp_tick(args.tick), save_dir=save_dir,
host=args.host,
port=args.port,
token=args.token,
tick=clamp_tick(args.tick),
save_dir=save_dir,
pid_file=args.pid_file or DEFAULT_PID_FILE,
log_file=args.log_file or DEFAULT_LOG_FILE,
)
@@ -84,6 +99,7 @@ def main(argv=None) -> int:
return 2
try:
import websockets # noqa: F401 只是探测,uvicorn 需要它来做 WS 协议升级
ws_ok = True
except ImportError:
ws_ok = False
@@ -135,8 +151,10 @@ def main(argv=None) -> int:
print(f" 存档目录 {save_dir}")
print(f" 实时通道 ws://{cfg.host}:{cfg.port}/ws?farm=<牧场名>")
if not ws_ok:
print(" ⚠ 未检测到 websockets 库,/ws 实时通道可能不可用;"
"网页端会自动降级为轮询。安装:pip install 'uvicorn[standard]'")
print(
" ⚠ 未检测到 websockets 库,/ws 实时通道可能不可用;"
"网页端会自动降级为轮询。安装:pip install 'uvicorn[standard]'"
)
if cfg.token:
print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数")
@@ -146,15 +164,17 @@ def main(argv=None) -> int:
if args.pid_file:
try:
if os.path.exists(args.pid_file):
with open(args.pid_file, "r", encoding="utf-8") as handle:
with open(args.pid_file, encoding="utf-8") as handle:
if handle.read().strip() == str(os.getpid()):
os.remove(args.pid_file)
except OSError:
pass
if not getattr(server, "started", False):
print(f"启动失败:端口 {cfg.port} 可能已被占用。"
f"换一个端口试试:--port {cfg.port + 1}", file=sys.stderr)
print(
f"启动失败:端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}",
file=sys.stderr,
)
return 3
print("服务器已停止。")
return 0
+113 -48
View File
@@ -13,7 +13,8 @@
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from collections.abc import Callable
from typing import Any
from .. import config
from ..engine import FarmEngine, GameError
@@ -22,12 +23,13 @@ from ..engine import FarmEngine, GameError
class Param:
"""一个动作参数的规格说明(同时用于校验与自动生成给 AI 的文档)。"""
__slots__ = ("name", "kind", "required", "default", "desc")
__slots__ = ("default", "desc", "kind", "name", "required")
def __init__(self, name: str, kind: str, required: bool = True,
default: Any = None, desc: str = "") -> None:
def __init__(
self, name: str, kind: str, required: bool = True, default: Any = None, desc: str = ""
) -> None:
self.name = name
self.kind = kind # "int" / "str" / "bool"
self.kind = kind # "int" / "str" / "bool"
self.required = required
self.default = default
self.desc = desc
@@ -58,7 +60,7 @@ class Param:
raise GameError("bad_param", f"参数 {self.name} 需要字符串,收到 {raw!r}")
return raw
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"type": self.kind,
@@ -71,18 +73,23 @@ class Param:
class Action:
"""一个可执行动作。"""
__slots__ = ("name", "summary", "params", "handler", "realtime")
__slots__ = ("handler", "name", "params", "realtime", "summary")
def __init__(self, name: str, summary: str, params: List[Param],
handler: Callable[[FarmEngine, Dict[str, Any]], Dict[str, Any]],
realtime: bool = True) -> None:
def __init__(
self,
name: str,
summary: str,
params: list[Param],
handler: Callable[[FarmEngine, dict[str, Any]], dict[str, Any]],
realtime: bool = True,
) -> None:
self.name = name
self.summary = summary
self.params = params
self.handler = handler
self.realtime = realtime
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return {
"action": self.name,
"summary": self.summary,
@@ -91,12 +98,12 @@ class Action:
# ---------------------------------------------------------------------- 处理器
def _harvest_all(engine: FarmEngine, _params: Dict[str, Any]) -> Dict[str, Any]:
def _harvest_all(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
"""收获所有成熟作物。逐块调用引擎,个别地块失败不影响其它地块。"""
total_gold = 0
total_units = 0
plots: List[int] = []
errors: List[str] = []
plots: list[int] = []
errors: list[str] = []
# 复制列表:harvest 会修改 state.plots 中元素的内容,但不会增删元素
for plot in list(engine.state.plots):
if not plot.is_ripe:
@@ -121,81 +128,139 @@ def _harvest_all(engine: FarmEngine, _params: Dict[str, Any]) -> Dict[str, Any]:
}
def _save(engine: FarmEngine, _params: Dict[str, Any]) -> Dict[str, Any]:
def _save(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
return engine.save()
def _reset(engine: FarmEngine, _params: Dict[str, Any]) -> Dict[str, Any]:
def _reset(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
return engine.reset()
def _pause(engine: FarmEngine, params: Dict[str, Any]) -> Dict[str, Any]:
def _pause(engine: FarmEngine, params: dict[str, Any]) -> dict[str, Any]:
value = params.get("paused")
return engine.pause(value if isinstance(value, bool) else None)
# ---------------------------------------------------------------------- 动作表
ACTIONS: Dict[str, Action] = {}
ACTIONS: dict[str, Action] = {}
def _register(action: Action) -> None:
ACTIONS[action.name] = action
_register(Action("plant", "在指定地块播种", [
Param("plot", "int", True, None, "地块编号,从 1 开始"),
Param("crop", "str", True, None, "作物标识,如 wheat"),
], lambda e, p: e.plant(p["plot"], p["crop"])))
_register(
Action(
"plant",
"在指定地块播种",
[
Param("plot", "int", True, None, "地块编号,从 1 开始"),
Param("crop", "str", True, None, "作物标识,如 wheat"),
],
lambda e, p: e.plant(p["plot"], p["crop"]),
)
)
_register(Action("water", "给指定地块浇水(补满水分)", [
Param("plot", "int", True, None, "地块编号,从 1 开始"),
], lambda e, p: e.water(p["plot"])))
_register(
Action(
"water",
"给指定地块浇水(补满水分)",
[
Param("plot", "int", True, None, "地块编号,从 1 开始"),
],
lambda e, p: e.water(p["plot"]),
)
)
_register(Action("harvest", "收获指定地块的成熟作物", [
Param("plot", "int", True, None, "地块编号,从 1 开始"),
], lambda e, p: e.harvest(p["plot"])))
_register(
Action(
"harvest",
"收获指定地块的成熟作物",
[
Param("plot", "int", True, None, "地块编号,从 1 开始"),
],
lambda e, p: e.harvest(p["plot"]),
)
)
_register(Action("harvest_all", "一键收获所有成熟作物", [], _harvest_all))
_register(Action("buy_animal", "购买牲畜", [
Param("kind", "str", True, None, "牲畜标识:chicken / sheep / cow"),
], lambda e, p: e.buy_animal(p["kind"])))
_register(
Action(
"buy_animal",
"购买牲畜",
[
Param("kind", "str", True, None, "牲畜标识:chicken / sheep / cow"),
],
lambda e, p: e.buy_animal(p["kind"]),
)
)
_register(Action("feed", "喂食指定牲畜", [
Param("animal_id", "int", True, None, "牲畜编号(state.animals[].id)"),
Param("quantity", "int", False, None, "喂食份数,默认按需求;多喂无益"),
], lambda e, p: e.feed(p["animal_id"], p.get("quantity"))))
_register(
Action(
"feed",
"喂食指定牲畜",
[
Param("animal_id", "int", True, None, "牲畜编号(state.animals[].id)"),
Param("quantity", "int", False, None, "喂食份数,默认按需求;多喂无益"),
],
lambda e, p: e.feed(p["animal_id"], p.get("quantity")),
)
)
_register(Action("collect", "收取牲畜的产出并自动出售", [
Param("animal_id", "int", True, None, "牲畜编号"),
], lambda e, p: e.collect(p["animal_id"])))
_register(
Action(
"collect",
"收取牲畜的产出并自动出售",
[
Param("animal_id", "int", True, None, "牲畜编号"),
],
lambda e, p: e.collect(p["animal_id"]),
)
)
_register(Action("buy_feed", "购买饲料", [
Param("quantity", "int", False, config.FEED_BULK, f"份数,默认 {config.FEED_BULK}"),
], lambda e, p: e.buy_feed(p.get("quantity", config.FEED_BULK))))
_register(
Action(
"buy_feed",
"购买饲料",
[
Param("quantity", "int", False, config.FEED_BULK, f"份数,默认 {config.FEED_BULK}"),
],
lambda e, p: e.buy_feed(p.get("quantity", config.FEED_BULK)),
)
)
_register(Action("buy_plot", "花钱开垦一块新土地", [], lambda e, p: e.buy_plot()))
_register(Action("sleep", "主动推进一天(有冷却)", [], lambda e, p: e.sleep()))
_register(Action("pause", "暂停或恢复自动推进时间", [
Param("paused", "bool", False, None, "true 暂停 / false 恢复;省略则反转当前状态"),
], _pause))
_register(
Action(
"pause",
"暂停或恢复自动推进时间",
[
Param("paused", "bool", False, None, "true 暂停 / false 恢复;省略则反转当前状态"),
],
_pause,
)
)
_register(Action("save", "把当前农场写入存档文件", [], _save))
_register(Action("reset", "重置农场,从头开始经营", [], _reset))
def action_names() -> List[str]:
def action_names() -> list[str]:
return sorted(ACTIONS)
def describe_actions() -> List[Dict[str, Any]]:
def describe_actions() -> list[dict[str, Any]]:
return [ACTIONS[name].to_dict() for name in action_names()]
def run_action(engine: FarmEngine, name: Any, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
def run_action(
engine: FarmEngine, name: Any, params: dict[str, Any] | None = None
) -> dict[str, Any]:
"""校验参数并执行动作。
这是所有入口的**唯一**执行通道,因此输入校验只需要在这里做一次。
@@ -223,7 +288,7 @@ def run_action(engine: FarmEngine, name: Any, params: Optional[Dict[str, Any]] =
f"动作 {key} 不认识参数 {'、'.join(unknown)}(可用:{allowed})",
)
clean: Dict[str, Any] = {}
clean: dict[str, Any] = {}
for spec in action.params:
if spec.name in params and params[spec.name] is not None:
clean[spec.name] = spec.coerce(params[spec.name])
+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))
+68 -57
View File
@@ -15,6 +15,7 @@
from __future__ import annotations
import contextlib
import json
import os
import signal
@@ -24,7 +25,7 @@ import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, List, Optional, Tuple
from typing import Any
from .. import net
@@ -48,7 +49,7 @@ def pid_alive(pid: int) -> bool:
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
if not handle:
return False
try:
@@ -68,10 +69,17 @@ def pid_alive(pid: int) -> bool:
class DaemonManager:
"""负责在后台拉起/停止 ``farm.server`` 进程。"""
def __init__(self, host: str = "127.0.0.1", port: int = 8765,
token: Optional[str] = None, tick: float = 15.0,
save_dir: str = "", pid_file: str = DEFAULT_PID_FILE,
log_file: str = DEFAULT_LOG_FILE, python: Optional[str] = None) -> None:
def __init__(
self,
host: str = "127.0.0.1",
port: int = 8765,
token: str | None = None,
tick: float = 15.0,
save_dir: str = "",
pid_file: str = DEFAULT_PID_FILE,
log_file: str = DEFAULT_LOG_FILE,
python: str | None = None,
) -> None:
self.host = host
self.port = port
self.token = token
@@ -83,9 +91,9 @@ class DaemonManager:
self.python = python or sys.executable
# ------------------------------------------------------------ pid 文件
def read_pid(self) -> Optional[int]:
def read_pid(self) -> int | None:
try:
with open(self.pid_file, "r", encoding="utf-8") as handle:
with open(self.pid_file, encoding="utf-8") as handle:
return int(handle.read().strip())
except (OSError, ValueError):
return None
@@ -96,12 +104,10 @@ class DaemonManager:
handle.write(str(pid))
def clear_pid(self) -> None:
try:
with contextlib.suppress(OSError):
os.remove(self.pid_file)
except OSError:
pass
def current(self) -> Optional[int]:
def current(self) -> int | None:
"""返回正在运行的服务器 pid;pid 文件过期则顺手清理。"""
pid = self.read_pid()
if pid is None:
@@ -112,7 +118,7 @@ class DaemonManager:
return None
# ------------------------------------------------------------ 探测
def probe(self, timeout: float = 1.5) -> Optional[Dict[str, Any]]:
def probe(self, timeout: float = 1.5) -> dict[str, Any] | None:
"""访问 /api/health,成功说明服务真的在监听。
用 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走,
@@ -125,7 +131,7 @@ class DaemonManager:
except (urllib.error.URLError, OSError, ValueError):
return None
def world(self, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
def world(self, timeout: float = 2.0) -> dict[str, Any] | None:
url = f"http://{self.host}:{self.port}/api/world"
if self.token:
url += "?token=" + urllib.parse.quote(self.token)
@@ -141,27 +147,36 @@ class DaemonManager:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.6)
return sock.connect_ex((self.host if self.host != "0.0.0.0" else "127.0.0.1",
self.port)) == 0
return (
sock.connect_ex((self.host if self.host != "0.0.0.0" else "127.0.0.1", self.port))
== 0
)
# ------------------------------------------------------------ 启动
def command(self) -> List[str]:
def command(self) -> list[str]:
args = [
self.python, "-m", "farm.server",
"--host", self.host,
"--port", str(self.port),
"--tick", str(self.tick),
"--save-dir", self.save_dir,
self.python,
"-m",
"farm.server",
"--host",
self.host,
"--port",
str(self.port),
"--tick",
str(self.tick),
"--save-dir",
self.save_dir,
# 关键:pid 文件由**服务进程自己**写。
# Windows 上 venv 的 python.exe 可能是个转发器,父进程拿到的 pid
# 未必是真正在监听端口的那个进程,写错 pid 会导致 stop 杀错人。
"--pid-file", self.pid_file,
"--pid-file",
self.pid_file,
]
if self.token:
args += ["--token", self.token]
return args
def start(self, wait: float = 12.0) -> Tuple[bool, str]:
def start(self, wait: float = 12.0) -> tuple[bool, str]:
existing = self.current()
if existing is not None:
return False, f"服务器已经在运行(pid {existing}),无需重复启动"
@@ -172,13 +187,13 @@ class DaemonManager:
os.makedirs(os.path.dirname(self.log_file), exist_ok=True)
os.makedirs(os.path.dirname(self.pid_file), exist_ok=True)
self.clear_pid() # 清掉上一次留下的陈旧 pid
self.clear_pid() # 清掉上一次留下的陈旧 pid
env = dict(os.environ)
env["PYTHONUNBUFFERED"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
env["PYTHONPATH"] = PROJECT_ROOT + os.pathsep + env.get("PYTHONPATH", "")
kwargs: Dict[str, Any] = {
kwargs: dict[str, Any] = {
"cwd": PROJECT_ROOT,
"env": env,
"stdin": subprocess.DEVNULL,
@@ -194,20 +209,18 @@ class DaemonManager:
else:
kwargs["start_new_session"] = True
log_handle = open(self.log_file, "a", encoding="utf-8")
try:
# 日志交给子进程自己写文件:父进程退出后文件描述符依然有效
process = subprocess.Popen(
self.command(),
stdout=log_handle,
stderr=log_handle,
**kwargs,
)
except OSError as exc:
log_handle.close()
return False, f"启动失败:{exc}"
else:
log_handle.close()
# 日志交给子进程自己写文件:父进程退出后文件描述符依然有效。
# 用 with 管理句柄:Popen 已经把 fd 复制给子进程,父进程这边可以安全关闭。
with open(self.log_file, "a", encoding="utf-8") as log_handle:
try:
process = subprocess.Popen(
self.command(),
stdout=log_handle,
stderr=log_handle,
**kwargs,
)
except OSError as exc:
return False, f"启动失败:{exc}"
# 等它真的能响应,而不是"进程起来了但端口还没监听"
deadline = time.time() + wait
@@ -227,12 +240,10 @@ class DaemonManager:
)
time.sleep(0.25)
return False, (
f"服务器进程已启动但 {wait:.0f} 秒内没响应。请查看日志:{self.log_file}"
)
return False, (f"服务器进程已启动但 {wait:.0f} 秒内没响应。请查看日志:{self.log_file}")
# ------------------------------------------------------------ 停止
def stop(self, force: bool = False) -> Tuple[bool, str]:
def stop(self, force: bool = False) -> tuple[bool, str]:
pid = self.current()
online = self.probe() is not None
if pid is None and not online:
@@ -261,16 +272,15 @@ class DaemonManager:
return True, f"服务器已停止(pid {pid})"
if force and os.name == "nt":
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],
capture_output=True, check=False)
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True, check=False
)
if self._wait_offline(pid, 3.0):
self.clear_pid()
return True, f"服务器已强制结束(pid {pid})"
elif force:
try:
with contextlib.suppress(OSError):
os.kill(pid, signal.SIGKILL)
except OSError:
pass
if self._wait_offline(pid, 3.0):
self.clear_pid()
return True, f"服务器已强制结束(pid {pid})"
@@ -281,7 +291,7 @@ class DaemonManager:
+ f" 日志:{self.log_file}"
)
def restart(self) -> Tuple[bool, str]:
def restart(self) -> tuple[bool, str]:
stopped, message = self.stop()
if not stopped:
return False, message
@@ -291,8 +301,9 @@ class DaemonManager:
url = f"http://{self.host}:{self.port}/api/admin/shutdown"
if self.token:
url += "?token=" + urllib.parse.quote(self.token)
request = urllib.request.Request(url, data=b"{}", method="POST",
headers={"Content-Type": "application/json"})
request = urllib.request.Request(
url, data=b"{}", method="POST", headers={"Content-Type": "application/json"}
)
try:
with net.open_url(request, timeout=3.0) as response:
return response.status == 200
@@ -308,7 +319,7 @@ class DaemonManager:
time.sleep(0.2)
return not pid_alive(pid)
def _wait_offline(self, pid: Optional[int], seconds: float) -> bool:
def _wait_offline(self, pid: int | None, seconds: float) -> bool:
"""等到进程消失 **且** 端口不再响应。
两件事都要等:进程可能已经死了但端口还被 TIME_WAIT 占着,
@@ -324,10 +335,10 @@ class DaemonManager:
return process_gone and self.probe(timeout=0.6) is None
# ------------------------------------------------------------ 状态
def status(self) -> Dict[str, Any]:
def status(self) -> dict[str, Any]:
pid = self.current()
health = self.probe()
info: Dict[str, Any] = {
info: dict[str, Any] = {
"pid": pid,
"running": pid is not None or health is not None,
"host": self.host,
@@ -343,9 +354,9 @@ class DaemonManager:
info["uptime"] = health.get("uptime")
return info
def tail(self, lines: int = 40) -> List[str]:
def tail(self, lines: int = 40) -> list[str]:
try:
with open(self.log_file, "r", encoding="utf-8", errors="replace") as handle:
with open(self.log_file, encoding="utf-8", errors="replace") as handle:
return handle.read().splitlines()[-lines:]
except OSError:
return []
+44 -29
View File
@@ -22,14 +22,15 @@ uvicorn 跑在 asyncio 事件循环里,而游戏引擎是同步的、跑在工
from __future__ import annotations
import asyncio
import contextlib
import itertools
import threading
import time
from collections import defaultdict
from typing import Any, Dict, List, Optional, Set
from typing import Any
STATE_INTERVAL = 0.25 # 状态推送的最小间隔(秒),天然把高频操作合并成一次
QUEUE_SIZE = 32 # 单连接出口缓冲区;满了就丢最旧的(状态类消息可丢)
STATE_INTERVAL = 0.25 # 状态推送的最小间隔(秒),天然把高频操作合并成一次
QUEUE_SIZE = 32 # 单连接出口缓冲区;满了就丢最旧的(状态类消息可丢)
_seq_counter = itertools.count(1)
@@ -48,8 +49,17 @@ class Client:
_ids = itertools.count(1)
__slots__ = ("cid", "farm_id", "loop", "queue", "name", "joined_at", "alive",
"_sent", "_dropped")
__slots__ = (
"_dropped",
"_sent",
"alive",
"cid",
"farm_id",
"joined_at",
"loop",
"name",
"queue",
)
def __init__(self, farm_id: str, loop: asyncio.AbstractEventLoop, name: str = "") -> None:
self.cid = f"c{next(Client._ids)}"
@@ -63,7 +73,7 @@ class Client:
self._dropped = 0
# ------------------------------------------------------------ 线程安全投递
def push(self, message: Dict[str, Any]) -> None:
def push(self, message: dict[str, Any]) -> None:
"""从任意线程把消息排队到该连接的写协程。"""
if not self.alive:
return
@@ -73,7 +83,7 @@ class Client:
# 事件循环已关闭(客户端刚好断开)
self.alive = False
def _enqueue(self, message: Dict[str, Any]) -> None:
def _enqueue(self, message: dict[str, Any]) -> None:
if self.queue.full():
# 丢最旧的一条:实时状态宁可少一帧,也不能让连接被拖死
try:
@@ -83,12 +93,12 @@ class Client:
pass
self.queue.put_nowait(message)
async def next_message(self) -> Dict[str, Any]:
async def next_message(self) -> dict[str, Any]:
message = await self.queue.get()
self._sent += 1
return message
def stats(self) -> Dict[str, Any]:
def stats(self) -> dict[str, Any]:
return {
"client_id": self.cid,
"farm": self.farm_id,
@@ -104,8 +114,8 @@ class ConnectionHub:
def __init__(self) -> None:
self._lock = threading.RLock()
self._by_farm: Dict[str, Set[Client]] = defaultdict(set)
self._all: Set[Client] = set()
self._by_farm: dict[str, set[Client]] = defaultdict(set)
self._all: set[Client] = set()
# ------------------------------------------------------------ 增删
def register(self, client: Client) -> None:
@@ -124,7 +134,7 @@ class ConnectionHub:
self._all.discard(client)
# ------------------------------------------------------------ 查询
def clients(self, farm_id: str) -> List[Client]:
def clients(self, farm_id: str) -> list[Client]:
with self._lock:
return list(self._by_farm.get(farm_id, ()))
@@ -132,7 +142,7 @@ class ConnectionHub:
with self._lock:
return len(self._by_farm.get(farm_id, ()))
def online_map(self) -> Dict[str, int]:
def online_map(self) -> dict[str, int]:
with self._lock:
return {farm: len(clients) for farm, clients in self._by_farm.items() if clients}
@@ -140,13 +150,14 @@ class ConnectionHub:
with self._lock:
return len(self._all)
def snapshot(self) -> List[Dict[str, Any]]:
def snapshot(self) -> list[dict[str, Any]]:
with self._lock:
return [c.stats() for c in sorted(self._all, key=lambda c: c.cid)]
# ------------------------------------------------------------ 广播
def broadcast(self, farm_id: str, message: Dict[str, Any],
exclude: Optional[Client] = None) -> int:
def broadcast(
self, farm_id: str, message: dict[str, Any], exclude: Client | None = None
) -> int:
sent = 0
for client in self.clients(farm_id):
if client is exclude:
@@ -155,7 +166,7 @@ class ConnectionHub:
sent += 1
return sent
def broadcast_all(self, message: Dict[str, Any], exclude: Optional[Client] = None) -> int:
def broadcast_all(self, message: dict[str, Any], exclude: Client | None = None) -> int:
with self._lock:
targets = list(self._all)
sent = 0
@@ -207,10 +218,9 @@ class Publisher(threading.Thread):
if now - self._last_autosave >= self.autosave_interval:
self._last_autosave = now
try:
# 自动存档失败不能把推送线程带走
with contextlib.suppress(Exception):
self.sessions.autosave_all()
except Exception:
pass
def _push_state(self, session, now: float) -> None:
payload = session.state_message(seq=next_seq())
@@ -219,13 +229,16 @@ class Publisher(threading.Thread):
day = session.engine.state.day
if day != session.published_day:
session.published_day = day
self.hub.broadcast(session.farm_id, {
"type": "day",
"farm": session.farm_id,
"day": day,
"events": session.last_events,
"at": now,
})
self.hub.broadcast(
session.farm_id,
{
"type": "day",
"farm": session.farm_id,
"day": day,
"events": session.last_events,
"at": now,
},
)
self.hub.broadcast(session.farm_id, payload)
self.pushes += 1
@@ -233,9 +246,11 @@ class Publisher(threading.Thread):
return round(time.time() - self._started_at, 1)
def build_state_message(session, seq: int, note: str = "", extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
def build_state_message(
session, seq: int, note: str = "", extra: dict[str, Any] | None = None
) -> dict[str, Any]:
"""构造给客户端的状态消息(HTTP 与 WS 共用同一份结构)。"""
message: Dict[str, Any] = {
message: dict[str, Any] = {
"type": "state",
"seq": seq,
"farm": session.farm_id,
+29 -26
View File
@@ -15,11 +15,12 @@
from __future__ import annotations
import contextlib
import os
import re
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from typing import Any
from .. import config
from ..engine import FarmEngine, GameError
@@ -29,7 +30,7 @@ DEFAULT_FARM = "default"
MAX_FARM_ID = 24
# 牧场 ID 会变成存档文件名,所以只允许"安全且可读"的字符
_FARM_ID_RE = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{1,%d}$" % MAX_FARM_ID)
_FARM_ID_RE = re.compile(rf"^[A-Za-z0-9_\-\u4e00-\u9fff]{{1,{MAX_FARM_ID}}}$")
def validate_farm_id(raw: Any, default: str = DEFAULT_FARM) -> str:
@@ -63,8 +64,7 @@ def validate_owner(raw: Any) -> str:
class FarmSession:
"""一个独立的农场玩家。"""
def __init__(self, farm_id: str, save_dir: str, tick_seconds: float,
owner: str = "") -> None:
def __init__(self, farm_id: str, save_dir: str, tick_seconds: float, owner: str = "") -> None:
self.farm_id = farm_id
self.owner = validate_owner(owner) or farm_id
self.save_dir = save_dir
@@ -75,7 +75,7 @@ class FarmSession:
# 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写
self.dirty = True
self.published_day = 0
self.last_events: List[str] = []
self.last_events: list[str] = []
self.created_at = time.time()
self.last_active = time.time()
self.saved_at = 0.0
@@ -122,10 +122,9 @@ class FarmSession:
return False
def shutdown(self) -> None:
try:
# 停机时尽力落盘;写失败也不能挡住进程退出
with contextlib.suppress(OSError):
self.save()
except OSError:
pass
def touch(self, count_action: bool = False) -> None:
self.last_active = time.time()
@@ -133,7 +132,7 @@ class FarmSession:
self.actions_taken += 1
self.dirty = True
def advance_day(self, reason: str = "时间流逝") -> List[str]:
def advance_day(self, reason: str = "时间流逝") -> list[str]:
with self._lock:
events = self.engine.advance_day(reason=reason)
self.last_events = events
@@ -146,14 +145,14 @@ class FarmSession:
self.touch(count_action=True)
# ------------------------------------------------------------ 查询
def snapshot(self) -> Dict[str, Any]:
def snapshot(self) -> dict[str, Any]:
data = self.engine.snapshot()
data["farm"] = self.farm_id
data["owner"] = self.owner
data["tick_seconds"] = self.engine.tick_seconds
return data
def summary(self, online: int = 0) -> Dict[str, Any]:
def summary(self, online: int = 0) -> dict[str, Any]:
"""给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。"""
snap = self.engine.snapshot()
return {
@@ -172,7 +171,7 @@ class FarmSession:
"idle_seconds": round(time.time() - self.last_active, 1),
}
def state_message(self, seq: int = 0, note: str = "") -> Dict[str, Any]:
def state_message(self, seq: int = 0, note: str = "") -> dict[str, Any]:
from .hub import build_state_message
return build_state_message(self, seq, note)
@@ -181,14 +180,19 @@ class FarmSession:
class SessionManager:
"""所有牧场的登记处。"""
def __init__(self, save_dir: str, tick_seconds: float = config.DEFAULT_TICK_SECONDS,
max_sessions: int = 64, idle_timeout: float = 1800.0) -> None:
def __init__(
self,
save_dir: str,
tick_seconds: float = config.DEFAULT_TICK_SECONDS,
max_sessions: int = 64,
idle_timeout: float = 1800.0,
) -> None:
self.save_dir = save_dir
self.tick_seconds = tick_seconds
self.max_sessions = max_sessions
self.idle_timeout = idle_timeout
self._lock = threading.RLock()
self._sessions: Dict[str, FarmSession] = {}
self._sessions: dict[str, FarmSession] = {}
os.makedirs(save_dir, exist_ok=True)
# ------------------------------------------------------------ 取用
@@ -203,23 +207,22 @@ class SessionManager:
if len(self._sessions) >= self.max_sessions:
raise GameError(
"too_many_farms",
f"服务器牧场数量已达上限({self.max_sessions}),"
"请稍后再试或联系管理员扩容",
f"服务器牧场数量已达上限({self.max_sessions}),请稍后再试或联系管理员扩容",
status=503,
)
session = FarmSession(farm_id, self.save_dir, self.tick_seconds, owner)
self._sessions[farm_id] = session
return session
def find(self, farm_id: str) -> Optional[FarmSession]:
def find(self, farm_id: str) -> FarmSession | None:
with self._lock:
return self._sessions.get(validate_farm_id(farm_id))
def all(self) -> List[FarmSession]:
def all(self) -> list[FarmSession]:
with self._lock:
return list(self._sessions.values())
def ids(self) -> List[str]:
def ids(self) -> list[str]:
with self._lock:
return sorted(self._sessions)
@@ -228,7 +231,7 @@ class SessionManager:
return len(self._sessions)
# ------------------------------------------------------------ 世界视图
def leaderboard(self, online_map: Optional[Dict[str, int]] = None) -> List[Dict[str, Any]]:
def leaderboard(self, online_map: dict[str, int] | None = None) -> list[dict[str, Any]]:
online_map = online_map or {}
rows = [s.summary(online_map.get(s.farm_id, 0)) for s in self.all()]
rows.sort(key=lambda row: (-row["net_worth"], -row["day"], row["farm"]))
@@ -244,22 +247,22 @@ class SessionManager:
saved += 1
return saved
def reap(self, online_map: Optional[Dict[str, int]] = None) -> List[str]:
def reap(self, online_map: dict[str, int] | None = None) -> list[str]:
"""回收长时间无人访问的牧场:先落盘,再从内存里移除。"""
online_map = online_map or {}
now = time.time()
dropped: List[str] = []
dropped: list[str] = []
for session in self.all():
if online_map.get(session.farm_id, 0) > 0:
continue
if session.engine.paused:
continue # 暂停是人为操作,说明玩家还会回来
continue # 暂停是人为操作,说明玩家还会回来
if now - session.last_active < self.idle_timeout:
continue
try:
session.shutdown()
except OSError:
continue # 存不下就别丢,宁可占着内存
continue # 存不下就别丢,宁可占着内存
with self._lock:
self._sessions.pop(session.farm_id, None)
dropped.append(session.farm_id)
@@ -305,5 +308,5 @@ class FarmClock(threading.Thread):
# 单个牧场出错不影响其它牧场的时间
engine.next_tick_at = now + engine.tick_seconds
def stats(self) -> Tuple[int, float]:
def stats(self) -> tuple[int, float]:
return self.ticks, self.accuracy
+4 -4
View File
@@ -431,7 +431,7 @@ def dashboard_text(host: str, port: int) -> str:
f"curl 'http://{host}:{port}/api/actions?farm=default'\n"
f"curl -X POST 'http://{host}:{port}/api/actions?farm=default' \\\n"
" -H 'Content-Type: application/json' \\\n"
" -d '{\"action\":\"plant\",\"params\":{\"plot\":1,\"crop\":\"wheat\"}}'\n"
' -d \'{"action":"plant","params":{"plot":1,"crop":"wheat"}}\'\n'
"\n"
"读取类\n"
"------\n"
@@ -445,11 +445,11 @@ def dashboard_text(host: str, port: int) -> str:
"\n"
"写入类\n"
"------\n"
"POST /api/actions {\"action\":\"...\",\"params\":{...}} 通用入口\n"
"POST /api/batch {\"actions\":[{...},{...}]} 批量\n"
'POST /api/actions {"action":"...","params":{...}} 通用入口\n'
'POST /api/batch {"actions":[{...},{...}]} 批量\n'
"POST /api/plant|water|harvest|harvest_all|buy_animal|feed|\n"
" collect|buy_feed|buy_plot|sleep|pause|save|reset 便捷入口\n"
"POST /api/farms {\"farm\":\"名字\"} 创建/进入牧场\n"
'POST /api/farms {"farm":"名字"} 创建/进入牧场\n'
"POST /api/admin/save 全部存档\n"
"POST /api/admin/shutdown 优雅停机\n"
)