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

+18 -7
View File
@@ -50,16 +50,23 @@ def main(argv=None) -> int:
args = build_parser().parse_args(argv)
try:
client = RemoteFarm(endpoint=args.url, farm=args.farm, token=args.token,
name=f"bot-{args.farm}", realtime=False)
client = RemoteFarm(
endpoint=args.url,
farm=args.farm,
token=args.token,
name=f"bot-{args.farm}",
realtime=False,
)
except GameError as exc:
print(f"✗ 连不上服务器:{exc.message}", file=sys.stderr)
print(f" 先启动:python main.py serve --daemon", file=sys.stderr)
print(" 先启动:python main.py serve --daemon", file=sys.stderr)
return 2
snapshot = client.snapshot()
print(f"已连接 {args.url} · 牧场「{args.farm}」· 第 {snapshot.get('day')} 天 · "
f"金币 {snapshot.get('gold')}")
print(
f"已连接 {args.url} · 牧场「{args.farm}」· 第 {snapshot.get('day')} 天 · "
f"金币 {snapshot.get('gold')}"
)
factory = random_bot if args.random else RuleBot
bot = factory(client, verbose=not args.quiet, pause=args.pause, name=args.farm)
@@ -67,8 +74,12 @@ def main(argv=None) -> int:
summary = bot.run(rounds=args.rounds, max_minutes=args.minutes)
except KeyboardInterrupt:
bot.stop()
summary = {"name": args.farm, "actions": bot.actions_executed,
"failures": bot.failures, "interrupted": True}
summary = {
"name": args.farm,
"actions": bot.actions_executed,
"failures": bot.failures,
"interrupted": True,
}
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
+9 -2
View File
@@ -15,6 +15,13 @@
__version__ = "2.0.0"
__all__ = [
"config", "models", "engine", "storage", "keys", "tui",
"client", "bot", "server",
"bot",
"client",
"config",
"engine",
"keys",
"models",
"server",
"storage",
"tui",
]
+23 -20
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import random
import threading
import time
from typing import Any, Dict, List, Optional
from typing import Any
from .engine import GameError
@@ -27,13 +27,14 @@ from .engine import GameError
class RuleBot:
"""一个会种地、会养殖、会投资的规则机器人。"""
def __init__(self, client, verbose: bool = True, pause: float = 0.25,
name: str = "bot") -> None:
self.client = client # 任何与 RemoteFarm 接口一致的对象
def __init__(
self, client, verbose: bool = True, pause: float = 0.25, name: str = "bot"
) -> None:
self.client = client # 任何与 RemoteFarm 接口一致的对象
self.verbose = verbose
self.pause = pause
self.name = name
self.done: List[str] = []
self.done: list[str] = []
self.failures = 0
self.actions_executed = 0
self._stop = threading.Event()
@@ -51,8 +52,9 @@ class RuleBot:
return self._stop.is_set()
# ------------------------------------------------------------ 打分
def score(self, action: Dict[str, Any], state: Dict[str, Any],
catalog: Dict[str, Any]) -> float:
def score(
self, action: dict[str, Any], state: dict[str, Any], catalog: dict[str, Any]
) -> float:
"""给一个候选动作打分。分数越高越先执行。
这张表就是整个"策略":先做免费的,再喂饱牲畜,再补水,再扩张。
@@ -90,15 +92,16 @@ class RuleBot:
return 34.0
return 4.0
if name == "buy_animal":
info = next((a for a in catalog.get("animals", [])
if a["key"] == params.get("kind")), None)
info = next(
(a for a in catalog.get("animals", []) if a["key"] == params.get("kind")), None
)
cost = info["cost"] if info else 0
if cost and gold >= cost * 3 and ap >= 1:
return 30.0 + (info.get("profit_per_day", 0) if info else 0)
return 3.0
return 1.0
def choose(self) -> Optional[Dict[str, Any]]:
def choose(self) -> dict[str, Any] | None:
"""在服务器给出的合法动作里挑一个最优的。"""
actions = self.client.available_actions()
if not actions:
@@ -111,7 +114,7 @@ class RuleBot:
return best
# ------------------------------------------------------------ 单步
def step(self) -> Optional[str]:
def step(self) -> str | None:
"""执行一个动作,返回动作名;没得做或做不了返回 None。"""
action = self.choose()
if action is None:
@@ -119,7 +122,7 @@ class RuleBot:
name = action["action"]
params = action.get("params") or {}
try:
result = self.client._action(name, params) # 走通用动作入口
result = self.client._action(name, params) # 走通用动作入口
except GameError as exc:
self.failures += 1
if exc.code == "cooldown":
@@ -151,8 +154,7 @@ class RuleBot:
return True
# ------------------------------------------------------------ 主循环
def run(self, rounds: int = 0, max_minutes: float = 0.0,
idle_limit: int = 8) -> Dict[str, Any]:
def run(self, rounds: int = 0, max_minutes: float = 0.0, idle_limit: int = 8) -> dict[str, Any]:
"""跑机器人。
``rounds`` 为 0 表示不限轮数;``max_minutes`` 为 0 表示不限时间。
@@ -161,8 +163,7 @@ class RuleBot:
start_day = self.client.snapshot().get("day", 1)
idle = 0
round_no = 0
self.say(f"机器人 {self.name} 上线(第 {start_day} 天,"
f"{self.client.transport_label()})")
self.say(f"机器人 {self.name} 上线(第 {start_day} 天,{self.client.transport_label()})")
while not self._stop.is_set():
round_no += 1
@@ -199,8 +200,10 @@ class RuleBot:
"net_worth": self.client.snapshot().get("net_worth"),
"gold": self.client.snapshot().get("gold"),
}
self.say(f"机器人 {self.name} 下线:跑了 {summary['days']} 天、"
f"执行 {summary['actions']} 个动作、净资产 {summary['net_worth']}")
self.say(
f"机器人 {self.name} 下线:跑了 {summary['days']} 天、"
f"执行 {summary['actions']} 个动作、净资产 {summary['net_worth']}"
)
return summary
@@ -209,13 +212,13 @@ def random_bot(client, **kwargs) -> RuleBot:
bot = RuleBot(client, **kwargs)
original = bot.choose
def choose() -> Optional[Dict[str, Any]]:
def choose() -> dict[str, Any] | None:
actions = client.available_actions()
if not actions:
return None
return random.choice(actions)
bot.choose = choose # type: ignore[assignment]
bot.choose = choose # type: ignore[assignment]
bot.name = kwargs.get("name", "random")
del original
return bot
+132 -105
View File
@@ -27,6 +27,7 @@ TUI 原本直接拿着 ``FarmEngine`` 对象,现在游戏跑在服务器进程
from __future__ import annotations
import asyncio
import contextlib
import json
import threading
import time
@@ -34,12 +35,13 @@ import urllib.error
import urllib.parse
import urllib.request
from collections import deque
from concurrent.futures import Future, TimeoutError as FutureTimeout
from typing import Any, Callable, Deque, Dict, List, Optional
from collections.abc import Callable
from concurrent.futures import Future
from concurrent.futures import TimeoutError as FutureTimeout
from typing import Any
from . import config
from . import config, net
from .engine import GameError
from . import net
DEFAULT_ENDPOINT = "http://127.0.0.1:8765"
LOG_KEEP = 200
@@ -52,7 +54,7 @@ class PlotView:
__slots__ = ("_d",)
def __init__(self, data: Dict[str, Any]) -> None:
def __init__(self, data: dict[str, Any]) -> None:
self._d = data
@property
@@ -64,7 +66,7 @@ class PlotView:
return int(self._d.get("position", self.index + 1))
@property
def crop(self) -> Optional[str]:
def crop(self) -> str | None:
return self._d.get("crop")
@property
@@ -99,7 +101,7 @@ class PlotView:
def stage(self) -> str:
return self._d.get("stage", "")
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return dict(self._d)
@@ -108,7 +110,7 @@ class AnimalView:
__slots__ = ("_d",)
def __init__(self, data: Dict[str, Any]) -> None:
def __init__(self, data: dict[str, Any]) -> None:
self._d = data
@property
@@ -155,18 +157,18 @@ class AnimalView:
def is_sick(self) -> bool:
return bool(self._d.get("is_sick"))
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return dict(self._d)
class StateView:
"""整局游戏的只读视图。刻意保留 ``to_dict()``,方便直接落盘或转发。"""
def __init__(self, data: Optional[Dict[str, Any]] = None) -> None:
self._d: Dict[str, Any] = {}
def __init__(self, data: dict[str, Any] | None = None) -> None:
self._d: dict[str, Any] = {}
self.apply(data or {})
def apply(self, data: Dict[str, Any]) -> None:
def apply(self, data: dict[str, Any]) -> None:
"""用新数据刷新内部视图。
服务器会发两种东西:**完整快照**(带 plots/animals)和**精简状态**
@@ -210,7 +212,7 @@ class StateView:
return int(self._d.get("feed", 0))
@property
def market(self) -> Dict[str, float]:
def market(self) -> dict[str, float]:
return self._d.get("market", {})
def get(self, key: str, default: Any = None) -> Any:
@@ -219,21 +221,24 @@ class StateView:
def __getitem__(self, key: str) -> Any:
return self._d[key]
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return dict(self._d)
# 初始化占位,避免属性访问落空
plots: List[PlotView] = []
animals: List[AnimalView] = []
# --------------------------------------------------------------------- 客户端
class RemoteFarm:
"""远程农场的句柄:接口与 :class:`farm.engine.FarmEngine` 保持一致。"""
def __init__(self, endpoint: str = DEFAULT_ENDPOINT, farm: str = "default",
token: Optional[str] = None, name: str = "", timeout: float = 6.0,
realtime: bool = True, autostart: bool = True) -> None:
def __init__(
self,
endpoint: str = DEFAULT_ENDPOINT,
farm: str = "default",
token: str | None = None,
name: str = "",
timeout: float = 6.0,
realtime: bool = True,
autostart: bool = True,
) -> None:
self.endpoint = self._normalize(endpoint)
self.farm = farm or "default"
self.token = token or ""
@@ -243,24 +248,27 @@ class RemoteFarm:
self.state = StateView()
self.connected = False
self.mode = "http" # http / ws / poll
self.mode = "http" # http / ws / poll
self.last_error = ""
self.last_message = ""
self.server_info: Dict[str, Any] = {}
self.on_change: Optional[Callable[[], None]] = None
self.server_info: dict[str, Any] = {}
self.on_change: Callable[[], None] | None = None
self._lock = threading.RLock()
self._pending: Dict[int, Future] = {}
self._pending: dict[int, Future] = {}
self._req_id = 0
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._ws_socket: Any = None
self._ws_loop: Any = None
self._catalog: Dict[str, Any] = {"crops": [], "animals": [],
"feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY,
"ap_cost": config.AP_COST}
self._log: Deque[Dict[str, Any]] = deque(maxlen=LOG_KEEP)
self._catalog: dict[str, Any] = {
"crops": [],
"animals": [],
"feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY,
"ap_cost": config.AP_COST,
}
self._log: deque[dict[str, Any]] = deque(maxlen=LOG_KEEP)
self._log_seq = 0
self.stats = {"http": 0, "ws_actions": 0, "pushes": 0, "errors": 0}
@@ -274,7 +282,7 @@ class RemoteFarm:
def _normalize(endpoint: str) -> str:
endpoint = (endpoint or DEFAULT_ENDPOINT).strip().rstrip("/")
if endpoint.startswith("tcp://"):
endpoint = "http://" + endpoint[len("tcp://"):]
endpoint = "http://" + endpoint[len("tcp://") :]
if not endpoint.startswith(("http://", "https://")):
endpoint = "http://" + endpoint
return endpoint
@@ -290,8 +298,9 @@ class RemoteFarm:
query.update({k: v for k, v in params.items() if v is not None})
return f"{self.endpoint}{path}?{urllib.parse.urlencode(query)}"
def _http(self, method: str, path: str, payload: Optional[Dict[str, Any]] = None,
**params: Any) -> Dict[str, Any]:
def _http(
self, method: str, path: str, payload: dict[str, Any] | None = None, **params: Any
) -> dict[str, Any]:
url = self.url(path, **params)
data = None
headers = {"Accept": "application/json"}
@@ -313,14 +322,15 @@ class RemoteFarm:
self.connected = False
raise GameError(
"network",
f"连不上服务器 {self.endpoint}({exc})。"
"请先启动:python main.py serve --daemon",
f"连不上服务器 {self.endpoint}({exc})。请先启动:python main.py serve --daemon",
status=503,
)
) from exc
try:
data_out = json.loads(body) if body else {}
except json.JSONDecodeError:
raise GameError("bad_response", f"服务器返回了非 JSON 内容(HTTP {status})", 502)
except json.JSONDecodeError as exc:
raise GameError(
"bad_response", f"服务器返回了非 JSON 内容(HTTP {status})", 502
) from exc
if not isinstance(data_out, dict):
raise GameError("bad_response", "服务器返回的 JSON 不是对象", 502)
if not data_out.get("ok", True):
@@ -352,49 +362,48 @@ class RemoteFarm:
raise
return False
def _apply_online(self, state: Dict[str, Any], online: Any) -> None:
def _apply_online(self, state: dict[str, Any], online: Any) -> None:
"""把"在线人数"合进状态视图:TUI 头部和网页都要显示它。"""
if state and isinstance(online, int):
state = dict(state)
state["online"] = online
self._apply(state)
def snapshot(self) -> Dict[str, Any]:
def snapshot(self) -> dict[str, Any]:
return self.state.to_dict()
def catalog(self) -> Dict[str, Any]:
def catalog(self) -> dict[str, Any]:
return self._catalog
def recent_log(self, limit: int = 12, since: int = 0) -> List[Dict[str, Any]]:
def recent_log(self, limit: int = 12, since: int = 0) -> list[dict[str, Any]]:
with self._lock:
entries = [e for e in self._log if int(e.get("seq", 0)) > since]
return entries[-limit:] if limit > 0 else entries
def available_actions(self) -> List[Dict[str, Any]]:
def available_actions(self) -> list[dict[str, Any]]:
data = self._http("GET", "/api/actions")
self._apply(data.get("state") or {})
return data.get("actions") or []
def leaderboard(self) -> List[Dict[str, Any]]:
def leaderboard(self) -> list[dict[str, Any]]:
return self._http("GET", "/api/farms").get("farms") or []
def world(self) -> Dict[str, Any]:
def world(self) -> dict[str, Any]:
return self._http("GET", "/api/world")
def help_text(self) -> str:
return self._http("GET", "/api/help").get("rules", "")
def _apply(self, state: Dict[str, Any]) -> None:
def _apply(self, state: dict[str, Any]) -> None:
if not state:
return
self.state.apply(state)
if self.on_change is not None:
try:
# UI 回调抛异常不能影响数据同步
with contextlib.suppress(Exception):
self.on_change()
except Exception:
pass
def _merge_log(self, entries: List[Dict[str, Any]]) -> None:
def _merge_log(self, entries: list[dict[str, Any]]) -> None:
with self._lock:
known = {int(e.get("seq", 0)) for e in self._log}
for entry in entries:
@@ -403,12 +412,14 @@ class RemoteFarm:
self._log.append(entry)
known.add(seq)
self._log_seq = max(self._log_seq, seq)
self._log = deque(sorted(self._log, key=lambda e: int(e.get("seq", 0))),
maxlen=LOG_KEEP)
self._log = deque(
sorted(self._log, key=lambda e: int(e.get("seq", 0))), maxlen=LOG_KEEP
)
# ------------------------------------------------------------ 写接口
def _action(self, action: str, params: Optional[Dict[str, Any]] = None,
transport: str = "auto") -> Dict[str, Any]:
def _action(
self, action: str, params: dict[str, Any] | None = None, transport: str = "auto"
) -> dict[str, Any]:
"""执行一个动作。
优先走 WebSocket(延迟更低、顺便复用已有连接);只有**通道类**错误
@@ -416,8 +427,9 @@ class RemoteFarm:
否则"金币不足"会被悄悄重试成两次操作。
"""
params = params or {}
use_ws = (transport == "ws") or (transport == "auto" and self.mode == "ws"
and self.connected)
use_ws = (transport == "ws") or (
transport == "auto" and self.mode == "ws" and self.connected
)
if use_ws:
try:
result = self._ws_action(action, params)
@@ -436,48 +448,47 @@ class RemoteFarm:
return result
# full=1:让服务器顺手把完整快照带回来,省掉"操作后再查一次"
data = self._http("POST", "/api/actions", {"action": action, "params": params},
full=1)
data = self._http("POST", "/api/actions", {"action": action, "params": params}, full=1)
self._apply(data.get("snapshot") or data.get("state") or {})
self._merge_log(data.get("log") or [])
return data
# ---- 与本地引擎同名的方法
def plant(self, plot: Any, crop: Any) -> Dict[str, Any]:
def plant(self, plot: Any, crop: Any) -> dict[str, Any]:
return self._action("plant", {"plot": plot, "crop": crop})
def water(self, plot: Any) -> Dict[str, Any]:
def water(self, plot: Any) -> dict[str, Any]:
return self._action("water", {"plot": plot})
def harvest(self, plot: Any) -> Dict[str, Any]:
def harvest(self, plot: Any) -> dict[str, Any]:
return self._action("harvest", {"plot": plot})
def harvest_all(self) -> Dict[str, Any]:
def harvest_all(self) -> dict[str, Any]:
return self._action("harvest_all")
def buy_animal(self, kind: Any) -> Dict[str, Any]:
def buy_animal(self, kind: Any) -> dict[str, Any]:
return self._action("buy_animal", {"kind": kind})
def feed(self, animal_id: Any, quantity: Any = None) -> Dict[str, Any]:
params: Dict[str, Any] = {"animal_id": animal_id}
def feed(self, animal_id: Any, quantity: Any = None) -> dict[str, Any]:
params: dict[str, Any] = {"animal_id": animal_id}
if quantity is not None:
params["quantity"] = quantity
return self._action("feed", params)
def collect(self, animal_id: Any) -> Dict[str, Any]:
def collect(self, animal_id: Any) -> dict[str, Any]:
return self._action("collect", {"animal_id": animal_id})
def buy_feed(self, quantity: Any = config.FEED_BULK) -> Dict[str, Any]:
def buy_feed(self, quantity: Any = config.FEED_BULK) -> dict[str, Any]:
return self._action("buy_feed", {"quantity": quantity})
def buy_plot(self) -> Dict[str, Any]:
def buy_plot(self) -> dict[str, Any]:
return self._action("buy_plot")
def sleep(self) -> Dict[str, Any]:
def sleep(self) -> dict[str, Any]:
return self._action("sleep")
def pause(self, paused: Optional[bool] = None) -> Dict[str, Any]:
params: Dict[str, Any] = {}
def pause(self, paused: bool | None = None) -> dict[str, Any]:
params: dict[str, Any] = {}
if paused is not None:
params["paused"] = paused
data = self._action("pause", params)
@@ -490,7 +501,7 @@ class RemoteFarm:
self.state.apply(merged)
return data
def reset(self) -> Dict[str, Any]:
def reset(self) -> dict[str, Any]:
data = self._action("reset")
with self._lock:
self._log.clear()
@@ -498,11 +509,11 @@ class RemoteFarm:
self.refresh()
return data
def save(self, path: Optional[str] = None) -> Dict[str, Any]:
def save(self, path: str | None = None) -> dict[str, Any]:
"""远程存档:位置由服务器决定,``path`` 参数仅为与本地引擎保持同签名。"""
return self._action("save")
def batch(self, items: List[Dict[str, Any]]) -> Dict[str, Any]:
def batch(self, items: list[dict[str, Any]]) -> dict[str, Any]:
"""批量执行(一个请求干多件事),给机器人/AI 用。"""
data = self._http("POST", "/api/batch", {"actions": items}, full=1)
self._apply(data.get("snapshot") or data.get("state") or {})
@@ -514,8 +525,7 @@ class RemoteFarm:
if not self.realtime_enabled or self._thread is not None:
return
self._stop.clear()
self._thread = threading.Thread(target=self._realtime_loop, name="farm-ws",
daemon=True)
self._thread = threading.Thread(target=self._realtime_loop, name="farm-ws", daemon=True)
self._thread.start()
def stop(self) -> None:
@@ -586,9 +596,13 @@ class RemoteFarm:
asyncio.run(self._ws_main(websockets))
async def _ws_main(self, websockets) -> None:
async with websockets.connect(self.ws_url, open_timeout=self.timeout,
ping_interval=20, ping_timeout=20,
max_size=4 * 1024 * 1024) as socket:
async with websockets.connect(
self.ws_url,
open_timeout=self.timeout,
ping_interval=20,
ping_timeout=20,
max_size=4 * 1024 * 1024,
) as socket:
self.mode = "ws"
self.connected = True
self.last_error = ""
@@ -650,37 +664,48 @@ class RemoteFarm:
return
if message.get("ok"):
self._merge_log(message.get("log") or [])
future.set_result({
"ok": True,
"message": message.get("message", "完成"),
**(message.get("result") or {}),
"state": message.get("state"),
"snapshot": message.get("snapshot"),
"log": message.get("log"),
})
future.set_result(
{
"ok": True,
"message": message.get("message", "完成"),
**(message.get("result") or {}),
"state": message.get("state"),
"snapshot": message.get("snapshot"),
"log": message.get("log"),
}
)
else:
future.set_exception(GameError(message.get("error", "error"),
message.get("message", "操作失败")))
future.set_exception(
GameError(message.get("error", "error"), message.get("message", "操作失败"))
)
return
if kind == "day":
self._merge_log([{
"seq": self._log_seq + 1,
"day": message.get("day", 0),
"kind": "info",
"text": f"第 {message.get('day')} 天开始",
}] + [{
"seq": self._log_seq + 2 + i,
"day": message.get("day", 0),
"kind": "warn",
"text": text,
} for i, text in enumerate(message.get("events") or [])])
self._merge_log(
[
{
"seq": self._log_seq + 1,
"day": message.get("day", 0),
"kind": "info",
"text": f"第 {message.get('day')} 天开始",
}
]
+ [
{
"seq": self._log_seq + 2 + i,
"day": message.get("day", 0),
"kind": "warn",
"text": text,
}
for i, text in enumerate(message.get("events") or [])
]
)
return
if kind == "error":
self.last_error = message.get("message", "")
def _ws_send(self, payload: Dict[str, Any]) -> None:
def _ws_send(self, payload: dict[str, Any]) -> None:
"""把消息发到 WS 线程的事件循环里(跨线程投递)。"""
socket = getattr(self, "_ws_socket", None)
loop = getattr(self, "_ws_loop", None)
@@ -688,8 +713,9 @@ class RemoteFarm:
raise GameError("ws_closed", "WebSocket 尚未连接")
asyncio.run_coroutine_threadsafe(socket.send(json.dumps(payload)), loop)
def _ws_action(self, action: str, params: Dict[str, Any],
timeout: Optional[float] = None) -> Dict[str, Any]:
def _ws_action(
self, action: str, params: dict[str, Any], timeout: float | None = None
) -> dict[str, Any]:
with self._lock:
self._req_id += 1
rid = self._req_id
@@ -706,7 +732,8 @@ class RemoteFarm:
except FutureTimeout:
with self._lock:
self._pending.pop(rid, None)
raise GameError("timeout", "服务器没有在超时时间内回应")
# 超时是正常控制流:FutureTimeout 是内部实现细节,不必挂到异常链上
raise GameError("timeout", "服务器没有在超时时间内回应") from None
class RemoteBotClient(RemoteFarm):
+80 -39
View File
@@ -6,32 +6,32 @@
from __future__ import annotations
from typing import Any, Dict
from typing import Any
# --------------------------------------------------------------------------
# 基础经济参数
# --------------------------------------------------------------------------
START_GOLD = 120 # 初始金币
START_LEVEL = 1 # 初始等级
START_AP = 10 # 每天行动点上限
START_FEED = 5 # 初始饲料库存
START_GOLD = 120 # 初始金币
START_LEVEL = 1 # 初始等级
START_AP = 10 # 每天行动点上限
START_FEED = 5 # 初始饲料库存
BASE_PLOTS = 6 # 初始土地数量
MAX_PLOTS = 16 # 土地数量硬上限
PLOT_BASE_PRICE = 200 # 第一块新地的价格
BASE_PLOTS = 6 # 初始土地数量
MAX_PLOTS = 16 # 土地数量硬上限
PLOT_BASE_PRICE = 200 # 第一块新地的价格
PLOT_PRICE_GROWTH = 1.75 # 每多买一块,价格乘以该系数
FEED_UNIT_PRICE = 6 # 主动购买饲料的单价
FEED_UNIT_PRICE = 6 # 主动购买饲料的单价
FEED_EMERGENCY_PRICE = 9 # 自动补货(缺饲料时喂食)的惩罚单价
FEED_BULK = 10 # 一次购买饲料的默认批量
FEED_BULK = 10 # 一次购买饲料的默认批量
WATER_CAPACITY = 3 # 一次浇水可支撑的天数
WATER_CAPACITY = 3 # 一次浇水可支撑的天数
PLANT_INITIAL_MOISTURE = 2 # 播种后地块自带的水分
PRICE_VOLATILITY = 0.18 # 每天价格波动幅度(±18%)
LUCKY_CHANCE = 0.12 # 丰收暴击概率(产出翻倍)
MAX_LOG_ENTRIES = 300 # 内存中保留的事件条数上限
MAX_FEED_PURCHASE = 200 # 单次购买饲料的数量上限
PRICE_VOLATILITY = 0.18 # 每天价格波动幅度(±18%)
LUCKY_CHANCE = 0.12 # 丰收暴击概率(产出翻倍)
MAX_LOG_ENTRIES = 300 # 内存中保留的事件条数上限
MAX_FEED_PURCHASE = 200 # 单次购买饲料的数量上限
# 每级提供的土地上限(基础 6 + 2/级,封顶 MAX_PLOTS)
PLOTS_PER_LEVEL = 2
@@ -48,11 +48,13 @@ AP_COST = {
"buy_feed": 0,
}
# 经验曲线:升到 level+1 所需经验
def exp_to_next(level: int) -> int:
"""返回从 level 升到 level+1 所需的经验值。"""
return int(80 * (max(1, level) ** 1.6))
# 升级奖励金币
def level_up_bonus(level: int) -> int:
"""升到 level 时发放的金币奖励。"""
@@ -70,30 +72,54 @@ def level_up_bonus(level: int) -> int:
# exp : 每次收获获得的经验
# level_req : 解锁所需等级
CROPS: Dict[str, Dict[str, Any]] = {
CROPS: dict[str, dict[str, Any]] = {
"wheat": {
"name": "小麦", "seed_cost": 5, "grow_days": 2,
"base_price": 14, "exp": 3, "level_req": 1,
"name": "小麦",
"seed_cost": 5,
"grow_days": 2,
"base_price": 14,
"exp": 3,
"level_req": 1,
},
"carrot": {
"name": "胡萝卜", "seed_cost": 8, "grow_days": 3,
"base_price": 26, "exp": 5, "level_req": 1,
"name": "胡萝卜",
"seed_cost": 8,
"grow_days": 3,
"base_price": 26,
"exp": 5,
"level_req": 1,
},
"tomato": {
"name": "番茄", "seed_cost": 16, "grow_days": 4,
"base_price": 52, "exp": 9, "level_req": 2,
"name": "番茄",
"seed_cost": 16,
"grow_days": 4,
"base_price": 52,
"exp": 9,
"level_req": 2,
},
"corn": {
"name": "玉米", "seed_cost": 28, "grow_days": 6,
"base_price": 95, "exp": 14, "level_req": 3,
"name": "玉米",
"seed_cost": 28,
"grow_days": 6,
"base_price": 95,
"exp": 14,
"level_req": 3,
},
"pumpkin": {
"name": "南瓜", "seed_cost": 45, "grow_days": 8,
"base_price": 180, "exp": 22, "level_req": 4,
"name": "南瓜",
"seed_cost": 45,
"grow_days": 8,
"base_price": 180,
"exp": 22,
"level_req": 4,
},
"strawberry": {
"name": "草莓", "seed_cost": 70, "grow_days": 10,
"base_price": 320, "exp": 35, "level_req": 5,
"name": "草莓",
"seed_cost": 70,
"grow_days": 10,
"base_price": 320,
"exp": 35,
"level_req": 5,
},
}
@@ -119,21 +145,36 @@ GROWTH_STAGES = [
# exp : 每次收集产出获得的经验
# level_req : 解锁所需等级
ANIMALS: Dict[str, Dict[str, Any]] = {
ANIMALS: dict[str, dict[str, Any]] = {
"chicken": {
"name": "鸡", "cost": 90, "product": "鸡蛋",
"base_price": 15, "cycle": 1, "feed_per_day": 1,
"exp": 4, "level_req": 1,
"name": "鸡",
"cost": 90,
"product": "鸡蛋",
"base_price": 15,
"cycle": 1,
"feed_per_day": 1,
"exp": 4,
"level_req": 1,
},
"sheep": {
"name": "羊", "cost": 220, "product": "羊毛",
"base_price": 55, "cycle": 2, "feed_per_day": 1,
"exp": 9, "level_req": 2,
"name": "羊",
"cost": 220,
"product": "羊毛",
"base_price": 55,
"cycle": 2,
"feed_per_day": 1,
"exp": 9,
"level_req": 2,
},
"cow": {
"name": "牛", "cost": 480, "product": "牛奶",
"base_price": 72, "cycle": 1, "feed_per_day": 2,
"exp": 15, "level_req": 3,
"name": "牛",
"cost": 480,
"product": "牛奶",
"base_price": 72,
"cycle": 1,
"feed_per_day": 2,
"exp": 15,
"level_req": 3,
},
}
@@ -147,7 +188,7 @@ MAX_ANIMALS = 8 # 牧场总容量
DEFAULT_TICK_SECONDS = 15.0 # 自动推进一天所需的真实秒数
MIN_TICK_SECONDS = 3.0
MAX_TICK_SECONDS = 600.0
SLEEP_COOLDOWN = 2.0 # 手动"休息"推进一天的冷却(秒)
SLEEP_COOLDOWN = 2.0 # 手动"休息"推进一天的冷却(秒)
# --------------------------------------------------------------------------
+56 -52
View File
@@ -16,10 +16,12 @@ TUI 与 HTTP/gRPC 接口都调用同一组方法,因此三方共享完全一
from __future__ import annotations
import contextlib
import random
import threading
import time
from typing import Any, Callable, Dict, List, Optional
from collections.abc import Callable
from typing import Any
from . import config
from .models import Animal, GameState, Plot, clamp
@@ -34,7 +36,7 @@ class GameError(Exception):
self.message = message
self.status = status
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return {"ok": False, "error": self.code, "message": self.message}
@@ -52,9 +54,9 @@ class FarmEngine:
self._last_sleep = 0.0
self.paused = False
# 由 Scheduler 维护:下一次自动推进的绝对时间戳(无调度器时为 None)
self.next_tick_at: Optional[float] = None
self.next_tick_at: float | None = None
# 状态变化回调(TUI 用它触发重绘)
self._listeners: List[Callable[[], None]] = []
self._listeners: list[Callable[[], None]] = []
# ================================================================ 订阅
def subscribe(self, callback: Callable[[], None]) -> None:
@@ -63,11 +65,9 @@ class FarmEngine:
def _notify(self) -> None:
for callback in list(self._listeners):
try:
# 监听器异常不能影响游戏主流程
with contextlib.suppress(Exception):
callback()
except Exception:
# 监听器异常不能影响游戏主流程
pass
# ================================================================ 日志
def add_log(self, text: str, kind: str = "info") -> None:
@@ -77,20 +77,20 @@ class FarmEngine:
{
"seq": self._log_seq,
"day": self.state.day,
"kind": kind, # info / good / bad / warn / action
"kind": kind, # info / good / bad / warn / action
"text": text,
}
)
if len(self.state.log) > config.MAX_LOG_ENTRIES:
del self.state.log[: len(self.state.log) - config.MAX_LOG_ENTRIES]
def recent_log(self, limit: int = 12, since: int = 0) -> List[Dict[str, Any]]:
def recent_log(self, limit: int = 12, since: int = 0) -> list[dict[str, Any]]:
with self._lock:
entries = [e for e in self.state.log if e["seq"] > since]
return entries[-limit:] if limit > 0 else entries
# ================================================================ 查询
def snapshot(self) -> Dict[str, Any]:
def snapshot(self) -> dict[str, Any]:
"""返回完整状态快照(供 TUI 渲染与 API 响应)。"""
with self._lock:
data = self.state.to_dict()
@@ -101,7 +101,7 @@ class FarmEngine:
return data
@property
def seconds_to_tick(self) -> Optional[float]:
def seconds_to_tick(self) -> float | None:
if self.paused or self.next_tick_at is None:
return None
return round(max(0.0, self.next_tick_at - time.time()), 1)
@@ -110,7 +110,7 @@ class FarmEngine:
with self._lock:
return round(self.state.market.get(key, 0.0), 2)
def catalog(self) -> Dict[str, Any]:
def catalog(self) -> dict[str, Any]:
"""返回所有可种植/可养殖对象及其在市场上的实时价格。"""
with self._lock:
crops = []
@@ -148,9 +148,7 @@ class FarmEngine:
"exp": info["exp"],
"level_req": info["level_req"],
"locked": self.state.level < info["level_req"],
"profit_per_day": round(
(price - feed_cost) / max(1, info["cycle"]), 2
),
"profit_per_day": round((price - feed_cost) / max(1, info["cycle"]), 2),
}
)
return {
@@ -161,14 +159,14 @@ class FarmEngine:
"ap_cost": config.AP_COST,
}
def available_actions(self) -> List[Dict[str, Any]]:
def available_actions(self) -> list[dict[str, Any]]:
"""列出当前**合法**的动作,供 AI 直接决策使用。
每个动作包含:``action``(接口名)、``params``(参数模板)、
``cost``(金币/行动点开销)、``reason``(为什么值得做)。
"""
with self._lock:
actions: List[Dict[str, Any]] = []
actions: list[dict[str, Any]] = []
state = self.state
# ---- 收获 / 浇水 / 播种
@@ -193,7 +191,9 @@ class FarmEngine:
"action": "harvest",
"params": {"plot": plot.index + 1},
"cost": {"gold": 0, "ap": config.AP_COST["harvest"]},
"reason": f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟",
"reason": (
f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟"
),
}
)
elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]:
@@ -274,7 +274,8 @@ class FarmEngine:
try:
number = int(plot_no)
except (TypeError, ValueError):
raise GameError("bad_param", f"地块编号必须是整数,收到 {plot_no!r}")
# 底层 ValueError 的细节对调用者没意义,只保留友好提示
raise GameError("bad_param", f"地块编号必须是整数,收到 {plot_no!r}") from None
if number < 1 or number > len(self.state.plots):
raise GameError(
"no_such_plot",
@@ -286,7 +287,7 @@ class FarmEngine:
try:
aid = int(animal_id)
except (TypeError, ValueError):
raise GameError("bad_param", f"动物编号必须是整数,收到 {animal_id!r}")
raise GameError("bad_param", f"动物编号必须是整数,收到 {animal_id!r}") from None
for animal in self.state.animals:
if animal.aid == aid:
return animal
@@ -316,14 +317,14 @@ class FarmEngine:
self.state.total_spent += amount
def _earn(self, amount: float, reason: str) -> int:
earned = max(0, int(round(amount)))
earned = max(0, round(amount))
self.state.gold += earned
self.state.total_earned += earned
return earned
def _gain_exp(self, amount: int) -> List[str]:
def _gain_exp(self, amount: int) -> list[str]:
"""增加经验并处理连续升级,返回升级提示文本。"""
messages: List[str] = []
messages: list[str] = []
self.state.exp += int(amount)
guard = 0
while self.state.exp >= self.state.exp_needed and guard < 50:
@@ -353,7 +354,7 @@ class FarmEngine:
return 2 if random.random() < config.LUCKY_CHANCE else 1
# ================================================================ 动作
def plant(self, plot_no: Any, crop: Any) -> Dict[str, Any]:
def plant(self, plot_no: Any, crop: Any) -> dict[str, Any]:
with self._lock:
if not isinstance(crop, str) or crop not in config.CROPS:
raise GameError(
@@ -368,7 +369,9 @@ class FarmEngine:
)
plot = self._get_plot(plot_no)
if plot.crop is not None:
raise GameError("plot_busy", f"{plot.index + 1} 号地已经种了{config.CROPS[plot.crop]['name']}")
raise GameError(
"plot_busy", f"{plot.index + 1} 号地已经种了{config.CROPS[plot.crop]['name']}"
)
self._spend_gold(info["seed_cost"], f"购买{info['name']}种子")
self._spend_ap("plant")
@@ -382,11 +385,13 @@ class FarmEngine:
self._notify()
return {
"ok": True,
"message": f"{plot.index + 1} 号地已种下{info['name']},{info['grow_days']} 天后成熟",
"message": (
f"{plot.index + 1} 号地已种下{info['name']},{info['grow_days']} 天后成熟"
),
"plot": plot.to_dict(),
}
def water(self, plot_no: Any) -> Dict[str, Any]:
def water(self, plot_no: Any) -> dict[str, Any]:
with self._lock:
plot = self._get_plot(plot_no)
if plot.crop is None:
@@ -402,7 +407,7 @@ class FarmEngine:
self._notify()
return {"ok": True, "message": f"{plot.index + 1} 号地已浇水", "plot": plot.to_dict()}
def harvest(self, plot_no: Any) -> Dict[str, Any]:
def harvest(self, plot_no: Any) -> dict[str, Any]:
with self._lock:
plot = self._get_plot(plot_no)
if plot.crop is None:
@@ -426,9 +431,7 @@ class FarmEngine:
self.state.total_harvested += quantity
lucky = "(丰收!)" if quantity > 1 else ""
self.add_log(
f"收获 {quantity} 份{info['name']}{lucky},+{earned} 金币", "good"
)
self.add_log(f"收获 {quantity} 份{info['name']}{lucky},+{earned} 金币", "good")
levelups = self._gain_exp(info["exp"])
self._notify()
return {
@@ -440,7 +443,7 @@ class FarmEngine:
"level_ups": levelups,
}
def buy_animal(self, kind: Any) -> Dict[str, Any]:
def buy_animal(self, kind: Any) -> dict[str, Any]:
with self._lock:
if not isinstance(kind, str) or kind not in config.ANIMALS:
raise GameError(
@@ -469,7 +472,7 @@ class FarmEngine:
"animal": animal.to_dict(),
}
def feed(self, animal_id: Any, quantity: Any = None) -> Dict[str, Any]:
def feed(self, animal_id: Any, quantity: Any = None) -> dict[str, Any]:
with self._lock:
animal = self._get_animal(animal_id)
if animal.fed:
@@ -481,7 +484,7 @@ class FarmEngine:
try:
quantity = int(quantity)
except (TypeError, ValueError):
raise GameError("bad_param", "喂食数量必须是整数")
raise GameError("bad_param", "喂食数量必须是整数") from None
if quantity <= 0:
raise GameError("bad_param", "喂食数量必须大于 0")
if quantity > need:
@@ -507,7 +510,7 @@ class FarmEngine:
"animal": animal.to_dict(),
}
def collect(self, animal_id: Any) -> Dict[str, Any]:
def collect(self, animal_id: Any) -> dict[str, Any]:
with self._lock:
animal = self._get_animal(animal_id)
if animal.pending <= 0:
@@ -524,7 +527,8 @@ class FarmEngine:
self.state.total_collected += quantity
self.add_log(
f"收取 {quantity} 份{animal.product}({animal.name} #{animal.aid}),+{earned} 金币",
f"收取 {quantity} 份{animal.product}({animal.name} #{animal.aid}),"
f"+{earned} 金币",
"good",
)
levelups = self._gain_exp(animal.info["exp"] * quantity)
@@ -537,12 +541,12 @@ class FarmEngine:
"level_ups": levelups,
}
def buy_feed(self, quantity: Any = config.FEED_BULK) -> Dict[str, Any]:
def buy_feed(self, quantity: Any = config.FEED_BULK) -> dict[str, Any]:
with self._lock:
try:
quantity = int(quantity)
except (TypeError, ValueError):
raise GameError("bad_param", "饲料数量必须是整数")
raise GameError("bad_param", "饲料数量必须是整数") from None
if quantity <= 0:
raise GameError("bad_param", "饲料数量必须大于 0")
if quantity > config.MAX_FEED_PURCHASE:
@@ -561,7 +565,7 @@ class FarmEngine:
"feed": self.state.feed,
}
def buy_plot(self) -> Dict[str, Any]:
def buy_plot(self) -> dict[str, Any]:
with self._lock:
price = self.state.next_plot_price
if price < 0:
@@ -583,7 +587,7 @@ class FarmEngine:
"next_plot_price": self.state.next_plot_price,
}
def sleep(self) -> Dict[str, Any]:
def sleep(self) -> dict[str, Any]:
"""手动推进一天,带冷却避免刷时间。"""
with self._lock:
elapsed = time.time() - self._last_sleep
@@ -594,14 +598,14 @@ class FarmEngine:
events = self.advance_day(reason="休息")
return {"ok": True, "message": f"进入第 {self.state.day} 天", "events": events}
def pause(self, paused: Optional[bool] = None) -> Dict[str, Any]:
def pause(self, paused: bool | None = None) -> dict[str, Any]:
with self._lock:
self.paused = bool(paused) if paused is not None else not self.paused
self.add_log("时间已暂停" if self.paused else "时间继续流动", "warn")
self._notify()
return {"ok": True, "paused": self.paused}
def reset(self, keep_tick: bool = True) -> Dict[str, Any]:
def reset(self, keep_tick: bool = True) -> dict[str, Any]:
with self._lock:
tick = self.tick_seconds
self.state = GameState()
@@ -614,7 +618,7 @@ class FarmEngine:
self._notify()
return {"ok": True, "message": "农场已重置"}
def save(self, path: Optional[str] = None) -> Dict[str, Any]:
def save(self, path: str | None = None) -> dict[str, Any]:
"""把当前状态写入磁盘。
单机模式下由 TUI 直接调用;服务器模式里存档位置由牧场会话决定,
@@ -627,13 +631,13 @@ class FarmEngine:
return {"ok": True, "message": f"已存档到 {target}", "path": target}
# ================================================================ 时间推进
def advance_day(self, reason: str = "自动") -> List[str]:
def advance_day(self, reason: str = "自动") -> list[str]:
"""推进游戏内一天,返回本日发生的事件文本。"""
with self._lock:
state = self.state
state.day += 1
state.ap = state.max_ap
events: List[str] = []
events: list[str] = []
# ---- 作物生长
for plot in state.plots:
@@ -643,12 +647,14 @@ class FarmEngine:
plot.moisture -= 1
plot.growth = min(plot.grow_days, plot.growth + 1)
if plot.is_ripe:
events.append(f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟")
events.append(
f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟"
)
else:
events.append(f"{plot.index + 1} 号地缺水,作物停止生长")
# ---- 牲畜
dead: List[Animal] = []
dead: list[Animal] = []
for animal in state.animals:
if animal.fed:
animal.fed = False
@@ -710,7 +716,7 @@ class Scheduler:
self.engine = engine
self.enabled = enabled
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
def start(self) -> None:
if not self.enabled or self._thread is not None:
@@ -734,10 +740,8 @@ class Scheduler:
if deadline is None:
deadline = time.time() + engine.tick_seconds
if time.time() >= deadline:
try:
with contextlib.suppress(Exception):
engine.advance_day(reason="时间流逝")
except Exception:
pass
engine.next_tick_at = time.time() + engine.tick_seconds
else:
engine.next_tick_at = deadline
+4 -5
View File
@@ -11,7 +11,6 @@ from __future__ import annotations
import os
import sys
from typing import Optional
IS_WINDOWS = os.name == "nt"
@@ -42,7 +41,7 @@ class KeyReader:
def __init__(self) -> None:
self.available = sys.stdin is not None and sys.stdin.isatty()
self._fd: Optional[int] = None
self._fd: int | None = None
self._saved_term = None
if self.available and not IS_WINDOWS:
@@ -65,13 +64,13 @@ class KeyReader:
self.available = False
# ------------------------------------------------------------------ 读取
def poll(self) -> Optional[str]:
def poll(self) -> str | None:
"""非阻塞读取一个按键;没有按键时返回 None。"""
if not self.available:
return None
return self._poll_windows() if IS_WINDOWS else self._poll_posix()
def _poll_windows(self) -> Optional[str]:
def _poll_windows(self) -> str | None:
import msvcrt
if not msvcrt.kbhit():
@@ -86,7 +85,7 @@ class KeyReader:
return _WIN_CTRL[char]
return char.lower()
def _poll_posix(self) -> Optional[str]:
def _poll_posix(self) -> str | None:
import select
if self._fd is None:
+28 -29
View File
@@ -7,7 +7,7 @@
from __future__ import annotations
import itertools
from typing import Any, Dict, List, Optional
from typing import Any
from . import config
@@ -20,13 +20,13 @@ def clamp(value: float, low: float, high: float) -> float:
class Plot:
"""一块土地。"""
__slots__ = ("index", "crop", "growth", "moisture", "planted_day")
__slots__ = ("crop", "growth", "index", "moisture", "planted_day")
def __init__(self, index: int) -> None:
self.index = index # 0-based 下标,对外展示用 index+1
self.crop: Optional[str] = None
self.growth: float = 0.0 # 已生长天数
self.moisture: int = 0 # 剩余可支撑天数
self.index = index # 0-based 下标,对外展示用 index+1
self.crop: str | None = None
self.growth: float = 0.0 # 已生长天数
self.moisture: int = 0 # 剩余可支撑天数
self.planted_day: int = 0
# ---------------------------------------------------------------- 查询
@@ -63,7 +63,7 @@ class Plot:
return name
# ---------------------------------------------------------------- 序列化
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
info = config.CROPS.get(self.crop) if self.crop else None
return {
"index": self.index,
@@ -81,7 +81,7 @@ class Plot:
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Plot":
def from_dict(cls, data: dict[str, Any]) -> Plot:
plot = cls(int(data.get("index", 0)))
crop = data.get("crop")
# 校验存档里的作物名仍然合法,避免旧存档导致 KeyError
@@ -97,19 +97,19 @@ class Animal:
_id_counter = itertools.count(1)
__slots__ = ("aid", "kind", "fed", "progress", "starve", "pending")
__slots__ = ("aid", "fed", "kind", "pending", "progress", "starve")
def __init__(self, kind: str, aid: Optional[int] = None) -> None:
def __init__(self, kind: str, aid: int | None = None) -> None:
self.aid = aid if aid is not None else next(Animal._id_counter)
self.kind = kind
self.fed = False # 今天是否已喂食
self.progress = 0 # 距下次产出的累计天数
self.starve = 0 # 连续未进食天数
self.pending = 0 # 待收集的产出数量
self.fed = False # 今天是否已喂食
self.progress = 0 # 距下次产出的累计天数
self.starve = 0 # 连续未进食天数
self.pending = 0 # 待收集的产出数量
# ---------------------------------------------------------------- 查询
@property
def info(self) -> Dict[str, Any]:
def info(self) -> dict[str, Any]:
return config.ANIMALS[self.kind]
@property
@@ -134,7 +134,7 @@ class Animal:
return self.starve >= 2
# ---------------------------------------------------------------- 序列化
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return {
"id": self.aid,
"kind": self.kind,
@@ -150,7 +150,7 @@ class Animal:
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> Optional["Animal"]:
def from_dict(cls, data: dict[str, Any]) -> Animal | None:
kind = data.get("kind")
if kind not in config.ANIMALS:
return None
@@ -174,12 +174,12 @@ class GameState:
self.exp: int = 0
self.ap: int = config.START_AP
self.feed: int = config.START_FEED
self.plots: List[Plot] = [Plot(i) for i in range(config.BASE_PLOTS)]
self.animals: List[Animal] = []
self.log: List[Dict[str, Any]] = []
self.plots: list[Plot] = [Plot(i) for i in range(config.BASE_PLOTS)]
self.animals: list[Animal] = []
self.log: list[dict[str, Any]] = []
# 市场价格(每天波动):key -> 当前单价
self.market: Dict[str, float] = {
self.market: dict[str, float] = {
key: float(info["base_price"]) for key, info in config.CROPS.items()
}
for key, info in config.ANIMALS.items():
@@ -208,7 +208,7 @@ class GameState:
"""下一块土地的价格;已达上限时返回 -1。"""
if len(self.plots) >= self.max_plots:
return -1
return int(config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH ** self.plots_bought))
return int(config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH**self.plots_bought))
@property
def exp_needed(self) -> int:
@@ -229,8 +229,7 @@ class GameState:
def net_worth(self) -> float:
"""净资产:金币 + 已购地价 + 牲畜折价 + 库存 + 地里未收的作物。"""
land = sum(
config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH ** i)
for i in range(self.plots_bought)
config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH**i) for i in range(self.plots_bought)
)
crops = 0.0
for plot in self.plots:
@@ -243,7 +242,7 @@ class GameState:
return config.goal_index(self.net_worth)
@property
def next_goal(self) -> Optional[Dict[str, Any]]:
def next_goal(self) -> dict[str, Any] | None:
nxt = self.goal_index + 1
if nxt >= len(config.GOALS):
return None
@@ -251,7 +250,7 @@ class GameState:
return {"amount": amount, "text": text}
# ---------------------------------------------------------------- 序列化
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return {
"day": self.day,
"gold": self.gold,
@@ -281,7 +280,7 @@ class GameState:
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "GameState":
def from_dict(cls, data: dict[str, Any]) -> GameState:
state = cls()
state.day = max(1, int(data.get("day", 1)))
state.gold = max(0, int(data.get("gold", config.START_GOLD)))
@@ -295,11 +294,11 @@ class GameState:
count = int(clamp(len(plots) or config.BASE_PLOTS, config.BASE_PLOTS, config.MAX_PLOTS))
if len(plots) < count:
plots.extend(Plot(i) for i in range(len(plots), count))
state.plots = plots[:config.MAX_PLOTS]
state.plots = plots[: config.MAX_PLOTS]
for i, plot in enumerate(state.plots):
plot.index = i
animals: List[Animal] = []
animals: list[Animal] = []
for item in data.get("animals", [])[: config.MAX_ANIMALS]:
if isinstance(item, dict):
animal = Animal.from_dict(item)
+6 -5
View File
@@ -18,14 +18,14 @@ from __future__ import annotations
import urllib.parse
import urllib.request
from typing import Any, Optional, Union
from typing import Any
LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]", "0.0.0.0", ""}
_no_proxy_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def is_loopback(host: Optional[str]) -> bool:
def is_loopback(host: str | None) -> bool:
"""判断主机名是否是本机回环(含 127.x.x.x 网段与 IPv6 回环)。"""
if host is None:
return False
@@ -35,7 +35,7 @@ def is_loopback(host: Optional[str]) -> bool:
return host.startswith("127.")
def host_of(target: Union[str, urllib.request.Request]) -> Optional[str]:
def host_of(target: str | urllib.request.Request) -> str | None:
url = target.full_url if isinstance(target, urllib.request.Request) else target
try:
return urllib.parse.urlsplit(url).hostname
@@ -43,8 +43,9 @@ def host_of(target: Union[str, urllib.request.Request]) -> Optional[str]:
return None
def open_url(target: Union[str, urllib.request.Request], timeout: float = 6.0,
host: Optional[str] = None) -> Any:
def open_url(
target: str | urllib.request.Request, timeout: float = 6.0, host: str | None = None
) -> Any:
"""打开一个 URL 或 Request,回环地址自动绕过代理。
返回值是 ``http.client.HTTPResponse``,用法与 ``urllib.request.urlopen`` 一致。
+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"
)
+6 -6
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import json
import os
import time
from typing import Any, Dict, Optional, Tuple
from typing import Any
from .models import GameState
@@ -22,7 +22,7 @@ def default_save_path() -> str:
return os.path.join(root, "saves", "save.json")
def save_state(state: GameState, path: Optional[str] = None) -> str:
def save_state(state: GameState, path: str | None = None) -> str:
"""把状态写入磁盘,返回实际写入路径。"""
path = path or default_save_path()
directory = os.path.dirname(os.path.abspath(path))
@@ -45,7 +45,7 @@ def save_state(state: GameState, path: Optional[str] = None) -> str:
return path
def _raw_state(state: GameState) -> Dict[str, Any]:
def _raw_state(state: GameState) -> dict[str, Any]:
"""导出可完整还原的原始字段(与 to_dict 的展示字段不同)。"""
return {
"day": state.day,
@@ -85,7 +85,7 @@ def _raw_state(state: GameState) -> Dict[str, Any]:
}
def load_state(path: Optional[str] = None) -> Tuple[Optional[GameState], str]:
def load_state(path: str | None = None) -> tuple[GameState | None, str]:
"""读取存档。
返回 ``(state, message)``;失败时 ``state`` 为 None 并给出原因,
@@ -96,7 +96,7 @@ def load_state(path: Optional[str] = None) -> Tuple[Optional[GameState], str]:
return None, f"未找到存档 {path}"
try:
with open(path, "r", encoding="utf-8") as handle:
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
return None, f"存档无法解析:{exc}"
@@ -120,7 +120,7 @@ def load_state(path: Optional[str] = None) -> Tuple[Optional[GameState], str]:
return state, f"已载入存档(第 {state.day} 天)"
def delete_save(path: Optional[str] = None) -> bool:
def delete_save(path: str | None = None) -> bool:
path = path or default_save_path()
try:
os.remove(path)
+120 -73
View File
@@ -13,13 +13,14 @@
from __future__ import annotations
import contextlib
import os
import re
import shutil
import sys
import time
import unicodedata
from typing import Any, Dict, List, Optional, Tuple
from typing import Any
from . import config
from .engine import GameError
@@ -41,11 +42,11 @@ C_PURPLE = "\x1b[38;5;176m"
C_BOLD = "\x1b[1m"
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
CELL_WIDTH = 17 # 单块农田格子的显示宽度
CELL_WIDTH = 17 # 单块农田格子的显示宽度
CELL_INNER = CELL_WIDTH - 2
SIDE_WIDTH = 36 # 右侧栏宽度
SIDE_WIDTH = 36 # 右侧栏宽度
MIN_TERM_WIDTH = 76
MAX_VISIBLE_ANIMALS = 5 # 侧栏同时最多展示的牲畜数量
MAX_VISIBLE_ANIMALS = 5 # 侧栏同时最多展示的牲畜数量
def strip_ansi(text: str) -> str:
@@ -103,7 +104,7 @@ def bar(filled: int, total: int, width: int, on: str = "█", off: str = "░")
"""生成进度条字符串(不含颜色)。"""
if total <= 0:
total = 1
filled = max(0, min(width, int(round(width * filled / total))))
filled = max(0, min(width, round(width * filled / total)))
return on * filled + off * (width - filled)
@@ -127,14 +128,15 @@ def enable_ansi() -> bool:
return False
def terminal_size() -> Tuple[int, int]:
def terminal_size() -> tuple[int, int]:
size = shutil.get_terminal_size(fallback=(110, 30))
return max(MIN_TERM_WIDTH, size.columns), max(18, size.lines)
# ---------------------------------------------------------------- 绘制工具
def draw_box(title: str, body: List[str], width: int, accent: Optional[str] = None,
use_color: bool = True) -> List[str]:
def draw_box(
title: str, body: list[str], width: int, accent: str | None = None, use_color: bool = True
) -> list[str]:
"""画一个带标题的方框,返回行列表(宽度精确等于 ``width``)。"""
inner = max(0, width - 2)
head = f" {title} "
@@ -144,30 +146,34 @@ def draw_box(title: str, body: List[str], width: int, accent: Optional[str] = No
top = "┌─" + head + "─" * fill_len + "┐"
bottom = "└" + "─" * inner + "┘"
lines = [top]
for row in body:
lines.append("│" + pad(truncate(row, inner), inner) + "│")
lines.extend("│" + pad(truncate(row, inner), inner) + "│" for row in body)
lines.append(bottom)
return lines
def hstack(left: List[str], right: List[str], gap: int = 1) -> List[str]:
def hstack(left: list[str], right: list[str], gap: int = 1) -> list[str]:
"""把两列行按行并排,短的一侧用空格补齐。"""
height = max(len(left), len(right))
left_width = max((display_width(line) for line in left), default=0)
out = []
for i in range(height):
l = left[i] if i < len(left) else ""
r = right[i] if i < len(right) else ""
out.append(pad(l, left_width) + " " * gap + r)
left_line = left[i] if i < len(left) else ""
right_line = right[i] if i < len(right) else ""
out.append(pad(left_line, left_width) + " " * gap + right_line)
return out
class TuiApp:
"""交互式界面主程序。"""
def __init__(self, engine: Any, save_path: Optional[str] = None,
use_color: bool = True, autosave: bool = True,
server_label: str = "") -> None:
def __init__(
self,
engine: Any,
save_path: str | None = None,
use_color: bool = True,
autosave: bool = True,
server_label: str = "",
) -> None:
# engine 既可以是本地 FarmEngine,也可以是远程 RemoteFarm——只要接口一致
self.engine = engine
self.save_path = save_path or default_save_path()
@@ -177,11 +183,11 @@ class TuiApp:
self.reader = KeyReader()
self.running = True
self.mode = "main" # main / help / select_crop / select_animal / confirm_reset
self.mode = "main" # main / help / select_crop / select_animal / confirm_reset
self.plot_cursor = 0
self.animal_cursor = 0
self.grid_cols = 3
self.menu: List[Dict[str, Any]] = []
self.menu: list[dict[str, Any]] = []
self.menu_title = ""
self.pending_action = ""
self.message = ""
@@ -192,20 +198,18 @@ class TuiApp:
# ================================================================ 生命周期
def _enter(self) -> None:
if enable_ansi() and os.name == "nt":
try:
# 个别终端不支持重新配置,失败就按原编码继续
with contextlib.suppress(Exception):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
sys.stdout.write("\x1b[?25l\x1b[2J\x1b[H")
sys.stdout.write(C_RESET)
sys.stdout.flush()
def _exit(self) -> None:
if self.autosave:
try:
self.engine.save(self.save_path) # 本地引擎写文件;远程引擎让服务器落盘
except Exception:
pass
# 本地引擎写文件;远程引擎让服务器落盘
with contextlib.suppress(Exception):
self.engine.save(self.save_path)
self.reader.close()
sys.stdout.write(C_RESET + "\x1b[?25h\n")
sys.stdout.flush()
@@ -348,7 +352,7 @@ class TuiApp:
self._flash(f"{plot.index + 1} 号地已种了作物,先收获再说", C_RED)
return
items: List[Dict[str, Any]] = []
items: list[dict[str, Any]] = []
catalog = {c["key"]: c for c in self.engine.catalog()["crops"]}
for key, info in config.CROPS.items():
entry = catalog.get(key, {})
@@ -470,7 +474,7 @@ class TuiApp:
middle = hstack(farm_col, side_col, gap=1)
bottom = self._render_bottom(snapshot, width)
lines: List[str] = []
lines: list[str] = []
lines.append(self._render_header(snapshot, width))
lines.extend(self._render_status(snapshot, width))
lines.extend(middle)
@@ -480,15 +484,14 @@ class TuiApp:
lines = lines[: height - 1]
out = ["\x1b[H"]
for line in lines:
out.append(truncate(line, width) + "\x1b[K\r\n")
out.extend(truncate(line, width) + "\x1b[K\r\n" for line in lines)
# 清掉本轮剩余行
out.append("\x1b[J")
sys.stdout.write("".join(out))
sys.stdout.flush()
# ---------------------------------------------------------- header
def _render_header(self, snap: Dict[str, Any], width: int) -> str:
def _render_header(self, snap: dict[str, Any], width: int) -> str:
title = color(" SimpleFarm", C_BOLD + C_GREEN, self.use_color)
if self.server_label:
title += color(f" {self.server_label} ", C_BOLD + C_CYAN, self.use_color)
@@ -514,14 +517,12 @@ class TuiApp:
)
gap = max(1, width - display_width(left) - display_width(right) - 1)
line = left + "─" * gap + right + "╮"
return line
return left + "─" * gap + right + "╮"
# ---------------------------------------------------------- 状态区
def _render_status(self, snap: Dict[str, Any], width: int) -> List[str]:
def _render_status(self, snap: dict[str, Any], width: int) -> list[str]:
net_worth = snap["net_worth"]
goal = snap["next_goal"]
reached = snap["goal_index"]
ap_ratio = snap["ap"] / max(1, snap["max_ap"])
ap_color = C_GREEN if ap_ratio > 0.4 else (C_ORANGE if ap_ratio > 0 else C_RED)
@@ -568,14 +569,14 @@ class TuiApp:
return [pad(line1, width), pad(line2, width)]
# ---------------------------------------------------------- 农田
def _render_farm(self, snap: Dict[str, Any], width: int) -> List[str]:
def _render_farm(self, snap: dict[str, Any], width: int) -> list[str]:
inner = width - 2
# 格子之间留 1 列间隙,避免相邻边框粘成 "││"
cols = max(1, (inner + 1) // (CELL_WIDTH + 1))
self.grid_cols = cols
plots = snap["plots"]
rows: List[str] = []
rows: list[str] = []
for start in range(0, len(plots), cols):
chunk = plots[start : start + cols]
parts = [self._render_cell(cell, width).split("\n") for cell in chunk]
@@ -590,7 +591,7 @@ class TuiApp:
title = f"农田 {snap['plot_count']}/{snap['max_plots']} 块"
return draw_box(title, rows, width, accent=C_GREEN, use_color=self.use_color)
def _render_cell(self, plot: Dict[str, Any], farm_width: int) -> str:
def _render_cell(self, plot: dict[str, Any], farm_width: int) -> str:
selected = plot["index"] == self.plot_cursor
border = C_BOLD + C_GREEN if selected else C_DIM
@@ -611,9 +612,12 @@ class TuiApp:
name_text = color(f" {plot['position']} {info['name']}", C_WHITE, self.use_color)
ratio = plot["growth"] / max(1, plot["grow_days"])
filled_count = max(0, min(10, int(round(ratio * 10))))
progress = color("█" * filled_count + "░" * (10 - filled_count),
C_GREEN if plot["is_ripe"] else C_CYAN, self.use_color)
filled_count = max(0, min(10, round(ratio * 10)))
progress = color(
"█" * filled_count + "░" * (10 - filled_count),
C_GREEN if plot["is_ripe"] else C_CYAN,
self.use_color,
)
water_level = plot["moisture"]
water = color(
@@ -629,30 +633,40 @@ class TuiApp:
top = color("╭" + "─" * CELL_INNER + "╮", border, self.use_color)
bottom = color("╰" + "─" * CELL_INNER + "╯", border, self.use_color)
body = [
color("│", border, self.use_color) + pad(line1, CELL_INNER) + color("│", border, self.use_color),
color("│", border, self.use_color) + pad(line2, CELL_INNER) + color("│", border, self.use_color),
color("│", border, self.use_color)
+ pad(line1, CELL_INNER)
+ color("│", border, self.use_color),
color("│", border, self.use_color)
+ pad(line2, CELL_INNER)
+ color("│", border, self.use_color),
]
return "\n".join([top] + body + [bottom])
return "\n".join([top, *body, bottom])
# ---------------------------------------------------------- 侧栏
def _render_side(self, snap: Dict[str, Any], side_width: int = SIDE_WIDTH) -> List[str]:
def _render_side(self, snap: dict[str, Any], side_width: int = SIDE_WIDTH) -> list[str]:
animals = snap["animals"]
rows: List[str] = []
rows: list[str] = []
if not animals:
rows.append(color(" 牧场空空的,按 B 购买", C_DIM, self.use_color))
rows.append(color(" 鸡/羊/牛需每日喂食才产出", C_DIM, self.use_color))
else:
# 只渲染一个跟随光标的窗口,避免牲畜变多后把界面撑爆
start = max(0, min(max(0, len(animals) - MAX_VISIBLE_ANIMALS),
self.animal_cursor - MAX_VISIBLE_ANIMALS // 2))
start = max(
0,
min(
max(0, len(animals) - MAX_VISIBLE_ANIMALS),
self.animal_cursor - MAX_VISIBLE_ANIMALS // 2,
),
)
for index in range(start, min(len(animals), start + MAX_VISIBLE_ANIMALS)):
animal = animals[index]
focused = index == self.animal_cursor
marker = color("▸", C_BOLD + C_GREEN, self.use_color) if focused else " "
if animal["pending"] > 0:
status = color(f"{animal['product']}x{animal['pending']}",
C_BOLD + C_GOLD, self.use_color)
status = color(
f"{animal['product']}x{animal['pending']}", C_BOLD + C_GOLD, self.use_color
)
elif animal["is_sick"]:
status = color("虚弱!", C_RED, self.use_color)
elif not animal["fed"]:
@@ -662,18 +676,22 @@ class TuiApp:
else:
status = color(f"再{animal['days_to_product']}天", C_GRAY, self.use_color)
head = marker + color(f" #{animal['id']} {animal['name']}",
C_WHITE, self.use_color)
head = marker + color(f" #{animal['id']} {animal['name']}", C_WHITE, self.use_color)
rows.append(pad(head, 13) + status)
hidden = len(animals) - start - MAX_VISIBLE_ANIMALS
if hidden > 0:
rows.append(color(f" …还有 {hidden} 只(Tab 切换)", C_DIM, self.use_color))
pasture = draw_box(f"牧场 {snap['animal_count']}/{snap['max_animals']}", rows,
side_width, accent=C_ORANGE, use_color=self.use_color)
pasture = draw_box(
f"牧场 {snap['animal_count']}/{snap['max_animals']}",
rows,
side_width,
accent=C_ORANGE,
use_color=self.use_color,
)
# ---- 市场行情(中国习惯:涨红跌绿)
market_rows: List[str] = []
market_rows: list[str] = []
name_by_key = {k: v["name"] for k, v in config.CROPS.items()}
ranked = sorted(
((k, v) for k, v in snap["market"].items() if not k.startswith("animal:")),
@@ -694,19 +712,20 @@ class TuiApp:
market_rows.append(
color(f" 饲料 {config.FEED_UNIT_PRICE} 金币/份(按 K 购买)", C_DIM, self.use_color)
)
market = draw_box("市场行情", market_rows, side_width,
accent=C_CYAN, use_color=self.use_color)
market = draw_box(
"市场行情", market_rows, side_width, accent=C_CYAN, use_color=self.use_color
)
return pasture + market
# ---------------------------------------------------------- 底部
def _render_bottom(self, snap: Dict[str, Any], width: int) -> List[str]:
def _render_bottom(self, snap: dict[str, Any], width: int) -> list[str]:
if self.mode == "help":
return self._render_help(width)
if self.mode in ("select_crop", "select_animal"):
return self._render_menu(width)
rows: List[str] = []
rows: list[str] = []
now = time.time()
if self.message and now < self.message_until:
rows.append(color(" " + self.message, self.message_color, self.use_color))
@@ -737,8 +756,8 @@ class TuiApp:
]
return log_box + [pad(h, width) for h in hint]
def _render_menu(self, width: int) -> List[str]:
rows: List[str] = []
def _render_menu(self, width: int) -> list[str]:
rows: list[str] = []
rows.append(color(" " + self.menu_title + "(按 Esc 取消)", C_CYAN, self.use_color))
for i, item in enumerate(self.menu, start=1):
if item["locked"]:
@@ -752,32 +771,60 @@ class TuiApp:
rows.append(label + pad("", max(1, 20 - display_width(label))) + detail)
return draw_box("菜单", rows, width, accent=C_GOLD, use_color=self.use_color)
def _render_help(self, width: int) -> List[str]:
def _render_help(self, width: int) -> list[str]:
rows = [
color(" 玩法目标:靠种地和养殖把净资产做到 10000 金币,解锁全部 5 个里程碑。", C_WHITE, self.use_color),
color(
" 玩法目标:靠种地和养殖把净资产做到 10000 金币,解锁全部 5 个里程碑。",
C_WHITE,
self.use_color,
),
"",
color(" 时间", C_BOLD + C_GOLD, self.use_color)
+ color(" 每 15 秒自动推进一天,作物成长、牲畜产奶、市场价格都在这一天结算。", C_GRAY, self.use_color),
+ color(
" 每 15 秒自动推进一天,作物成长、牲畜产奶、市场价格都在这一天结算。",
C_GRAY,
self.use_color,
),
color(" 水分", C_BOLD + C_GOLD, self.use_color)
+ color(" 作物每天消耗 1 点水分,水为 0 就停止生长。按 W 浇水恢复到 3 点。", C_GRAY, self.use_color),
+ color(
" 作物每天消耗 1 点水分,水为 0 就停止生长。按 W 浇水恢复到 3 点。",
C_GRAY,
self.use_color,
),
color(" 行动点", C_BOLD + C_GOLD, self.use_color)
+ color(" 每天 10 点,播种/浇水/喂食各消耗 1 点,收获和收取免费。", C_GRAY, self.use_color),
+ color(
" 每天 10 点,播种/浇水/喂食各消耗 1 点,收获和收取免费。", C_GRAY, self.use_color
),
color(" 牲畜", C_BOLD + C_GOLD, self.use_color)
+ color(" 必须每天喂食才会产出;连续 3 天没吃东西会离开农场。", C_GRAY, self.use_color),
+ color(
" 必须每天喂食才会产出;连续 3 天没吃东西会离开农场。", C_GRAY, self.use_color
),
color(" 市场", C_BOLD + C_GOLD, self.use_color)
+ color(" 作物和畜产品价格每天波动 ±18%,高点卖出更赚。", C_GRAY, self.use_color),
"",
color(" 快捷键", C_BOLD + C_GOLD, self.use_color)
+ color(" P 播种 / W 浇水 / H 收获 / E 一键收获 / F 喂食 / C 收取产出", C_GRAY, self.use_color),
+ color(
" P 播种 / W 浇水 / H 收获 / E 一键收获 / F 喂食 / C 收取产出",
C_GRAY,
self.use_color,
),
color(" ", C_GRAY, self.use_color)
+ color(" B 买动物 / L 开垦土地 / K 买饲料 / N 休息 / 空格 暂停 / S 存档 / R 重置", C_GRAY, self.use_color),
+ color(
" B 买动物 / L 开垦土地 / K 买饲料 / N 休息 / 空格 暂停 / S 存档 / R 重置",
C_GRAY,
self.use_color,
),
"",
color(" AI 接入", C_BOLD + C_GOLD, self.use_color)
+ color(" 游戏跑在服务器进程里,HTTP 接口与 WebSocket 通道同时开放,",
C_GRAY, self.use_color),
+ color(
" 游戏跑在服务器进程里,HTTP 接口与 WebSocket 通道同时开放,",
C_GRAY,
self.use_color,
),
color(" ", C_GRAY, self.use_color)
+ color(" AI 与你在同一个农场里操作(见 README「给 AI 的接口」)。",
C_GRAY, self.use_color),
+ color(
" AI 与你在同一个农场里操作(见 README「给 AI 的接口」)。", C_GRAY, self.use_color
),
"",
color(" 按任意键返回", C_DIM, self.use_color),
]
+118 -61
View File
@@ -98,21 +98,31 @@ def build_parser() -> argparse.ArgumentParser:
# ---- 服务器
p_serve = sub.add_parser("serve", help="启动游戏服务器(可后台运行)")
p_serve.add_argument("--daemon", action="store_true", help="转到后台运行(守护进程)")
p_serve.add_argument("--host", default="127.0.0.1",
help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0")
p_serve.add_argument(
"--host", default="127.0.0.1", help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0"
)
p_serve.add_argument("--port", type=int, default=8765, help="监听端口,默认 8765")
p_serve.add_argument("--token", default=None, help="访问令牌,设置后接口需要携带")
p_serve.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS,
help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒")
p_serve.add_argument(
"--tick",
type=float,
default=config.DEFAULT_TICK_SECONDS,
help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒",
)
p_serve.add_argument("--save-dir", default=None, help="存档目录,默认 <项目>/saves")
p_serve.add_argument("--world", default="SimpleFarm 公共农场", help="世界名称")
p_serve.add_argument("--max-sessions", type=int, default=64, help="同时在线的牧场上限")
p_serve.add_argument("--idle-timeout", type=float, default=1800.0,
help="无人访问多少秒后把牧场移出内存(存档保留)")
p_serve.add_argument(
"--idle-timeout",
type=float,
default=1800.0,
help="无人访问多少秒后把牧场移出内存(存档保留)",
)
p_serve.add_argument("--no-remote-control", action="store_true", help="禁止接口停机")
p_serve.add_argument("--access-log", action="store_true", help="打印每个 HTTP 请求")
p_serve.add_argument("--log-level", default="info",
choices=["critical", "error", "warning", "info", "debug"])
p_serve.add_argument(
"--log-level", default="info", choices=["critical", "error", "warning", "info", "debug"]
)
p_serve.add_argument("--log-file", default=None, help="守护进程日志路径")
p_serve.add_argument("--pid-file", default=None, help="守护进程 pid 文件路径")
@@ -139,8 +149,7 @@ def build_parser() -> argparse.ArgumentParser:
p_play.add_argument("--token", default=None)
p_play.add_argument("--name", default="", help="在服务器上显示的昵称")
p_play.add_argument("--no-color", action="store_true")
p_play.add_argument("--no-realtime", action="store_true",
help="不用 WebSocket,改走 HTTP 轮询")
p_play.add_argument("--no-realtime", action="store_true", help="不用 WebSocket,改走 HTTP 轮询")
p_solo = sub.add_parser("solo", help="离线单机模式(本地引擎 + 终端界面,不开服务器)")
p_solo.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS)
@@ -150,8 +159,9 @@ def build_parser() -> argparse.ArgumentParser:
p_do = sub.add_parser("do", help="执行一个动作(脚本/AI 用途)")
p_do.add_argument("action", help="动作名:plant / water / harvest / ...")
p_do.add_argument("params", nargs="?", default="{}",
help='JSON 参数,例如 \'{"plot":1,"crop":"wheat"}\'')
p_do.add_argument(
"params", nargs="?", default="{}", help='JSON 参数,例如 \'{"plot":1,"crop":"wheat"}\''
)
_add_client_args(p_do)
p_batch = sub.add_parser("batch", help="批量执行动作(一个请求干多件事)")
@@ -202,10 +212,12 @@ def make_client(args, realtime: bool = False):
try:
return RemoteFarm(
endpoint=args.url, farm=getattr(args, "farm", "default"),
endpoint=args.url,
farm=getattr(args, "farm", "default"),
token=getattr(args, "token", None),
name=getattr(args, "name", "") or "cli",
realtime=realtime, autostart=True,
realtime=realtime,
autostart=True,
)
except GameError as exc:
hint = (
@@ -213,7 +225,7 @@ def make_client(args, realtime: bool = False):
"先启动服务器:python main.py serve --daemon\n"
"再确认地址端口:python main.py status"
)
raise SystemExit(die(f"无法连接服务器:{exc.message}", hint))
raise SystemExit(die(f"无法连接服务器:{exc.message}", hint)) from None
# ---------------------------------------------------------------------- 子命令
@@ -221,11 +233,20 @@ def cmd_serve(args) -> int:
from farm.server.__main__ import main as server_main
argv = [
"--host", args.host, "--port", str(args.port),
"--tick", str(args.tick), "--world", args.world,
"--max-sessions", str(args.max_sessions),
"--idle-timeout", str(args.idle_timeout),
"--log-level", args.log_level,
"--host",
args.host,
"--port",
str(args.port),
"--tick",
str(args.tick),
"--world",
args.world,
"--max-sessions",
str(args.max_sessions),
"--idle-timeout",
str(args.idle_timeout),
"--log-level",
args.log_level,
]
if args.save_dir:
argv += ["--save-dir", args.save_dir]
@@ -265,7 +286,7 @@ def cmd_status(args) -> int:
if not info["running"]:
print(warn("• 服务器未运行"))
print(dim(f" 启动:python main.py serve --daemon"))
print(dim(" 启动:python main.py serve --daemon"))
print(dim(f" 地址:http://{args.host}:{args.port}/"))
return 1
@@ -284,8 +305,10 @@ def cmd_status(args) -> int:
if world and world.get("leaderboard"):
print(" 排行榜")
for row in world["leaderboard"][:5]:
print(f" #{row['rank']} {row['farm']:<14} 净资产 {row['net_worth']:>8.0f}"
f" · 第 {row['day']} 天 · Lv.{row['level']} · 在线 {row['online']}")
print(
f" #{row['rank']} {row['farm']:<14} 净资产 {row['net_worth']:>8.0f}"
f" · 第 {row['day']} 天 · Lv.{row['level']} · 在线 {row['online']}"
)
if args.logs:
print(dim(" ---- 日志尾部 ----"))
@@ -317,8 +340,9 @@ def cmd_play(args) -> int:
)
client = make_client(args, realtime=not args.no_realtime)
label = f"{client.farm} @ {client.host}"
app = TuiApp(client, save_path=None, use_color=not args.no_color,
autosave=True, server_label=label)
app = TuiApp(
client, save_path=None, use_color=not args.no_color, autosave=True, server_label=label
)
try:
app.run()
finally:
@@ -366,7 +390,10 @@ def cmd_do(args) -> int:
try:
params = json.loads(args.params) if args.params else {}
except json.JSONDecodeError as exc:
return die(f"参数不是合法 JSON:{exc}", '例子:python main.py do plant \'{"plot":1,"crop":"wheat"}\'')
return die(
f"参数不是合法 JSON:{exc}",
'例子:python main.py do plant \'{"plot":1,"crop":"wheat"}\'',
)
if not isinstance(params, dict):
return die("参数必须是 JSON 对象")
@@ -374,19 +401,25 @@ def cmd_do(args) -> int:
try:
result = client._action(args.action, params)
except GameError as exc:
return die(f"{args.action} 失败({exc.code}):{exc.message}",
"看看现在能做什么:python main.py actions")
return die(
f"{args.action} 失败({exc.code}):{exc.message}",
"看看现在能做什么:python main.py actions",
)
print(ok("✓ ") + str(result.get("message", "完成"))
+ dim(f" ({time.time() - started:.2f}s)"))
extra = {k: v for k, v in result.items()
if k not in ("ok", "message", "state", "log", "action") and not isinstance(v, (dict, list))}
print(ok("✓ ") + str(result.get("message", "完成")) + dim(f" ({time.time() - started:.2f}s)"))
extra = {
k: v
for k, v in result.items()
if k not in ("ok", "message", "state", "log", "action") and not isinstance(v, (dict, list))
}
if extra:
print(dim(" " + " ".join(f"{k}={v}" for k, v in extra.items())))
state = result.get("state") or {}
if state:
print(f" 第 {state.get('day')} 天 · 金币 {state.get('gold')} · "
f"行动点 {state.get('ap')}/{state.get('max_ap')} · 净资产 {state.get('net_worth')}")
print(
f" 第 {state.get('day')} 天 · 金币 {state.get('gold')} · "
f"行动点 {state.get('ap')}/{state.get('max_ap')} · 净资产 {state.get('net_worth')}"
)
return 0
@@ -397,8 +430,11 @@ def cmd_batch(args) -> int:
try:
items = json.loads(args.actions)
except json.JSONDecodeError as exc:
return die(f"actions 不是合法 JSON:{exc}",
'例子:python main.py batch \'[{"action":"harvest_all"},{"action":"buy_feed","params":{"quantity":5}}]\'')
return die(
f"actions 不是合法 JSON:{exc}",
"例子:python main.py batch "
'\'[{"action":"harvest_all"},{"action":"buy_feed","params":{"quantity":5}}]\'',
)
if not isinstance(items, list):
return die("actions 必须是 JSON 数组")
try:
@@ -416,8 +452,10 @@ def cmd_actions(args) -> int:
client = make_client(args)
state = client.snapshot()
items = client.available_actions()
print(f"牧场 {client.farm} · 第 {state.get('day')} 天 · 金币 {state.get('gold')} · "
f"行动点 {state.get('ap')}/{state.get('max_ap')}")
print(
f"牧场 {client.farm} · 第 {state.get('day')} 天 · 金币 {state.get('gold')} · "
f"行动点 {state.get('ap')}/{state.get('max_ap')}"
)
if not items:
print(warn("• 当前没有可执行的动作"))
print(dim(" 试试:python main.py do sleep (推进一天)"))
@@ -426,8 +464,8 @@ def cmd_actions(args) -> int:
for index, item in enumerate(items, start=1):
cost = item.get("cost") or {}
cost_text = " ".join(f"{k}:{v}" for k, v in cost.items()) or "免费"
print(f" [{index:>2}] {item['action']:<12} {json.dumps(item['params'], ensure_ascii=False):<42}"
f" ({cost_text})")
params_text = json.dumps(item["params"], ensure_ascii=False)
print(f" [{index:>2}] {item['action']:<12} {params_text:<42} ({cost_text})")
print(dim(f" {item.get('reason', '')}"))
print(dim("\n执行方式:python main.py do <动作名> '<参数JSON>'"))
return 0
@@ -440,11 +478,18 @@ def cmd_state(args) -> int:
return 0
snap = client.snapshot()
print(f"牧场 {snap.get('farm')}")
print(f"第 {snap.get('day')} 天"
+ (f" · {snap.get('seconds_to_tick')}s 后进入下一天"
if snap.get("seconds_to_tick") is not None else " · 已暂停"))
print(f"金币 {snap.get('gold')} 等级 Lv.{snap.get('level')} "
f"({snap.get('exp')}/{snap.get('exp_needed')})")
print(
f"第 {snap.get('day')} 天"
+ (
f" · {snap.get('seconds_to_tick')}s 后进入下一天"
if snap.get("seconds_to_tick") is not None
else " · 已暂停"
)
)
print(
f"金币 {snap.get('gold')} 等级 Lv.{snap.get('level')} "
f"({snap.get('exp')}/{snap.get('exp_needed')})"
)
print(f"行动点 {snap.get('ap')}/{snap.get('max_ap')} 饲料 {snap.get('feed')}")
print(f"净资产 {snap.get('net_worth')}")
print(f"土地 {snap.get('plot_count')}/{snap.get('max_plots')} 块")
@@ -452,11 +497,16 @@ def cmd_state(args) -> int:
if plot["is_empty"]:
continue
status = "可收获" if plot["is_ripe"] else f"还需{plot['days_left']}天"
print(f" {plot['position']:>2} 号地 {plot['crop_name']:<6} 生长 {plot['growth']}"
f"/{plot['grow_days']} 水分 {plot['moisture']} · {status}")
print(
f" {plot['position']:>2} 号地 {plot['crop_name']:<6} 生长 {plot['growth']}"
f"/{plot['grow_days']} 水分 {plot['moisture']} · {status}"
)
for animal in snap.get("animals", []):
note = (f"{animal['product']} x{animal['pending']} 待收" if animal["pending"]
else ("已喂食" if animal["fed"] else "未喂食"))
note = (
f"{animal['product']} x{animal['pending']} 待收"
if animal["pending"]
else ("已喂食" if animal["fed"] else "未喂食")
)
print(f" #{animal['id']} {animal['name']:<4} {note}")
return 0
@@ -471,7 +521,6 @@ def cmd_log(args) -> int:
def cmd_watch(args) -> int:
"""实时滚动:状态每轮刷新,日志增量打印。适合盯着 AI 玩。"""
import threading
client = make_client(args, realtime=True)
print(BANNER)
@@ -484,9 +533,11 @@ def cmd_watch(args) -> int:
last_seq = max(last_seq, int(entry.get("seq", 0)))
print(f"[D{entry['day']:>3}] {entry['text']}")
online = snap.get("online")
head = (f"第 {snap.get('day')} 天 | 金币 {snap.get('gold')} | "
f"Lv.{snap.get('level')} | 行动点 {snap.get('ap')}/{snap.get('max_ap')} | "
f"净资产 {snap.get('net_worth')}")
head = (
f"第 {snap.get('day')} 天 | 金币 {snap.get('gold')} | "
f"Lv.{snap.get('level')} | 行动点 {snap.get('ap')}/{snap.get('max_ap')} | "
f"净资产 {snap.get('net_worth')}"
)
if online is not None:
head += f" | 在线 {online}"
print("\r" + head + " " * 8, end="", flush=True)
@@ -517,13 +568,17 @@ def cmd_bot(args) -> int:
def cmd_farms(args) -> int:
client = make_client(args)
world = client.world()
print(f"世界:{world.get('world')} · 已运行 {world.get('uptime', 0):.0f} 秒 · "
f"牧场 {world.get('farms')} 个 · 连接 {world.get('clients')} 个")
print(
f"世界:{world.get('world')} · 已运行 {world.get('uptime', 0):.0f} 秒 · "
f"牧场 {world.get('farms')} 个 · 连接 {world.get('clients')} 个"
)
print(f"时钟:每 {world.get('tick_seconds')} 秒推进一天\n")
print(f" {'#':>2} {'牧场':<14} {'净资产':>9} {'金币':>7} {'天':>4} {'等级':>4} {'在线':>4}")
for row in world.get("leaderboard", []):
print(f" {row['rank']:>2} {row['farm']:<14} {row['net_worth']:>9.0f} "
f"{row['gold']:>7} {row['day']:>4} {row['level']:>4} {row['online']:>4}")
print(
f" {row['rank']:>2} {row['farm']:<14} {row['net_worth']:>9.0f} "
f"{row['gold']:>7} {row['day']:>4} {row['level']:>4} {row['online']:>4}"
)
return 0
@@ -601,9 +656,11 @@ def main(argv=None) -> int:
info = DaemonManager().status()
if info["running"]:
health = info.get("health") or {}
print(ok("✓ 服务器正在运行")
+ f" http://{info['host']}:{info['port']}/ "
+ dim(f"牧场 {health.get('farms', 0)} 个 · 在线 {health.get('clients', 0)}"))
print(
ok("✓ 服务器正在运行")
+ f" http://{info['host']}:{info['port']}/ "
+ dim(f"牧场 {health.get('farms', 0)} 个 · 在线 {health.get('clients', 0)}")
)
else:
print(warn("• 服务器未运行"))
except Exception:
@@ -616,7 +673,7 @@ def main(argv=None) -> int:
return die(f"未知命令 {args.command}", "python main.py --help 查看全部命令")
try:
return handler(args)
except SystemExit as exc: # make_client 里的友好退出
except SystemExit as exc: # make_client 里的友好退出
return int(exc.code or 0)
except KeyboardInterrupt:
print("\n已中断。")
+5
View File
@@ -82,6 +82,11 @@ ignore = [
"RUF001",
"RUF002",
"RUF003",
# PERF203 提示"循环里 try 有性能开销"。剩下两处都是**逐项容错**语义:
# 逐个牧场存档(失败要立刻上报,不能静默跳过)、收获全部时跳过失败的地块。
# 把 try 提到循环外会让"一项失败不影响其它项"这个行为消失——那是错的优化。
# 而且循环规模是牧场数 / 地块数级别(个位数),性能开销在这里无关紧要。
"PERF203",
]
[tool.ruff.lint.per-file-ignores]
+16 -16
View File
@@ -8,6 +8,7 @@
from __future__ import annotations
import contextlib
import os
import random
import sys
@@ -16,10 +17,10 @@ import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from farm import config # noqa: E402
from farm.engine import FarmEngine, GameError # noqa: E402
from farm.models import GameState, Plot # noqa: E402
from farm.storage import load_state, save_state # noqa: E402
from farm import config
from farm.engine import FarmEngine, GameError
from farm.models import GameState, Plot
from farm.storage import load_state, save_state
class EngineTestCase(unittest.TestCase):
@@ -190,14 +191,14 @@ class EngineTestCase(unittest.TestCase):
self.assertEqual(ctx.exception.code, "plot_limit")
def test_buy_plot_without_gold(self) -> None:
self.engine.state.level = 5 # 先解锁土地上限,才能走到"钱不够"这一步
self.engine.state.level = 5 # 先解锁土地上限,才能走到"钱不够"这一步
self.engine.state.gold = 0
with self.assertRaises(GameError) as ctx:
self.engine.buy_plot()
self.assertEqual(ctx.exception.code, "not_enough_gold")
def test_buy_plot_blocked_at_level_cap(self) -> None:
self.engine.state.gold = 10 ** 6
self.engine.state.gold = 10**6
with self.assertRaises(GameError) as ctx:
self.engine.buy_plot()
self.assertEqual(ctx.exception.code, "plot_limit")
@@ -262,7 +263,7 @@ class EngineTestCase(unittest.TestCase):
self.assertEqual(ctx.exception.code, "unknown_animal")
def test_pasture_limit(self) -> None:
self.engine.state.gold = 10 ** 6
self.engine.state.gold = 10**6
for _ in range(config.MAX_ANIMALS):
self.engine.buy_animal("chicken")
with self.assertRaises(GameError) as ctx:
@@ -280,7 +281,7 @@ class EngineTestCase(unittest.TestCase):
self.assertLess(self.engine.state.gold, gold) # 被扣了高价补货费
def test_buy_feed_validation(self) -> None:
self.engine.state.gold = 10 ** 6
self.engine.state.gold = 10**6
with self.assertRaises(GameError):
self.engine.buy_feed(0)
with self.assertRaises(GameError):
@@ -350,7 +351,7 @@ class EngineTestCase(unittest.TestCase):
def test_save_load_roundtrip(self) -> None:
self.engine.plant(1, "carrot")
self.engine.buy_animal("chicken")
self.engine.state.gold = 500 # 操作完成后再定金币,避免被扣费干扰断言
self.engine.state.gold = 500 # 操作完成后再定金币,避免被扣费干扰断言
self.engine.state.feed = 7
self.engine.state.day = 13
path = os.path.join(self.tmp, "roundtrip.json")
@@ -402,14 +403,14 @@ class EngineTestCase(unittest.TestCase):
self.assertEqual(state.day, 1)
self.assertEqual(state.gold, 0)
self.assertEqual(state.level, 1)
self.assertIsNone(state.plots[0].crop) # 非法作物被清空
self.assertEqual(state.animals, []) # 非法动物被丢弃
self.assertGreater(state.market["wheat"], 0) # 非法价格回落默认值
self.assertIsNone(state.plots[0].crop) # 非法作物被清空
self.assertEqual(state.animals, []) # 非法动物被丢弃
self.assertGreater(state.market["wheat"], 0) # 非法价格回落默认值
self.assertGreaterEqual(len(state.plots), config.BASE_PLOTS)
# ============================================================ 并发
def test_concurrent_operations_are_safe(self) -> None:
self.engine.state.gold = 10 ** 6
self.engine.state.gold = 10**6
errors = []
def worker(index: int) -> None:
@@ -418,10 +419,9 @@ class EngineTestCase(unittest.TestCase):
self.engine.snapshot()
self.engine.available_actions()
if index % 3 == 0:
try:
# 金币可能已经不够了,这里只关心它不崩溃
with contextlib.suppress(GameError):
self.engine.buy_feed(1)
except GameError:
pass
except Exception as exc: # 记录任何非预期异常
errors.append(exc)
+73 -48
View File
@@ -25,21 +25,21 @@ import unittest
from farm import config
from farm.engine import FarmEngine, GameError
from farm.server.actions import ACTIONS, describe_actions, run_action
from farm.server.session import FarmSession, SessionManager, validate_farm_id
from farm.server.session import SessionManager, validate_farm_id
try:
import fastapi # noqa: F401
from fastapi.testclient import TestClient
HAS_FASTAPI = True
except ImportError: # pragma: no cover - 取决于环境
except ImportError: # pragma: no cover - 取决于环境
HAS_FASTAPI = False
try:
import uvicorn # noqa: F401
HAS_UVICORN = True
except ImportError: # pragma: no cover
except ImportError: # pragma: no cover
HAS_UVICORN = False
@@ -231,8 +231,7 @@ class TestRestAPI(unittest.TestCase):
from farm.server.app import ServerConfig, create_app
self.tmp = tempfile.mkdtemp(prefix="farm-api-")
self.cfg = ServerConfig(host="127.0.0.1", port=8765, save_dir=self.tmp,
tick_seconds=60)
self.cfg = ServerConfig(host="127.0.0.1", port=8765, save_dir=self.tmp, tick_seconds=60)
self.app = create_app(self.cfg)
self.client = TestClient(self.app)
self.client.__enter__()
@@ -277,15 +276,18 @@ class TestRestAPI(unittest.TestCase):
data = self.client.post("/api/actions?farm=one", json=body).json()
self.assertTrue(data["ok"])
self.assertIn("已种下", data["message"])
self.assertEqual(data["state"]["gold"], config.START_GOLD - config.CROPS["wheat"]["seed_cost"])
self.assertEqual(
data["state"]["gold"], config.START_GOLD - config.CROPS["wheat"]["seed_cost"]
)
state = self.client.get("/api/state?farm=one").json()["state"]
self.assertEqual(state["plots"][0]["crop"], "wheat")
def test_flat_params_also_accepted(self) -> None:
"""{"action":"plant","plot":1,"crop":"wheat"} 这种平铺写法也要能用。"""
data = self.client.post("/api/actions?farm=flat",
json={"action": "plant", "plot": 1, "crop": "wheat"}).json()
data = self.client.post(
"/api/actions?farm=flat", json={"action": "plant", "plot": 1, "crop": "wheat"}
).json()
self.assertTrue(data["ok"])
def test_convenience_route(self) -> None:
@@ -294,8 +296,9 @@ class TestRestAPI(unittest.TestCase):
self.assertEqual(data["action"], "plant")
def test_action_error_shape(self) -> None:
response = self.client.post("/api/actions?farm=err",
json={"action": "plant", "params": {"plot": 1, "crop": "nope"}})
response = self.client.post(
"/api/actions?farm=err", json={"action": "plant", "params": {"plot": 1, "crop": "nope"}}
)
self.assertEqual(response.status_code, 400)
data = response.json()
self.assertFalse(data["ok"])
@@ -303,37 +306,43 @@ class TestRestAPI(unittest.TestCase):
self.assertIn("message", data)
def test_out_of_range_plot(self) -> None:
response = self.client.post("/api/actions?farm=err",
json={"action": "water", "params": {"plot": 999}})
response = self.client.post(
"/api/actions?farm=err", json={"action": "water", "params": {"plot": 999}}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "no_such_plot")
def test_bad_json_body(self) -> None:
response = self.client.post("/api/actions?farm=err", content=b"{oops",
headers={"Content-Type": "application/json"})
response = self.client.post(
"/api/actions?farm=err", content=b"{oops", headers={"Content-Type": "application/json"}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "bad_json")
def test_actions_endpoint_matches_state(self) -> None:
data = self.client.get("/api/actions?farm=solo").json()
names = {item["action"] for item in data["actions"]}
self.assertIn("plant", names) # 有空地 + 有钱 → 可播种
self.assertNotIn("harvest", names) # 还没种东西 → 不可收获
self.assertIn("plant", names) # 有空地 + 有钱 → 可播种
self.assertNotIn("harvest", names) # 还没种东西 → 不可收获
def test_batch_endpoint(self) -> None:
items = {"actions": [
{"action": "buy_feed", "params": {"quantity": 5}},
{"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
{"action": "harvest", "params": {"plot": 1}}, # 必然失败
]}
items = {
"actions": [
{"action": "buy_feed", "params": {"quantity": 5}},
{"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
{"action": "harvest", "params": {"plot": 1}}, # 必然失败
]
}
data = self.client.post("/api/batch?farm=multi", json=items).json()
self.assertEqual(data["done"], 2)
self.assertEqual(data["failed"], 1)
self.assertEqual(data["results"][2]["error"], "not_ripe")
def test_log_entries_and_since(self) -> None:
self.client.post("/api/actions?farm=logging",
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}})
self.client.post(
"/api/actions?farm=logging",
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
)
entries = self.client.get("/api/log?farm=logging").json()["entries"]
self.assertTrue(entries)
last = entries[-1]["seq"]
@@ -344,14 +353,14 @@ class TestRestAPI(unittest.TestCase):
self.assertTrue(newer)
def test_farms_list_and_isolation(self) -> None:
self.client.post("/api/actions?farm=alice",
json={"action": "buy_feed", "params": {"quantity": 10}})
self.client.post(
"/api/actions?farm=alice", json={"action": "buy_feed", "params": {"quantity": 10}}
)
self.client.post("/api/actions?farm=bob", json={"action": "save"})
rows = self.client.get("/api/farms").json()["farms"]
by_name = {row["farm"]: row for row in rows}
self.assertIn("alice", by_name)
self.assertEqual(by_name["alice"]["gold"],
config.START_GOLD - 10 * config.FEED_UNIT_PRICE)
self.assertEqual(by_name["alice"]["gold"], config.START_GOLD - 10 * config.FEED_UNIT_PRICE)
def test_create_farm_reports_created_flag(self) -> None:
first = self.client.post("/api/farms", json={"farm": "zoe", "owner": "小Z"}).json()
@@ -370,8 +379,10 @@ class TestRestAPI(unittest.TestCase):
self.assertEqual(data["farm"], "header-farm")
def test_admin_save(self) -> None:
self.client.post("/api/actions?farm=saver",
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}})
self.client.post(
"/api/actions?farm=saver",
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
)
data = self.client.post("/api/admin/save").json()
self.assertIn("saver", data["farms"])
self.assertTrue(os.path.exists(os.path.join(self.tmp, "saver.json")))
@@ -419,8 +430,7 @@ class TestAuth(unittest.TestCase):
self.assertEqual(self.client.get("/api/state?token=nope").status_code, 401)
def test_action_blocked_without_token(self) -> None:
response = self.client.post("/api/actions",
json={"action": "buy_plot"})
response = self.client.post("/api/actions", json={"action": "buy_plot"})
self.assertEqual(response.status_code, 401)
@@ -436,14 +446,27 @@ class TestLiveServer(unittest.TestCase):
cls.tmp = tempfile.mkdtemp(prefix="farm-live-")
cls.port = free_port()
cls.url = f"http://127.0.0.1:{cls.port}"
app = create_app(ServerConfig(host="127.0.0.1", port=cls.port, save_dir=cls.tmp,
tick_seconds=600, autosave_interval=3600))
app = create_app(
ServerConfig(
host="127.0.0.1",
port=cls.port,
save_dir=cls.tmp,
tick_seconds=600,
autosave_interval=3600,
)
)
import uvicorn
cls.server = uvicorn.Server(uvicorn.Config(
app, host="127.0.0.1", port=cls.port, log_level="warning",
access_log=False, lifespan="on",
))
cls.server = uvicorn.Server(
uvicorn.Config(
app,
host="127.0.0.1",
port=cls.port,
log_level="warning",
access_log=False,
lifespan="on",
)
)
cls.thread = threading.Thread(target=cls.server.run, name="live-server", daemon=True)
cls.thread.start()
@@ -454,7 +477,7 @@ class TestLiveServer(unittest.TestCase):
break
except OSError:
time.sleep(0.2)
else: # pragma: no cover
else: # pragma: no cover
raise unittest.SkipTest("服务器没能在 20 秒内起来")
@classmethod
@@ -466,8 +489,9 @@ class TestLiveServer(unittest.TestCase):
def client(self, farm: str, realtime: bool = True):
from farm.client import RemoteFarm
return RemoteFarm(endpoint=self.url, farm=farm, realtime=realtime, timeout=8.0,
autostart=True)
return RemoteFarm(
endpoint=self.url, farm=farm, realtime=realtime, timeout=8.0, autostart=True
)
def test_http_roundtrip_through_real_client(self) -> None:
client = self.client("live-http", realtime=False)
@@ -480,18 +504,18 @@ class TestLiveServer(unittest.TestCase):
self.assertFalse(client.state.plots[0].is_ripe)
with self.assertRaises(GameError) as ctx:
client.plant(1, "carrot") # 地已经占用了
client.plant(1, "carrot") # 地已经占用了
self.assertEqual(ctx.exception.code, "plot_busy")
actions = client.available_actions()
names = {item["action"] for item in actions}
self.assertIn("plant", names) # 还有空地可以种
self.assertNotIn("harvest", names) # 小麦还没成熟
self.assertIn("plant", names) # 还有空地可以种
self.assertNotIn("harvest", names) # 小麦还没成熟
client.water(1) # 播种后水分 2/3,可以补满
client.water(1) # 播种后水分 2/3,可以补满
self.assertEqual(client.state.plots[0].moisture, config.WATER_CAPACITY)
with self.assertRaises(GameError) as ctx:
client.water(1) # 水分已满,再浇就是错的
client.water(1) # 水分已满,再浇就是错的
self.assertEqual(ctx.exception.code, "already_watered")
catalog = client.catalog()
@@ -515,8 +539,9 @@ class TestLiveServer(unittest.TestCase):
deadline = time.time() + 8
while time.time() < deadline and watcher.state.plots[0].crop != "carrot":
time.sleep(0.1)
self.assertEqual(watcher.state.plots[0].crop, "carrot",
"实时通道应该把另一方的操作推过来")
self.assertEqual(
watcher.state.plots[0].crop, "carrot", "实时通道应该把另一方的操作推过来"
)
self.assertEqual(watcher.mode, "ws")
finally:
watcher.stop()
@@ -554,7 +579,7 @@ class TestLiveServer(unittest.TestCase):
client.save()
path = os.path.join(self.tmp, "persist.json")
self.assertTrue(os.path.exists(path))
with open(path, "r", encoding="utf-8") as handle:
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
self.assertEqual(payload["state"]["plots"][0]["crop"], "wheat")
finally:
+13 -9
View File
@@ -26,8 +26,8 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from farm.engine import FarmEngine # noqa: E402
from farm.tui import TuiApp # noqa: E402
from farm.engine import FarmEngine
from farm.tui import TuiApp
SCENES = ("empty", "mixed", "rich", "menu", "help")
@@ -37,8 +37,9 @@ def build_remote(url: str, farm: str, token: str | None) -> tuple[object, TuiApp
from farm.client import RemoteFarm
client = RemoteFarm(endpoint=url, farm=farm, token=token, realtime=False)
app = TuiApp(client, use_color=True, autosave=False,
server_label=f"{client.farm} @ {client.host}")
app = TuiApp(
client, use_color=True, autosave=False, server_label=f"{client.farm} @ {client.host}"
)
return client, app
@@ -68,7 +69,7 @@ def build_engine(scene: str) -> tuple[FarmEngine, TuiApp]:
engine.state.level = 4
engine.state.gold = 2400
engine.state.feed = 22
for i in range(2):
for _ in range(2):
engine.state.gold = 99999
engine.buy_plot()
engine.plant(5, "corn")
@@ -100,8 +101,9 @@ def main() -> int:
parser.add_argument("--height", type=int, default=32, help="终端高度")
parser.add_argument("--scene", choices=SCENES, default="mixed", help="场景")
parser.add_argument("--color", action="store_true", help="保留 ANSI 颜色")
parser.add_argument("--url", default=None,
help="连到真实服务器渲染(例如 http://127.0.0.1:8765)")
parser.add_argument(
"--url", default=None, help="连到真实服务器渲染(例如 http://127.0.0.1:8765)"
)
parser.add_argument("--farm", default="default", help="--url 模式下要看的牧场")
parser.add_argument("--token", default=None, help="--url 模式下的访问令牌")
args = parser.parse_args()
@@ -112,8 +114,10 @@ def main() -> int:
if args.url:
engine, app = build_remote(args.url, args.farm, args.token)
state = engine.snapshot()
print(f"# 来自真实服务器 {args.url} · 牧场 {args.farm} · 第 {state.get('day')} 天 · "
f"金币 {state.get('gold')} · 在线 {state.get('online')}\n")
print(
f"# 来自真实服务器 {args.url} · 牧场 {args.farm} · 第 {state.get('day')} 天 · "
f"金币 {state.get('gold')} · 在线 {state.get('online')}\n"
)
else:
engine, app = build_engine(args.scene)
# 预览脚本没有跑 Scheduler,这里手动给个倒计时,让画面更接近真实运行状态
+13 -7
View File
@@ -38,8 +38,10 @@ async def run(endpoint: str, farm: str, token: str | None, seconds: float) -> in
async with websockets.connect(url, open_timeout=8) as socket:
welcome = json.loads(await socket.recv())
state = welcome.get("state", {})
print(f"← welcome:牧场 {welcome['farm']} · 第 {state.get('day')} 天 · "
f"金币 {state.get('gold')} · 候选动作 {len(welcome.get('actions', []))} 个")
print(
f"← welcome:牧场 {welcome['farm']} · 第 {state.get('day')} 天 · "
f"金币 {state.get('gold')} · 候选动作 {len(welcome.get('actions', []))} 个"
)
async def sender() -> None:
"""通过 WebSocket 发一个动作,等回执。"""
@@ -61,13 +63,17 @@ async def run(endpoint: str, farm: str, token: str | None, seconds: float) -> in
kind = message.get("type", "?")
counts[kind] = counts.get(kind, 0) + 1
if kind == "result":
print(f"← result(id={message.get('id')}):{message.get('message')} "
f"[通过 WebSocket 提交的动作,ok={message.get('ok')}]")
print(
f"← result(id={message.get('id')}):{message.get('message')} "
f"[通过 WebSocket 提交的动作,ok={message.get('ok')}]"
)
elif kind == "state":
snapshot = message.get("state", {})
print(f"← state(seq={message.get('seq')}):第 {snapshot.get('day')} 天 · "
f"金币 {snapshot.get('gold')} · 行动点 {snapshot.get('ap')} · "
f"在线 {message.get('online')}")
print(
f"← state(seq={message.get('seq')}):第 {snapshot.get('day')} 天 · "
f"金币 {snapshot.get('gold')} · 行动点 {snapshot.get('ap')} · "
f"在线 {message.get('online')}"
)
elif kind == "day":
print(f"← day:进入第 {message.get('day')} 天,事件 {message.get('events')}")
elif kind == "notice":