Files
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

394 lines
15 KiB
Python
Raw Permalink 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.
"""守护进程管理:把服务器丢到后台跑,然后像服务一样 start / stop / status。
为什么不用 nohup 或 systemd
---------------------------
需求是"允许后台运行",而这个项目要能在 Windows / macOS / Linux 上都直接跑,
不能依赖某个平台的服务管理器。所以这里用最朴素也最通用的办法:
* ``subprocess.Popen`` 拉起一个**脱离父进程**的子进程(POSIX 用新会话,
Windows 用 DETACHED_PROCESS),父进程退出后它继续活着;
* 用 ``run/server.pid`` 记住它的 pid,用 ``run/server.log`` 收走它的输出;
* 停的时候先试着走 HTTP 优雅停机(能落盘),失败再发信号。
这套做法不依赖任何第三方库,也不需要管理员权限。
"""
from __future__ import annotations
import contextlib
import json
import os
import signal
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
from .. import net
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
RUN_DIR = os.path.join(PROJECT_ROOT, "run")
DEFAULT_PID_FILE = os.path.join(RUN_DIR, "server.pid")
DEFAULT_LOG_FILE = os.path.join(RUN_DIR, "server.log")
_STILL_ACTIVE = 259
def pid_alive(pid: int) -> bool:
"""跨平台地判断一个 pid 是否还活着。
注意:Windows 上 ``os.kill(pid, 0)`` 会**真的**把进程杀掉
(CPython 用 TerminateProcess 实现),所以这里必须走 Win32 API。
"""
if pid <= 0:
return False
if os.name == "nt":
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
if not handle:
return False
try:
code = ctypes.c_ulong()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
return False
return code.value == _STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
try:
os.kill(pid, 0)
except (OSError, ProcessLookupError):
return False
return True
def port_busy(host: str, port: int, timeout: float = 0.6) -> bool:
"""端口上已经有程序在监听了吗。
用"能不能连上"判断,而不是"能不能 bind":Windows 上只要两边都设了
``SO_REUSEADDR``(uvicorn 会设),第二个进程也能绑上同一个
端口、然后抢走新连接——于是 bind 探测会给出一个自信的错误答案。
"""
if host in ("0.0.0.0", ""):
target = "127.0.0.1"
elif host == "::":
target = "::1"
else:
target = host
try:
with socket.create_connection((target, port), timeout=timeout):
return True
except OSError:
return False
def ensure_parent_dir(path: str) -> None:
"""确保 ``path`` 所在的目录存在之后再动手写文件。
路径可能只给一个文件名(``--pid-file pid.txt``),此时
``os.path.dirname`` 返回空串,直接 ``makedirs`` 会以 WinError 3 /
FileNotFoundError 收场——启动阶段就崩,报错信息还只说"找不到路径"。
所以空目录部分要当成"当前目录",什么都不用建。
"""
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
class DaemonManager:
"""负责在后台拉起/停止 ``farm.server`` 进程。"""
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
self.tick = tick
self.save_dir = save_dir or os.path.join(PROJECT_ROOT, "saves")
# 防御式默认:调用方传 None 时用标准位置,绝不让 pid_file 变成 None
self.pid_file = pid_file or DEFAULT_PID_FILE
self.log_file = log_file or DEFAULT_LOG_FILE
self.python = python or sys.executable
# ------------------------------------------------------------ pid 文件
def read_pid(self) -> int | None:
try:
with open(self.pid_file, encoding="utf-8") as handle:
return int(handle.read().strip())
except (OSError, ValueError):
return None
def write_pid(self, pid: int) -> None:
ensure_parent_dir(self.pid_file)
with open(self.pid_file, "w", encoding="utf-8") as handle:
handle.write(str(pid))
def clear_pid(self) -> None:
with contextlib.suppress(OSError):
os.remove(self.pid_file)
def current(self) -> int | None:
"""返回正在运行的服务器 pid;pid 文件过期则顺手清理。"""
pid = self.read_pid()
if pid is None:
return None
if pid_alive(pid):
return pid
self.clear_pid()
return None
# ------------------------------------------------------------ 探测
def _get_json(self, path: str, timeout: float, token: bool = False) -> dict[str, Any] | None:
"""访问自己这个服务的某个接口;连不上或回包不是 JSON 就返回 None。
一律走 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走,
否则在设了 ``HTTP_PROXY`` 的机器上会得到莫名其妙的 502。
末尾那道 ``isinstance`` 是为了让返回类型名副其实——回包万一是个数组,
调用方的 ``.get(...)`` 会直接崩在属性访问上。
"""
url = f"http://{self.host}:{self.port}{path}"
if token and self.token:
url += "?token=" + urllib.parse.quote(self.token)
try:
with net.open_url(url, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError):
return None
return payload if isinstance(payload, dict) else None
def probe(self, timeout: float = 1.5) -> dict[str, Any] | None:
"""访问 /api/health,成功说明服务真的在监听。"""
return self._get_json("/api/health", timeout)
def world(self, timeout: float = 2.0) -> dict[str, Any] | None:
return self._get_json("/api/world", timeout, token=True)
def port_busy(self) -> bool:
"""端口被别的程序占了吗(用于给出"换端口"的提示)。"""
return port_busy(self.host, self.port)
# ------------------------------------------------------------ 启动
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,
# 关键:pid 文件由**服务进程自己**写。
# Windows 上 venv 的 python.exe 可能是个转发器,父进程拿到的 pid
# 未必是真正在监听端口的那个进程,写错 pid 会导致 stop 杀错人。
"--pid-file",
self.pid_file,
]
if self.token:
args += ["--token", self.token]
return args
def start(self, wait: float = 12.0) -> tuple[bool, str]:
existing = self.current()
if existing is not None:
return False, f"服务器已经在运行(pid {existing}),无需重复启动"
if self.probe() is not None:
return False, f"端口 {self.port} 上已经有一个 SimpleFarm 服务器在跑"
if self.port_busy():
return False, f"端口 {self.port} 被其它程序占用,请改用 --port 8899"
ensure_parent_dir(self.log_file)
ensure_parent_dir(self.pid_file)
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] = {
"cwd": PROJECT_ROOT,
"env": env,
"stdin": subprocess.DEVNULL,
}
if os.name == "nt":
# DETACHED_PROCESS:父进程退出后子进程继续活;
# CREATE_NEW_PROCESS_GROUP:方便单独给它发 Ctrl+Break
kwargs["creationflags"] = (
subprocess.DETACHED_PROCESS
| subprocess.CREATE_NEW_PROCESS_GROUP
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
)
else:
kwargs["start_new_session"] = True
# 日志交给子进程自己写文件:父进程退出后文件描述符依然有效。
# 用 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
while time.time() < deadline:
if self.probe() is not None:
pid = self.current() or process.pid
return True, (
f"服务器已在后台启动(pid {pid})\n"
f" 网页 http://{self.host}:{self.port}/\n"
f" 日志 {self.log_file}\n"
f" 停止 uv run main.py stop"
)
if process.poll() is not None and self.read_pid() is None:
return False, (
f"服务器进程启动后立刻退出(退出码 {process.returncode})。"
f"日志:{self.log_file}"
)
time.sleep(0.25)
return False, (f"服务器进程已启动但 {wait:.0f} 秒内没响应。请查看日志:{self.log_file}")
# ------------------------------------------------------------ 停止
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:
self.clear_pid()
return True, "服务器当前没有运行"
# 先请它自己优雅退出:这条路会把所有牧场写进存档
if online and self._request_shutdown() and self._wait_offline(pid, 8.0):
self.clear_pid()
return True, f"服务器已优雅停机(pid {pid or '未知'},所有牧场已存档)"
if pid is None:
return False, (
f"服务器仍在 {self.host}:{self.port} 上响应,但 pid 文件里没有记录,"
"无法确认该结束哪个进程。请手动处理,或用 docker/任务管理器结束它。"
)
try:
os.kill(pid, signal.SIGTERM)
except OSError as exc:
self.clear_pid()
return False, f"发送停止信号失败:{exc}"
if self._wait_offline(pid, 5.0):
self.clear_pid()
return True, f"服务器已停止(pid {pid})"
if force and os.name == "nt":
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:
with contextlib.suppress(OSError):
os.kill(pid, signal.SIGKILL)
if self._wait_offline(pid, 3.0):
self.clear_pid()
return True, f"服务器已强制结束(pid {pid})"
return False, (
f"进程 {pid} 没有在超时内退出。"
+ ("" if force else "可加 --force 强制结束。")
+ f" 日志:{self.log_file}"
)
def restart(self) -> tuple[bool, str]:
stopped, message = self.stop()
if not stopped:
return False, message
return self.start()
def _request_shutdown(self) -> bool:
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"}
)
try:
with net.open_url(request, timeout=3.0) as response:
return response.status == 200
except (urllib.error.URLError, OSError):
return False
@staticmethod
def _wait_gone(pid: int, seconds: float) -> bool:
deadline = time.time() + seconds
while time.time() < deadline:
if not pid_alive(pid):
return True
time.sleep(0.2)
return not pid_alive(pid)
def _wait_offline(self, pid: int | None, seconds: float) -> bool:
"""等到进程消失 **且** 端口不再响应。
两件事都要等:进程可能已经死了但端口还被 TIME_WAIT 占着,
也可能 pid 文件缺失(拿不到 pid),那就只看端口。
"""
deadline = time.time() + seconds
while time.time() < deadline:
process_gone = pid is None or not pid_alive(pid)
if process_gone and self.probe(timeout=0.6) is None:
return True
time.sleep(0.25)
process_gone = pid is None or not pid_alive(pid)
return process_gone and self.probe(timeout=0.6) is None
# ------------------------------------------------------------ 状态
def status(self) -> dict[str, Any]:
pid = self.current()
health = self.probe()
info: dict[str, Any] = {
"pid": pid,
"running": pid is not None or health is not None,
"host": self.host,
"port": self.port,
"log_file": self.log_file,
"pid_file": self.pid_file,
"health": health,
}
if health:
info["url"] = f"http://{self.host}:{self.port}/"
info["farms"] = health.get("farms")
info["clients"] = health.get("clients")
info["uptime"] = health.get("uptime")
return info
def tail(self, lines: int = 40) -> list[str]:
try:
with open(self.log_file, encoding="utf-8", errors="replace") as handle:
return handle.read().splitlines()[-lines:]
except OSError:
return []