Files
SimpleFarm/farm/server/__main__.py
T
snowy-yang cf24cb43f1 feat: 加天气 / 谷仓 / 建筑三套系统,收获改为入库 + 择时卖出
动手前先修掉了两处「死机制」,新增的三个系统各自激活一个:

* 天气:小麦 2 天成熟、初始水分 2、每天只掉 1 点,等于永远不需要浇水。
  现在水分消耗由天气决定(晴 1 / 多云 0 / 干旱 2,雨天反补 3),而
  `state.forecast` 把明天的天气提前告诉你——「今晚浇不浇」成了能算的决策。
  5 种天气、权重合计 100、同种不连出;暴风雨会毁未熟作物,温室免疫。
* 谷仓:市场波动原本纯装饰(收获当场按市价变现,没有择时空间)。现在收获与
  收取只进 `state.inventory`,靠新增的 `sell` 换金币,价格波动才真正参与决策。
  仓满时 `_store()` 抛 `granary_full` 且不改状态,作物原样留在地里。
* 建筑:新增 `build`,买的是「以后不用天天手动做」——水井自动浇干地、
  喂食器自动喂食(只吃库存饲料,绝不自动花钱)、温室、谷仓扩容。
  每天在 `advance_day` 里先于生长执行,同类越造越贵(×1.6)。

存档升到 `SAVE_VERSION=2`,配 `MIN_SAVE_VERSION=1` 向后兼容,旧档缺的新字段
一律走默认值,不作废玩家手里的档。`advance_day(weather=...)` 是留给测试的
确定性入口。

顺带修两个真 bug:
* `daemon.py` 三处 `makedirs(os.path.dirname(path))`:`--pid-file pid.txt`
  这种只给文件名的用法会让 dirname 变成空串,启动阶段直接崩。抽出
  `ensure_parent_dir()` 兜底。
* `RemoteFarm.snapshot()` 是本地缓存,非实时客户端轮询会一直读到旧数据,
  看起来像服务器卡死。补 docstring 指明要自己调 `refresh()`。

质量门禁:ruff format/check 全绿、pyright 0 错误、503 项测试 OK(skip 35)、
bench 无回归(brief 19.5µs / snapshot 48.5µs / POST full=1 400µs)。
另起真实服务器(--tick 3 + 预置存档)跑了 38 项端到端验收,全过。
2026-09-19 17:02:33 +08:00

316 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""``uv run python -m farm.server`` —— 服务器进程入口(前台运行)。
ASGI 服务器用 uvicorn,并打开它的 **HTTP/2** 支持(``http="zttp"`` +
``http2=True``):同一个端口既说 HTTP/1.1 也说 HTTP/2(TLS 走 ALPN,明文走
h2c prior-knowledge),`curl`、浏览器、AI 脚本各取所需,应用代码一行都不用改。
为什么 HTTP/2 要选 ``zttp``:uvicorn 自己不再内置 h2,它的 HTTP/2 实现建立在
``zttp`` 这个包上(见 ``uvicorn/protocols/http/zttp_h2_impl.py``)。装上之后
``AutoZttpProtocol`` 会在一条监听上自动分流两种协议,所以选它**不会**丢掉
HTTP/1.1 能力。
守护进程模式会由 :class:`farm.server.daemon.DaemonManager` 用同样的命令行
再拉起一个本模块的实例,只是不带 ``--daemon``。
"""
from __future__ import annotations
import argparse
import os
import sys
from types import FrameType
from .. import config
from .daemon import (
DEFAULT_LOG_FILE,
DEFAULT_PID_FILE,
PROJECT_ROOT,
DaemonManager,
ensure_parent_dir,
port_busy,
)
# 优雅停机的上限(秒)。SSE 是**永不自己结束**的长连接,停机时我们会先用
# hub.close_all() 让每个流自己收尾;这个值只是兜底:万一有客户端赖着不走
# (比如浏览器标签页没关),到点就取消剩下的连接任务,绝不让 stop 卡死。
GRACE_TIMEOUT = 5.0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="uv run python -m farm.server",
description="SimpleFarm 游戏服务器:HTTP 接口给 AI/脚本,SSE 给实时界面。",
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("--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("--save-dir", default=None, help="存档目录,默认 <项目根>/saves")
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("--world", default="SimpleFarm 公共农场", help="世界名称")
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="转到后台运行(等价于 uv run 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
def clamp_tick(value: float) -> float:
return max(config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, float(value)))
def dependency_hint() -> str:
return (
"缺少运行服务器所需的依赖。请先安装:\n"
" uv sync\n"
"如果不想用 uv,也可以用 pip:\n"
" pip install 'uvicorn[standard]' fastapi zttp\n"
f"\n当前解释器:{sys.executable}"
)
def clear_pid_file(path: str | None) -> None:
"""删掉自己写的 pid 文件,避免下次 status 看到"幽灵进程"。
只删内容确实是本进程 pid 的那个:万一文件已经被别的实例接管,
删掉它等于把别人的记录抹了。
顺序有讲究:必须**先读完、关掉句柄,再 os.remove**。Windows 上删一个还开着的
文件会抛 ``PermissionError``(WinError 32),而它会被下面的 ``except OSError``
吞掉——结果就是 pid 文件永远留在磁盘上(这个坑在本项目里真实存在过,
做端到端停机验证时才被发现)。
"""
if not path:
return
try:
if not os.path.exists(path):
return
with open(path, encoding="utf-8") as handle:
content = handle.read().strip()
if content == str(os.getpid()):
os.remove(path)
except OSError:
pass
def _resolve_save_dir(args) -> str:
"""存档目录:显式 --save-dir 优先,其次老参数 --save 所在目录,最后项目根/saves。"""
save_dir = args.save_dir or os.path.join(PROJECT_ROOT, "saves")
if args.save_file:
save_dir = os.path.dirname(os.path.abspath(args.save_file)) or save_dir
return save_dir
def _start_daemon(args, save_dir: str) -> int:
"""把服务丢到后台,父进程立刻返回(真正在监听的是子进程)。"""
manager = DaemonManager(
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,
)
ok, message = manager.start()
print(message)
return 0 if ok else 1
def _print_startup_banner(cfg, save_dir: str) -> None:
print(f"SimpleFarm 服务器启动中(pid {os.getpid()})…")
print(f" 监听 http://{cfg.host}:{cfg.port}/")
print(f" 存档目录 {save_dir}")
print(f" 实时通道 http://{cfg.host}:{cfg.port}/api/stream?farm=<牧场名>(SSE)")
print(" ASGI uvicorn(同一端口协商 HTTP/1.1 与 HTTP/2,HTTP/2 由 zttp 提供)")
if cfg.token:
print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数")
def _ensure_port_free(host: str, port: int) -> int | None:
"""端口自检:可用返回 ``None``,被别人占着则打印提示并返回退出码 3。
必须用"能不能连上"来判断,而不是"能不能 bind":Windows 上只要两边都设了
SO_REUSEADDR(uvicorn 会设),第二个进程也能在已被监听的端口上"绑定成功"
(然后抢走新连接),于是静默把第一个实例顶掉——这种事故比启动失败难查得多。
"""
if not port_busy(host, port):
return None
print(
f"启动失败:{host}:{port} 已经有程序在监听(可能是另一个 SimpleFarm 实例)。\n"
f" 换一个端口试试:--port {port + 1}",
file=sys.stderr,
)
return 3
def _run_foreground(args, save_dir: str) -> int:
"""在本进程里把 ASGI 服务器跑到退出,返回进程退出码。"""
# uvicorn 只在真的要跑服务时才需要——`main.py solo` 这类离线玩法不该被迫装它,
# 所以延迟到这一步才导入,失败时给出可照抄的安装命令。
try:
from uvicorn import Config as UvicornConfig
from uvicorn import Server as UvicornServer
except ImportError:
print(dependency_hint(), file=sys.stderr)
return 2
from .app import ServerConfig, ServerHandle, as_asgi, create_app
cfg = ServerConfig(
host=args.host,
port=args.port,
token=args.token,
tick_seconds=clamp_tick(args.tick),
save_dir=save_dir,
max_sessions=max(1, args.max_sessions),
idle_timeout=max(60.0, args.idle_timeout),
world_name=args.world,
allow_remote_control=not args.no_remote_control,
)
# 先看端口有没有人占着。
busy_code = _ensure_port_free(cfg.host, cfg.port)
if busy_code is not None:
return busy_code
app = create_app(cfg)
handle = app.state.handle
uv_config = UvicornConfig(
as_asgi(app),
host=cfg.host,
port=cfg.port,
log_level=args.log_level,
access_log=args.access_log,
# HTTP/2 必须在配置里显式打开:http="zttp" 选出 AutoZttpProtocol,
# http2=True 才让它真的启用 h2(uvicorn 只认 zttp 这个后端)。
http="zttp",
http2=True,
# 与 graceful shutdown 的语义一致:等连接自己收尾的最长时间
timeout_graceful_shutdown=int(GRACE_TIMEOUT),
)
# 服务器子类就地定义:覆盖 handle_exit 得继承 uvicorn.Server,而 uvicorn 是
# 延迟导入的(见上),写在模块顶层会让离线玩法也依赖它。
class FarmServer(UvicornServer):
"""uvicorn 服务器 + SimpleFarm 的停机语义。
唯一改动是 :meth:`handle_exit`(uvicorn 通过 ``capture_signals`` 把它接到
SIGINT / SIGTERM / SIGBREAK 上)。默认实现只置 ``should_exit``,那样 SSE
长连接收不到 ``bye``,只能干等 ``timeout_graceful_shutdown`` 到点被强杀——
实测白等满 5 秒。这里改成先 ``handle.request_stop()``:每个流立刻自己收尾,
再由 ``on_stop`` 回调置位 ``should_exit``。于是「接口停机」与「信号停机」
走的是同一条路径。
"""
# 由下面在实例化之后注入(放在这里是为了不动基类 __init__ 的签名)
handle: ServerHandle
def __init__(self, config: UvicornConfig) -> None:
super().__init__(config)
self._interrupts = 0
def handle_exit(self, sig: int, frame: FrameType | None) -> None:
self._interrupts += 1
if self._interrupts > 1:
# 第二次信号:用户不想再等了,放弃优雅收尾,直接退
print("\n再次收到中断信号,强制退出。", file=sys.stderr, flush=True)
os._exit(130)
# 故意不调 super():uvicorn 的版本只置 should_exit,而我们的停机钩子
# 会做同样的事,还要额外让 SSE 流主动收尾。另外保持 _captured_signals
# 为空,退出时就不会把信号重放一遍。
self.handle.request_stop()
server = FarmServer(uv_config)
server.handle = handle
# 两条停机路径汇到同一个地方:
# • POST /api/admin/shutdown → handle.request_stop()
# • Ctrl+C / SIGTERM / SIGBREAK → FarmServer.handle_exit → handle.request_stop()
# request_stop() 既让 SSE 流自己收尾(生成器看 handle.stopping),
# 也经由这个回调把 should_exit 置位,uvicorn 于是开始优雅退出。
handle.on_stop(lambda: setattr(server, "should_exit", True))
# pid 文件由**本进程**写:父进程(守护进程管理器)拿到的 pid 未必是真正
# 在监听端口的那个(Windows 上 venv 的 python.exe 可能是转发器)。
# uvicorn 的顺序是"先跑 lifespan(也就是这个钩子)、后 bind 端口",
# 所以这里只保证"服务已启动",不保证"端口已监听";万一 bind 失败,
# 下面的 finally 会把 pid 文件删掉,不留幽灵。
def write_pid_file() -> None:
if not args.pid_file:
return
try:
ensure_parent_dir(args.pid_file)
with open(args.pid_file, "w", encoding="utf-8") as file_handle:
file_handle.write(str(os.getpid()))
except OSError as exc:
print(f"⚠ 无法写入 pid 文件 {args.pid_file}:{exc}", file=sys.stderr)
handle.on_start(write_pid_file)
_print_startup_banner(cfg, save_dir)
status = 0
try:
server.run()
except OSError as exc:
print(
f"启动失败:{exc}\n"
f" 端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}",
file=sys.stderr,
)
status = 3
except SystemExit as exc:
# uvicorn 的 lifespan 跑在 bind 之前,监听失败时它自己 sys.exit(3)
code = exc.code
status = code if isinstance(code, int) and code else 3
print(
f"启动失败:无法监听 {cfg.host}:{cfg.port}(细节见上面的日志)。\n"
f" 换一个端口试试:--port {cfg.port + 1}",
file=sys.stderr,
)
finally:
clear_pid_file(args.pid_file)
if status == 0:
print("服务器已停止。")
return status
def main(argv=None) -> int:
"""入口:要么转后台(父进程立刻返回),要么就在本进程里把服务跑起来。"""
args = build_parser().parse_args(argv)
save_dir = _resolve_save_dir(args)
if args.daemon:
return _start_daemon(args, save_dir)
return _run_foreground(args, save_dir)
if __name__ == "__main__":
sys.exit(main())