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:
1 parent
c7c8a6308a
commit
fcd4cb4649
25 files changed
+1204
-794
No files matched your search
+29
-12
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
Reference in new issue
Block a user