chore: 工程化改造前的基线快照

在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前,
先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。

此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。
This commit is contained in:
snowy-yang committed 2026-09-18 21:12:40 +08:00
commit a7725f8812
35 files changed
+11871

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
"""SimpleFarm —— 一个可以被人和 AI 同时经营的小型农场游戏服务器。
分层
----
* :mod:`farm.engine` / :mod:`farm.models` / :mod:`farm.config`
纯游戏规则与数据,不依赖终端也不依赖网络。
* :mod:`farm.server`
服务器侧:FastAPI 应用、动作表、多牧场会话、WebSocket 推送、守护进程。
* :mod:`farm.client`
客户端侧:把远程服务器伪装成本地引擎,让 TUI 和脚本无需关心网络。
* :mod:`farm.tui`
终端界面,人和服务器之间的"手柄"。
"""
__version__ = "2.0.0"
__all__ = [
"config", "models", "engine", "storage", "keys", "tui",
"client", "bot", "server",
]
+221
View File
@@ -0,0 +1,221 @@
"""规则机器人玩家——"AI 也能玩"的最小可用示例。
它的意义不在于"内置了 AI",而在于证明一件事:
**服务器把状态和合法动作都摊开了,外面是谁在决策根本不重要。**
这个机器人做决策的方式是"给候选动作打分",而一个 LLM 做决策的方式是
"读同一份 ``/api/actions``,用语言模型选一个"。两者的接口调用序列一模一样。
所以:
* :meth:`RuleBot.choose` 里那段打分逻辑 = 可以整体替换成 LLM 的"策略函数";
* :meth:`RuleBot.step` 的执行路径 = LLM 将来要走的那条路(读动作 → 执行 → 看回执)。
它只依赖 :mod:`farm.client`,因此既能在本机跑,也能从别处连上服务器跑——
换个 ``--url`` 就是一个独立的外挂玩家。
"""
from __future__ import annotations
import random
import threading
import time
from typing import Any, Dict, List, Optional
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 接口一致的对象
self.verbose = verbose
self.pause = pause
self.name = name
self.done: List[str] = []
self.failures = 0
self.actions_executed = 0
self._stop = threading.Event()
# ------------------------------------------------------------ 输出
def say(self, text: str) -> None:
if self.verbose:
print(f"[{time.strftime('%H:%M:%S')}] {text}", flush=True)
def stop(self) -> None:
self._stop.set()
@property
def stopped(self) -> bool:
return self._stop.is_set()
# ------------------------------------------------------------ 打分
def score(self, action: Dict[str, Any], state: Dict[str, Any],
catalog: Dict[str, Any]) -> float:
"""给一个候选动作打分。分数越高越先执行。
这张表就是整个"策略":先做免费的,再喂饱牲畜,再补水,再扩张。
换成 LLM 时,把这里换成一次模型调用即可,其余代码不用动。
"""
name = action.get("action")
params = action.get("params") or {}
gold = state.get("gold", 0)
feed = state.get("feed", 0)
ap = state.get("ap", 0)
if name == "harvest":
return 100.0
if name == "collect":
return 96.0
if name == "feed":
return 92.0
if name == "water":
return 84.0
if name == "plant":
# 优先种"每天净收益"最高的作物
crop = params.get("crop")
entry = next((c for c in catalog.get("crops", []) if c["key"] == crop), None)
per_day = entry["profit_per_day"] if entry else 0.0
return 60.0 + max(0.0, per_day)
if name == "buy_feed":
# 饲料快见底才补,且别把钱花光
if feed <= 2 and gold >= 200:
return 40.0
return 5.0
if name == "buy_plot":
price = state.get("next_plot_price") or 0
# 地太贵就别买,留下周转资金
if price > 0 and gold >= price * 3:
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)
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]]:
"""在服务器给出的合法动作里挑一个最优的。"""
actions = self.client.available_actions()
if not actions:
return None
state = self.client.snapshot()
catalog = self.client.catalog()
best = max(actions, key=lambda item: self.score(item, state, catalog))
if self.score(best, state, catalog) <= 1.0:
return None
return best
# ------------------------------------------------------------ 单步
def step(self) -> Optional[str]:
"""执行一个动作,返回动作名;没得做或做不了返回 None。"""
action = self.choose()
if action is None:
return None
name = action["action"]
params = action.get("params") or {}
try:
result = self.client._action(name, params) # 走通用动作入口
except GameError as exc:
self.failures += 1
if exc.code == "cooldown":
# 休息有冷却;这是正常节奏,不该记成失败
self.failures -= 1
self.say(f"冷却中… {exc.message}")
time.sleep(1.0)
return None
self.say(f"✗ {name} 失败({exc.code}):{exc.message}")
return None
self.actions_executed += 1
self.done.append(name)
self.say(f"✓ {name} {params} → {result.get('message', '')}")
if self.pause:
time.sleep(self.pause)
return name
def rest(self) -> bool:
"""行动点用完时推进一天。"""
try:
result = self.client.sleep()
except GameError as exc:
if exc.code == "cooldown":
time.sleep(1.0)
return True
self.say(f"✗ sleep 失败:{exc.message}")
return False
self.say(f"— {result.get('message', '进入下一天')}")
return True
# ------------------------------------------------------------ 主循环
def run(self, rounds: int = 0, max_minutes: float = 0.0,
idle_limit: int = 8) -> Dict[str, Any]:
"""跑机器人。
``rounds`` 为 0 表示不限轮数;``max_minutes`` 为 0 表示不限时间。
"""
deadline = (time.time() + max_minutes * 60) if max_minutes > 0 else None
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()})")
while not self._stop.is_set():
round_no += 1
if rounds and round_no > rounds:
break
if deadline and time.time() > deadline:
self.say("到时间了,收工。")
break
state = self.client.snapshot()
if state.get("ap", 0) <= 0:
# 行动点用完:休息推进一天,顺便等冷却
self.rest()
continue
did = self.step()
if did is None:
idle += 1
if idle >= idle_limit:
self.say("暂时没有可做的事,休息一天再看。")
self.rest()
idle = 0
else:
time.sleep(0.4)
else:
idle = 0
end_day = self.client.snapshot().get("day", start_day)
summary = {
"name": self.name,
"days": end_day - start_day,
"actions": self.actions_executed,
"failures": self.failures,
"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']}")
return summary
def random_bot(client, **kwargs) -> RuleBot:
"""随机策略机器人:用来做对照实验(证明"有策略"确实比乱按强)。"""
bot = RuleBot(client, **kwargs)
original = bot.choose
def choose() -> Optional[Dict[str, Any]]:
actions = client.available_actions()
if not actions:
return None
return random.choice(actions)
bot.choose = choose # type: ignore[assignment]
bot.name = kwargs.get("name", "random")
del original
return bot
+721
View File
@@ -0,0 +1,721 @@
"""远程农场客户端:让本地代码(TUI、脚本、机器人)像用本地引擎一样用服务器。
设计目标
--------
TUI 原本直接拿着 ``FarmEngine`` 对象,现在游戏跑在服务器进程里,客户端必须
换成"远程把手"。为了不把上千行渲染代码推倒重写,这里做了一个**接口同构**的
:class:`RemoteFarm`:
本地引擎 远程客户端
---------- ----------
engine.state.plots[0].is_ripe state.plots[0].is_ripe
engine.harvest(1) harvest(1)
engine.catalog() catalog()
engine.recent_log(5) recent_log(5)
字段名和方法名都对齐,所以 :mod:`farm.tui` 不需要知道自己在跟谁说话。
双通道
------
* **写操作走 HTTP**(POST /api/actions):语义清晰、可重试、失败有明确错误码。
* **读数走 WebSocket**:服务器在状态变化时主动推过来,界面不用轮询。
* WS 不可用时自动降级成"HTTP 轮询 + 写操作",功能不缺,只是延迟高一点。
请求库用的是标准库 ``urllib``,所以即使机器上没装任何第三方包,TUI 也能跑。
"""
from __future__ import annotations
import asyncio
import json
import threading
import time
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 . import config
from .engine import GameError
from . import net
DEFAULT_ENDPOINT = "http://127.0.0.1:8765"
LOG_KEEP = 200
WS_RECONNECT_DELAY = 3.0
# --------------------------------------------------------------------- 状态视图
class PlotView:
"""一块地的只读视图(字段名与 :class:`farm.models.Plot` 对齐)。"""
__slots__ = ("_d",)
def __init__(self, data: Dict[str, Any]) -> None:
self._d = data
@property
def index(self) -> int:
return int(self._d.get("index", 0))
@property
def position(self) -> int:
return int(self._d.get("position", self.index + 1))
@property
def crop(self) -> Optional[str]:
return self._d.get("crop")
@property
def crop_name(self) -> str:
return self._d.get("crop_name") or ""
@property
def growth(self) -> float:
return float(self._d.get("growth", 0.0))
@property
def grow_days(self) -> int:
return int(self._d.get("grow_days", 0))
@property
def days_left(self) -> int:
return int(self._d.get("days_left", 0))
@property
def moisture(self) -> int:
return int(self._d.get("moisture", 0))
@property
def is_ripe(self) -> bool:
return bool(self._d.get("is_ripe"))
@property
def is_empty(self) -> bool:
return bool(self._d.get("is_empty", self.crop is None))
@property
def stage(self) -> str:
return self._d.get("stage", "")
def to_dict(self) -> Dict[str, Any]:
return dict(self._d)
class AnimalView:
"""一只牲畜的只读视图(``aid`` 对齐本地模型的 ``.aid``)。"""
__slots__ = ("_d",)
def __init__(self, data: Dict[str, Any]) -> None:
self._d = data
@property
def aid(self) -> int:
return int(self._d.get("id", 0))
@property
def kind(self) -> str:
return self._d.get("kind", "")
@property
def name(self) -> str:
return self._d.get("name", "")
@property
def product(self) -> str:
return self._d.get("product", "")
@property
def fed(self) -> bool:
return bool(self._d.get("fed"))
@property
def pending(self) -> int:
return int(self._d.get("pending", 0))
@property
def cycle(self) -> int:
return int(self._d.get("cycle", 1))
@property
def progress(self) -> int:
return int(self._d.get("progress", 0))
@property
def starve(self) -> int:
return int(self._d.get("starve", 0))
@property
def days_to_product(self) -> int:
return int(self._d.get("days_to_product", 0))
@property
def is_sick(self) -> bool:
return bool(self._d.get("is_sick"))
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] = {}
self.apply(data or {})
def apply(self, data: Dict[str, Any]) -> None:
"""用新数据刷新内部视图。
服务器会发两种东西:**完整快照**(带 plots/animals)和**精简状态**
(动作回执里的 state,只有几个标量)。前者整体替换,后者只做字段合并——
否则一次播种就会把 TUI 的农田格子清空。
"""
if not data:
return
if "plots" in data:
merged = dict(data)
else:
merged = dict(self._d)
merged.update(data)
self._d = merged
self.plots = [PlotView(p) for p in self._d.get("plots", []) if isinstance(p, dict)]
self.animals = [AnimalView(a) for a in self._d.get("animals", []) if isinstance(a, dict)]
# 常用标量字段
@property
def day(self) -> int:
return int(self._d.get("day", 1))
@property
def gold(self) -> int:
return int(self._d.get("gold", 0))
@property
def level(self) -> int:
return int(self._d.get("level", 1))
@property
def exp(self) -> int:
return int(self._d.get("exp", 0))
@property
def ap(self) -> int:
return int(self._d.get("ap", 0))
@property
def feed(self) -> int:
return int(self._d.get("feed", 0))
@property
def market(self) -> Dict[str, float]:
return self._d.get("market", {})
def get(self, key: str, default: Any = None) -> Any:
return self._d.get(key, default)
def __getitem__(self, key: str) -> Any:
return self._d[key]
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:
self.endpoint = self._normalize(endpoint)
self.farm = farm or "default"
self.token = token or ""
self.name = name
self.timeout = timeout
self.realtime_enabled = realtime
self.state = StateView()
self.connected = False
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._lock = threading.RLock()
self._pending: Dict[int, Future] = {}
self._req_id = 0
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = 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._log_seq = 0
self.stats = {"http": 0, "ws_actions": 0, "pushes": 0, "errors": 0}
if autostart:
self.refresh(raise_on_error=True)
if realtime:
self.start()
# ------------------------------------------------------------ 基础设施
@staticmethod
def _normalize(endpoint: str) -> str:
endpoint = (endpoint or DEFAULT_ENDPOINT).strip().rstrip("/")
if endpoint.startswith("tcp://"):
endpoint = "http://" + endpoint[len("tcp://"):]
if not endpoint.startswith(("http://", "https://")):
endpoint = "http://" + endpoint
return endpoint
@property
def host(self) -> str:
return urllib.parse.urlsplit(self.endpoint).netloc or "127.0.0.1:8765"
def url(self, path: str, **params: Any) -> str:
query = {"farm": self.farm}
if self.token:
query["token"] = self.token
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]:
url = self.url(path, **params)
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
self.stats["http"] += 1
try:
# 走 net.open_url:本地服务器不会被系统代理截走
with net.open_url(request, timeout=self.timeout) as response:
body = response.read().decode("utf-8", errors="replace")
status = response.status
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
status = exc.code
except (urllib.error.URLError, OSError, ValueError) as exc:
self.stats["errors"] += 1
self.connected = False
raise GameError(
"network",
f"连不上服务器 {self.endpoint}({exc})。"
"请先启动:python main.py serve --daemon",
status=503,
)
try:
data_out = json.loads(body) if body else {}
except json.JSONDecodeError:
raise GameError("bad_response", f"服务器返回了非 JSON 内容(HTTP {status})", 502)
if not isinstance(data_out, dict):
raise GameError("bad_response", "服务器返回的 JSON 不是对象", 502)
if not data_out.get("ok", True):
self.stats["errors"] += 1
raise GameError(
data_out.get("error", "server_error"),
data_out.get("message", "服务器拒绝了这次操作"),
status=status if status >= 400 else 400,
)
self.connected = True
self.last_error = ""
return data_out
# ------------------------------------------------------------ 读接口
def refresh(self, raise_on_error: bool = False) -> bool:
"""主动拉一次全量数据(首次连接、以及 WS 断开后的兜底)。"""
try:
data = self._http("GET", "/api/state")
self._apply_online(data.get("state") or {}, data.get("online"))
catalog = self._http("GET", "/api/catalog")
self._catalog = catalog.get("catalog") or self._catalog
logs = self._http("GET", "/api/log", limit=30)
self._merge_log(logs.get("entries") or [])
return True
except GameError as exc:
self.connected = False
self.last_error = exc.message
if raise_on_error:
raise
return False
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]:
return self.state.to_dict()
def catalog(self) -> Dict[str, Any]:
return self._catalog
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]]:
data = self._http("GET", "/api/actions")
self._apply(data.get("state") or {})
return data.get("actions") or []
def leaderboard(self) -> List[Dict[str, Any]]:
return self._http("GET", "/api/farms").get("farms") or []
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:
if not state:
return
self.state.apply(state)
if self.on_change is not None:
try:
self.on_change()
except Exception:
pass
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:
seq = int(entry.get("seq", 0))
if seq and seq not in known:
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)
# ------------------------------------------------------------ 写接口
def _action(self, action: str, params: Optional[Dict[str, Any]] = None,
transport: str = "auto") -> Dict[str, Any]:
"""执行一个动作。
优先走 WebSocket(延迟更低、顺便复用已有连接);只有**通道类**错误
(连接断了、超时)才退回 HTTP 重试——业务错误必须原样抛给玩家,
否则"金币不足"会被悄悄重试成两次操作。
"""
params = params or {}
use_ws = (transport == "ws") or (transport == "auto" and self.mode == "ws"
and self.connected)
if use_ws:
try:
result = self._ws_action(action, params)
except GameError as exc:
if exc.code in ("ws_closed", "timeout", "network"):
self.last_error = f"WebSocket 不可用({exc.message}),已改走 HTTP"
else:
raise
else:
self.stats["ws_actions"] += 1
if isinstance(result.get("snapshot"), dict):
self._apply(result["snapshot"])
elif isinstance(result.get("state"), dict):
self._apply(result["state"])
self._merge_log(result.get("log") or [])
return result
# 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]:
return self._action("plant", {"plot": plot, "crop": crop})
def water(self, plot: Any) -> Dict[str, Any]:
return self._action("water", {"plot": plot})
def harvest(self, plot: Any) -> Dict[str, Any]:
return self._action("harvest", {"plot": plot})
def harvest_all(self) -> Dict[str, Any]:
return self._action("harvest_all")
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}
if quantity is not None:
params["quantity"] = quantity
return self._action("feed", params)
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]:
return self._action("buy_feed", {"quantity": quantity})
def buy_plot(self) -> Dict[str, Any]:
return self._action("buy_plot")
def sleep(self) -> Dict[str, Any]:
return self._action("sleep")
def pause(self, paused: Optional[bool] = None) -> Dict[str, Any]:
params: Dict[str, Any] = {}
if paused is not None:
params["paused"] = paused
data = self._action("pause", params)
self._apply(data.get("state") or {})
# 本地视图也要反映暂停态,TUI 的倒计时依赖它
if "paused" in data:
merged = self.state.to_dict()
merged["paused"] = data["paused"]
merged["seconds_to_tick"] = None if data["paused"] else merged.get("seconds_to_tick")
self.state.apply(merged)
return data
def reset(self) -> Dict[str, Any]:
data = self._action("reset")
with self._lock:
self._log.clear()
self._log_seq = 0
self.refresh()
return data
def save(self, path: Optional[str] = None) -> Dict[str, Any]:
"""远程存档:位置由服务器决定,``path`` 参数仅为与本地引擎保持同签名。"""
return self._action("save")
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 {})
self._merge_log(data.get("log") or [])
return data
# ------------------------------------------------------------ WebSocket
def start(self) -> None:
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.start()
def stop(self) -> None:
self._stop.set()
with self._lock:
for future in self._pending.values():
if not future.done():
future.set_exception(GameError("closed", "连接已关闭"))
self._pending.clear()
def transport_label(self) -> str:
if self.mode == "ws":
return "WebSocket 实时"
if self.mode == "poll":
return "HTTP 轮询"
return "HTTP"
@property
def ws_url(self) -> str:
parts = urllib.parse.urlsplit(self.endpoint)
scheme = "wss" if parts.scheme == "https" else "ws"
query = {"farm": self.farm}
if self.token:
query["token"] = self.token
if self.name:
query["name"] = self.name
return f"{scheme}://{parts.netloc}/ws?{urllib.parse.urlencode(query)}"
def _realtime_loop(self) -> None:
"""实时线程:先试 WebSocket,不行就退化成 HTTP 轮询,永不放弃重连。"""
while not self._stop.is_set():
try:
self._ws_session()
except Exception as exc:
self.connected = False
self.last_error = f"{type(exc).__name__}: {exc}"
if self.mode != "ws":
self.mode = "poll"
self._fail_pending(self.last_error)
if self._stop.is_set():
break
self._poll_until_reconnect()
def _poll_until_reconnect(self) -> None:
"""WS 断开期间的降级通道:轻量轮询 + 到点再试一次 WS。"""
if self.mode == "ws":
self.mode = "poll"
deadline = time.time() + WS_RECONNECT_DELAY
while not self._stop.is_set() and time.time() < deadline:
try:
data = self._http("GET", "/api/state")
self._apply(data.get("state") or {})
except GameError:
pass
self._stop.wait(0.5)
def _ws_session(self) -> None:
import asyncio
try:
import websockets
except ImportError:
# 没装 websockets:直接走轮询模式,功能不减
self.mode = "poll"
self._stop.wait(WS_RECONNECT_DELAY)
return
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:
self.mode = "ws"
self.connected = True
self.last_error = ""
self._ws_socket = socket
self._ws_loop = asyncio.get_running_loop()
try:
while not self._stop.is_set():
try:
raw = await asyncio.wait_for(socket.recv(), timeout=30.0)
except asyncio.TimeoutError:
await socket.send(json.dumps({"type": "ping"}))
continue
self._handle_ws_message(raw)
finally:
self._ws_socket = None
self._ws_loop = None
self._fail_pending("WebSocket 连接已断开")
def _fail_pending(self, reason: str) -> None:
"""连接断了以后,把还在等回执的动作请求全部失败掉。"""
with self._lock:
pending, self._pending = self._pending, {}
for future in pending.values():
if not future.done():
future.set_exception(GameError("ws_closed", reason))
def _handle_ws_message(self, raw: Any) -> None:
try:
message = json.loads(raw)
except (TypeError, json.JSONDecodeError):
return
if not isinstance(message, dict):
return
kind = message.get("type")
if kind == "welcome":
self.server_info = message.get("server") or {}
self.farm = message.get("farm", self.farm)
if message.get("catalog"):
self._catalog = message["catalog"]
self._apply_online(message.get("state") or {}, message.get("online"))
self._merge_log(message.get("log") or [])
self.stats["pushes"] += 1
return
if kind in ("state", "sync"):
if message.get("catalog"):
self._catalog = message["catalog"]
self._apply_online(message.get("state") or {}, message.get("online"))
self._merge_log(message.get("log") or [])
self.stats["pushes"] += 1
return
if kind == "result":
rid = message.get("id")
with self._lock:
future = self._pending.pop(rid, None) if rid is not None else None
if future is None or future.done():
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"),
})
else:
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 [])])
return
if kind == "error":
self.last_error = message.get("message", "")
def _ws_send(self, payload: Dict[str, Any]) -> None:
"""把消息发到 WS 线程的事件循环里(跨线程投递)。"""
socket = getattr(self, "_ws_socket", None)
loop = getattr(self, "_ws_loop", None)
if socket is None or loop is None:
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]:
with self._lock:
self._req_id += 1
rid = self._req_id
future: Future = Future()
self._pending[rid] = future
try:
self._ws_send({"type": "action", "id": rid, "action": action, "params": params})
except GameError:
with self._lock:
self._pending.pop(rid, None)
raise
try:
return future.result(timeout=timeout or self.timeout)
except FutureTimeout:
with self._lock:
self._pending.pop(rid, None)
raise GameError("timeout", "服务器没有在超时时间内回应")
class RemoteBotClient(RemoteFarm):
"""给机器人用的薄封装:多一个"等服务器起来"的便利方法。"""
def wait_online(self, seconds: float = 30.0) -> bool:
deadline = time.time() + seconds
while time.time() < deadline:
if self.refresh():
return True
time.sleep(1.0)
return False
+172
View File
@@ -0,0 +1,172 @@
"""游戏平衡参数与静态定义。
本模块只描述"世界是什么样的"(作物表、动物表、经济参数),
不包含任何状态或逻辑,方便单独调参。
"""
from __future__ import annotations
from typing import Any, Dict
# --------------------------------------------------------------------------
# 基础经济参数
# --------------------------------------------------------------------------
START_GOLD = 120 # 初始金币
START_LEVEL = 1 # 初始等级
START_AP = 10 # 每天行动点上限
START_FEED = 5 # 初始饲料库存
BASE_PLOTS = 6 # 初始土地数量
MAX_PLOTS = 16 # 土地数量硬上限
PLOT_BASE_PRICE = 200 # 第一块新地的价格
PLOT_PRICE_GROWTH = 1.75 # 每多买一块,价格乘以该系数
FEED_UNIT_PRICE = 6 # 主动购买饲料的单价
FEED_EMERGENCY_PRICE = 9 # 自动补货(缺饲料时喂食)的惩罚单价
FEED_BULK = 10 # 一次购买饲料的默认批量
WATER_CAPACITY = 3 # 一次浇水可支撑的天数
PLANT_INITIAL_MOISTURE = 2 # 播种后地块自带的水分
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
# 行动点消耗表
AP_COST = {
"plant": 1,
"water": 1,
"harvest": 0,
"feed": 1,
"collect": 0,
"buy_animal": 0,
"buy_plot": 0,
"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 时发放的金币奖励。"""
return 80 + 40 * level
# --------------------------------------------------------------------------
# 作物表
# --------------------------------------------------------------------------
# key : 内部标识(API 与存档使用)
# name : 中文名
# seed_cost : 种子价格
# grow_days : 成熟所需生长天数(需要水分支撑)
# base_price : 收获物的基础售价
# exp : 每次收获获得的经验
# level_req : 解锁所需等级
CROPS: Dict[str, Dict[str, Any]] = {
"wheat": {
"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,
},
"tomato": {
"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,
},
"pumpkin": {
"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,
},
}
# 生长阶段阈值(按生长进度 0.0~1.0 划分)
GROWTH_STAGES = [
(0.00, "已播种"),
(0.30, "幼苗"),
(0.65, "生长中"),
(1.00, "已成熟"),
]
# --------------------------------------------------------------------------
# 动物表
# --------------------------------------------------------------------------
# key : 内部标识
# name : 中文名
# cost : 购买价格
# product : 产出物标识(中文名)
# base_price : 产出物基础售价
# cycle : 产出周期(天)
# feed_per_day: 每天消耗饲料数
# exp : 每次收集产出获得的经验
# level_req : 解锁所需等级
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,
},
"sheep": {
"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,
},
}
MAX_ANIMALS = 8 # 牧场总容量
# --------------------------------------------------------------------------
# 时间
# --------------------------------------------------------------------------
DEFAULT_TICK_SECONDS = 15.0 # 自动推进一天所需的真实秒数
MIN_TICK_SECONDS = 3.0
MAX_TICK_SECONDS = 600.0
SLEEP_COOLDOWN = 2.0 # 手动"休息"推进一天的冷却(秒)
# --------------------------------------------------------------------------
# 进度目标
# --------------------------------------------------------------------------
GOALS = [
(200, "起步:攒够 200 金币"),
(600, "小农:攒够 600 金币"),
(1500, "农场主:攒够 1500 金币"),
(4000, "庄园主:攒够 4000 金币"),
(10000, "农业大亨:攒够 10000 金币"),
]
def goal_index(net_worth: float) -> int:
"""返回已达成的最低目标索引(-1 表示一个都没达成)。"""
reached = -1
for i, (amount, _) in enumerate(GOALS):
if net_worth >= amount:
reached = i
return reached
+743
View File
@@ -0,0 +1,743 @@
"""核心游戏引擎。
这一层是纯粹的"游戏规则",不依赖终端、不依赖网络。
TUI 与 HTTP/gRPC 接口都调用同一组方法,因此三方共享完全一致的裁判逻辑。
线程模型
--------
所有对状态的读写都在 ``self._lock`` 保护下进行,TUI 主线程、
自动推进线程、HTTP 工作线程可以安全并发调用。
错误约定
--------
业务规则不满足时抛出 :class:`GameError`(带机器可读的 ``code``),
调用方据此返回 4xx JSON 或界面提示。
"""
from __future__ import annotations
import random
import threading
import time
from typing import Any, Callable, Dict, List, Optional
from . import config
from .models import Animal, GameState, Plot, clamp
class GameError(Exception):
"""业务规则错误。``code`` 供 API 调用方程序化判断。"""
def __init__(self, code: str, message: str, status: int = 400) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status = status
def to_dict(self) -> Dict[str, Any]:
return {"ok": False, "error": self.code, "message": self.message}
class FarmEngine:
"""农场游戏引擎。"""
def __init__(self, tick_seconds: float = config.DEFAULT_TICK_SECONDS) -> None:
self._lock = threading.RLock()
self.state = GameState()
self.tick_seconds = clamp(
float(tick_seconds), config.MIN_TICK_SECONDS, config.MAX_TICK_SECONDS
)
self._log_seq = 0
self._last_sleep = 0.0
self.paused = False
# 由 Scheduler 维护:下一次自动推进的绝对时间戳(无调度器时为 None)
self.next_tick_at: Optional[float] = None
# 状态变化回调(TUI 用它触发重绘)
self._listeners: List[Callable[[], None]] = []
# ================================================================ 订阅
def subscribe(self, callback: Callable[[], None]) -> None:
with self._lock:
self._listeners.append(callback)
def _notify(self) -> None:
for callback in list(self._listeners):
try:
callback()
except Exception:
# 监听器异常不能影响游戏主流程
pass
# ================================================================ 日志
def add_log(self, text: str, kind: str = "info") -> None:
with self._lock:
self._log_seq += 1
self.state.log.append(
{
"seq": self._log_seq,
"day": self.state.day,
"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]]:
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]:
"""返回完整状态快照(供 TUI 渲染与 API 响应)。"""
with self._lock:
data = self.state.to_dict()
data["tick_seconds"] = self.tick_seconds
data["paused"] = self.paused
data["seconds_to_tick"] = self.seconds_to_tick
data["last_event"] = self.state.log[-1] if self.state.log else None
return data
@property
def seconds_to_tick(self) -> Optional[float]:
if self.paused or self.next_tick_at is None:
return None
return round(max(0.0, self.next_tick_at - time.time()), 1)
def market_price(self, key: str) -> float:
with self._lock:
return round(self.state.market.get(key, 0.0), 2)
def catalog(self) -> Dict[str, Any]:
"""返回所有可种植/可养殖对象及其在市场上的实时价格。"""
with self._lock:
crops = []
for key, info in config.CROPS.items():
price = self.market_price(key)
gross = price
days = int(info["grow_days"])
crops.append(
{
"key": key,
"name": info["name"],
"seed_cost": info["seed_cost"],
"grow_days": days,
"price": price,
"exp": info["exp"],
"level_req": info["level_req"],
"locked": self.state.level < info["level_req"],
"profit": round(gross - info["seed_cost"], 2),
"profit_per_day": round((gross - info["seed_cost"]) / days, 2),
}
)
animals = []
for key, info in config.ANIMALS.items():
price = self.market_price(f"animal:{key}")
feed_cost = info["feed_per_day"] * config.FEED_UNIT_PRICE * info["cycle"]
animals.append(
{
"key": key,
"name": info["name"],
"cost": info["cost"],
"product": info["product"],
"price": price,
"cycle": info["cycle"],
"feed_per_day": info["feed_per_day"],
"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
),
}
)
return {
"crops": crops,
"animals": animals,
"feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY,
"ap_cost": config.AP_COST,
}
def available_actions(self) -> List[Dict[str, Any]]:
"""列出当前**合法**的动作,供 AI 直接决策使用。
每个动作包含:``action``(接口名)、``params``(参数模板)、
``cost``(金币/行动点开销)、``reason``(为什么值得做)。
"""
with self._lock:
actions: List[Dict[str, Any]] = []
state = self.state
# ---- 收获 / 浇水 / 播种
for plot in state.plots:
if plot.crop is None:
for key, info in config.CROPS.items():
if state.level < info["level_req"]:
continue
if state.gold < info["seed_cost"] or state.ap < config.AP_COST["plant"]:
continue
actions.append(
{
"action": "plant",
"params": {"plot": plot.index + 1, "crop": key},
"cost": {"gold": info["seed_cost"], "ap": config.AP_COST["plant"]},
"reason": f"{plot.index + 1} 号地空闲,可种{info['name']}",
}
)
elif plot.is_ripe:
actions.append(
{
"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']}已成熟",
}
)
elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]:
actions.append(
{
"action": "water",
"params": {"plot": plot.index + 1},
"cost": {"gold": 0, "ap": config.AP_COST["water"]},
"reason": f"{plot.index + 1} 号地缺水,作物已停止生长",
}
)
# ---- 动物
for animal in state.animals:
if animal.pending > 0:
actions.append(
{
"action": "collect",
"params": {"animal_id": animal.aid},
"cost": {"gold": 0, "ap": config.AP_COST["collect"]},
"reason": f"{animal.name}有 {animal.pending} 份{animal.product}待收取",
}
)
elif not animal.fed:
need = animal.info["feed_per_day"]
cost = 0 if state.feed >= need else need * config.FEED_EMERGENCY_PRICE
if state.gold >= cost and state.ap >= config.AP_COST["feed"]:
actions.append(
{
"action": "feed",
"params": {"animal_id": animal.aid},
"cost": {"gold": cost, "ap": config.AP_COST["feed"]},
"reason": f"{animal.name}(#{animal.aid})今天还没喂食",
}
)
# ---- 投资类
price = state.next_plot_price
if price > 0 and state.gold >= price:
actions.append(
{
"action": "buy_plot",
"params": {},
"cost": {"gold": price, "ap": config.AP_COST["buy_plot"]},
"reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地",
}
)
if state.feed < 5 and state.gold >= config.FEED_UNIT_PRICE * config.FEED_BULK:
actions.append(
{
"action": "buy_feed",
"params": {"quantity": config.FEED_BULK},
"cost": {
"gold": config.FEED_UNIT_PRICE * config.FEED_BULK,
"ap": config.AP_COST["buy_feed"],
},
"reason": "饲料库存偏低,建议补货",
}
)
for key, info in config.ANIMALS.items():
if state.level < info["level_req"]:
continue
if len(state.animals) >= config.MAX_ANIMALS or state.gold < info["cost"]:
continue
actions.append(
{
"action": "buy_animal",
"params": {"kind": key},
"cost": {"gold": info["cost"], "ap": config.AP_COST["buy_animal"]},
"reason": f"可购买{info['name']}({info['cost']} 金币)",
}
)
return actions
# ================================================================ 内部工具
def _get_plot(self, plot_no: Any) -> Plot:
"""把 1-based 的地块编号转成 Plot,越界即报错。"""
try:
number = int(plot_no)
except (TypeError, ValueError):
raise GameError("bad_param", f"地块编号必须是整数,收到 {plot_no!r}")
if number < 1 or number > len(self.state.plots):
raise GameError(
"no_such_plot",
f"地块编号 {number} 不存在(当前共 {len(self.state.plots)} 块地)",
)
return self.state.plots[number - 1]
def _get_animal(self, animal_id: Any) -> Animal:
try:
aid = int(animal_id)
except (TypeError, ValueError):
raise GameError("bad_param", f"动物编号必须是整数,收到 {animal_id!r}")
for animal in self.state.animals:
if animal.aid == aid:
return animal
raise GameError("no_such_animal", f"没有编号为 {aid} 的动物")
def _spend_ap(self, action: str) -> None:
cost = config.AP_COST.get(action, 0)
if cost and self.state.ap < cost:
raise GameError(
"no_ap",
f"行动点不足:{action} 需要 {cost} 点,当前只剩 {self.state.ap} 点。"
f"按 N 休息或等待下一天。",
)
if cost:
self.state.ap -= cost
def _spend_gold(self, amount: int, what: str) -> None:
amount = int(amount)
if amount < 0:
raise GameError("bad_param", "金额不能为负")
if self.state.gold < amount:
raise GameError(
"not_enough_gold",
f"金币不足:{what}需要 {amount},你只有 {self.state.gold}",
)
self.state.gold -= amount
self.state.total_spent += amount
def _earn(self, amount: float, reason: str) -> int:
earned = max(0, int(round(amount)))
self.state.gold += earned
self.state.total_earned += earned
return earned
def _gain_exp(self, amount: int) -> List[str]:
"""增加经验并处理连续升级,返回升级提示文本。"""
messages: List[str] = []
self.state.exp += int(amount)
guard = 0
while self.state.exp >= self.state.exp_needed and guard < 50:
guard += 1
self.state.exp -= self.state.exp_needed
self.state.level += 1
bonus = config.level_up_bonus(self.state.level)
self.state.gold += bonus
unlocked = [
info["name"]
for info in config.CROPS.values()
if info["level_req"] == self.state.level
] + [
info["name"]
for info in config.ANIMALS.values()
if info["level_req"] == self.state.level
]
text = f"升级到 Lv.{self.state.level}!奖励 {bonus} 金币"
if unlocked:
text += f",解锁:{'、'.join(unlocked)}"
messages.append(text)
self.add_log(text, "good")
return messages
def _roll_yield(self) -> int:
"""丰收回合判定:常规产出 1,暴击产出 2。"""
return 2 if random.random() < config.LUCKY_CHANCE else 1
# ================================================================ 动作
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(
"unknown_crop",
f"未知作物 {crop!r},可选:{'、'.join(config.CROPS)}",
)
info = config.CROPS[crop]
if self.state.level < info["level_req"]:
raise GameError(
"locked",
f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}",
)
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']}")
self._spend_gold(info["seed_cost"], f"购买{info['name']}种子")
self._spend_ap("plant")
plot.crop = crop
plot.growth = 0.0
plot.moisture = config.PLANT_INITIAL_MOISTURE
plot.planted_day = self.state.day
self.add_log(f"在 {plot.index + 1} 号地播下{info['name']}种子", "action")
self._notify()
return {
"ok": True,
"message": f"{plot.index + 1} 号地已种下{info['name']},{info['grow_days']} 天后成熟",
"plot": plot.to_dict(),
}
def water(self, plot_no: Any) -> Dict[str, Any]:
with self._lock:
plot = self._get_plot(plot_no)
if plot.crop is None:
raise GameError("plot_empty", f"{plot.index + 1} 号地是空地,不需要浇水")
if plot.moisture >= config.WATER_CAPACITY:
raise GameError(
"already_watered",
f"{plot.index + 1} 号地水分已满({plot.moisture}/{config.WATER_CAPACITY})",
)
self._spend_ap("water")
plot.moisture = config.WATER_CAPACITY
self.add_log(f"给 {plot.index + 1} 号地浇水(水分 {config.WATER_CAPACITY})", "action")
self._notify()
return {"ok": True, "message": f"{plot.index + 1} 号地已浇水", "plot": plot.to_dict()}
def harvest(self, plot_no: Any) -> Dict[str, Any]:
with self._lock:
plot = self._get_plot(plot_no)
if plot.crop is None:
raise GameError("plot_empty", f"{plot.index + 1} 号地是空地")
if not plot.is_ripe:
raise GameError(
"not_ripe",
f"{config.CROPS[plot.crop]['name']}还差 {plot.days_left} 天成熟",
)
crop_key = plot.crop
info = config.CROPS[crop_key]
self._spend_ap("harvest")
quantity = self._roll_yield()
price = self.market_price(crop_key)
earned = self._earn(price * quantity, info["name"])
plot.crop = None
plot.growth = 0.0
plot.moisture = 0
self.state.total_harvested += quantity
lucky = "(丰收!)" if quantity > 1 else ""
self.add_log(
f"收获 {quantity} 份{info['name']}{lucky},+{earned} 金币", "good"
)
levelups = self._gain_exp(info["exp"])
self._notify()
return {
"ok": True,
"message": f"收获 {quantity} 份{info['name']},获得 {earned} 金币",
"quantity": quantity,
"earned": earned,
"exp": info["exp"],
"level_ups": levelups,
}
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(
"unknown_animal",
f"未知动物 {kind!r},可选:{'、'.join(config.ANIMALS)}",
)
info = config.ANIMALS[kind]
if self.state.level < info["level_req"]:
raise GameError(
"locked",
f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}",
)
if len(self.state.animals) >= config.MAX_ANIMALS:
raise GameError("pasture_full", f"牧场已满(上限 {config.MAX_ANIMALS} 只)")
self._spend_gold(info["cost"], f"购买{info['name']}")
self._spend_ap("buy_animal")
animal = Animal(kind)
self.state.animals.append(animal)
self.add_log(f"买了一只{info['name']}(#{animal.aid}),{info['cost']} 金币", "action")
self._notify()
return {
"ok": True,
"message": f"购入{info['name']} #{animal.aid},记得每天喂食",
"animal": animal.to_dict(),
}
def feed(self, animal_id: Any, quantity: Any = None) -> Dict[str, Any]:
with self._lock:
animal = self._get_animal(animal_id)
if animal.fed:
raise GameError("already_fed", f"{animal.name} #{animal.aid} 今天已经吃过了")
need = int(animal.info["feed_per_day"])
if quantity is None:
quantity = need
try:
quantity = int(quantity)
except (TypeError, ValueError):
raise GameError("bad_param", "喂食数量必须是整数")
if quantity <= 0:
raise GameError("bad_param", "喂食数量必须大于 0")
if quantity > need:
quantity = need # 多喂无益,自动收窄到实际需求
if self.state.feed < quantity:
shortfall = quantity - self.state.feed
cost = shortfall * config.FEED_EMERGENCY_PRICE
self._spend_gold(cost, f"临时采购 {shortfall} 份饲料")
self.state.feed += shortfall
self.add_log(f"饲料不足,临时高价补货 {shortfall} 份(-{cost} 金币)", "warn")
self._spend_ap("feed")
self.state.feed -= quantity
animal.fed = True
animal.starve = 0
self.add_log(f"喂了{animal.name} #{animal.aid} {quantity} 份饲料", "action")
self._notify()
return {
"ok": True,
"message": f"{animal.name} #{animal.aid} 吃饱了,剩余饲料 {self.state.feed}",
"feed_left": self.state.feed,
"animal": animal.to_dict(),
}
def collect(self, animal_id: Any) -> Dict[str, Any]:
with self._lock:
animal = self._get_animal(animal_id)
if animal.pending <= 0:
raise GameError(
"nothing_to_collect",
f"{animal.name} #{animal.aid} 暂时没有可收取的{animal.product}"
f"(还需 {animal.days_to_product} 天)",
)
self._spend_ap("collect")
quantity = animal.pending
animal.pending = 0
price = self.market_price(f"animal:{animal.kind}")
earned = self._earn(price * quantity, animal.product)
self.state.total_collected += quantity
self.add_log(
f"收取 {quantity} 份{animal.product}({animal.name} #{animal.aid}),+{earned} 金币",
"good",
)
levelups = self._gain_exp(animal.info["exp"] * quantity)
self._notify()
return {
"ok": True,
"message": f"收取 {quantity} 份{animal.product},获得 {earned} 金币",
"quantity": quantity,
"earned": earned,
"level_ups": levelups,
}
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", "饲料数量必须是整数")
if quantity <= 0:
raise GameError("bad_param", "饲料数量必须大于 0")
if quantity > config.MAX_FEED_PURCHASE:
raise GameError(
"too_many",
f"单次最多购买 {config.MAX_FEED_PURCHASE} 份饲料(请求 {quantity})",
)
cost = quantity * config.FEED_UNIT_PRICE
self._spend_gold(cost, f"购买 {quantity} 份饲料")
self.state.feed += quantity
self.add_log(f"购买 {quantity} 份饲料(-{cost} 金币)", "action")
self._notify()
return {
"ok": True,
"message": f"购入 {quantity} 份饲料,库存 {self.state.feed}",
"feed": self.state.feed,
}
def buy_plot(self) -> Dict[str, Any]:
with self._lock:
price = self.state.next_plot_price
if price < 0:
raise GameError(
"plot_limit",
f"土地已达当前等级上限({self.state.max_plots} 块),升级后可继续开垦",
)
self._spend_gold(price, "开垦新土地")
self._spend_ap("buy_plot")
plot = Plot(len(self.state.plots))
self.state.plots.append(plot)
self.state.plots_bought += 1
self.add_log(f"开垦了第 {plot.index + 1} 块地(-{price} 金币)", "action")
self._notify()
return {
"ok": True,
"message": f"新增 {plot.index + 1} 号地,共 {len(self.state.plots)} 块",
"plot": plot.to_dict(),
"next_plot_price": self.state.next_plot_price,
}
def sleep(self) -> Dict[str, Any]:
"""手动推进一天,带冷却避免刷时间。"""
with self._lock:
elapsed = time.time() - self._last_sleep
if elapsed < config.SLEEP_COOLDOWN:
wait = config.SLEEP_COOLDOWN - elapsed
raise GameError("cooldown", f"刚休息过,请等待 {wait:.1f} 秒", status=429)
self._last_sleep = time.time()
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]:
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]:
with self._lock:
tick = self.tick_seconds
self.state = GameState()
self._log_seq = 0
self._last_sleep = 0.0
self.paused = False
if keep_tick:
self.tick_seconds = tick
self.add_log("新的一天,新的开始。欢迎来到 SimpleFarm!", "info")
self._notify()
return {"ok": True, "message": "农场已重置"}
def save(self, path: Optional[str] = None) -> Dict[str, Any]:
"""把当前状态写入磁盘。
单机模式下由 TUI 直接调用;服务器模式里存档位置由牧场会话决定,
但保留同名方法是为了让界面代码不必区分"本地/远程"。
"""
from .storage import save_state
target = save_state(self.state, path)
self.add_log(f"已存档到 {target}", "info")
return {"ok": True, "message": f"已存档到 {target}", "path": target}
# ================================================================ 时间推进
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] = []
# ---- 作物生长
for plot in state.plots:
if plot.crop is None:
continue
if plot.moisture > 0:
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']}已成熟")
else:
events.append(f"{plot.index + 1} 号地缺水,作物停止生长")
# ---- 牲畜
dead: List[Animal] = []
for animal in state.animals:
if animal.fed:
animal.fed = False
animal.starve = 0
animal.progress += 1
if animal.progress >= animal.cycle:
animal.progress = 0
animal.pending += 1
else:
animal.starve += 1
if animal.starve >= 3:
dead.append(animal)
elif animal.starve == 2:
events.append(f"{animal.name} #{animal.aid} 已经两天没吃东西了")
for animal in dead:
state.animals.remove(animal)
events.append(f"{animal.name} #{animal.aid} 因长期饥饿离你而去")
self.add_log(f"{animal.name} #{animal.aid} 因长期饥饿离开农场", "bad")
# ---- 市场波动
self._shuffle_market()
# ---- 写入日志(最多 4 条,避免刷屏)
for text in events[:4]:
self.add_log(text, "warn")
if events:
self.add_log(f"第 {state.day} 天开始({reason}),行动点已恢复", "info")
else:
self.add_log(f"第 {state.day} 天开始({reason}),一切安好", "info")
self._notify()
return events
def _shuffle_market(self) -> None:
"""每天让市场价格围绕基准价波动。"""
vol = config.PRICE_VOLATILITY
for key, info in config.CROPS.items():
base = float(info["base_price"])
factor = random.uniform(1 - vol, 1 + vol)
# 轻微均值回归,避免价格长期漂移
current = self.state.market.get(key, base)
blended = current * 0.35 + base * 0.65
self.state.market[key] = round(clamp(blended * factor, base * 0.55, base * 1.55), 2)
for key, info in config.ANIMALS.items():
base = float(info["base_price"])
factor = random.uniform(1 - vol, 1 + vol)
current = self.state.market.get(f"animal:{key}", base)
blended = current * 0.35 + base * 0.65
self.state.market[f"animal:{key}"] = round(
clamp(blended * factor, base * 0.55, base * 1.55), 2
)
class Scheduler:
"""自动推进游戏时间的后台线程。"""
def __init__(self, engine: FarmEngine, enabled: bool = True) -> None:
self.engine = engine
self.enabled = enabled
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def start(self) -> None:
if not self.enabled or self._thread is not None:
return
self.engine.next_tick_at = time.time() + self.engine.tick_seconds
self._thread = threading.Thread(target=self._run, name="farm-clock", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
self.engine.next_tick_at = None
def _run(self) -> None:
while not self._stop.wait(0.2):
engine = self.engine
if engine.paused:
# 暂停时顺延,保证恢复后仍有完整的一个 tick
engine.next_tick_at = time.time() + engine.tick_seconds
continue
deadline = engine.next_tick_at
if deadline is None:
deadline = time.time() + engine.tick_seconds
if time.time() >= deadline:
try:
engine.advance_day(reason="时间流逝")
except Exception:
pass
engine.next_tick_at = time.time() + engine.tick_seconds
else:
engine.next_tick_at = deadline
+142
View File
@@ -0,0 +1,142 @@
"""跨平台非阻塞键盘读取。
Windows 用 ``msvcrt``,POSIX 用 ``termios`` + ``select``,
两端统一返回规范化的按键名,TUI 层无需关心平台差异。
在非交互式环境(管道、重定向)下 ``available`` 为 False,
TUI 会退化为无输入的只读模式。
"""
from __future__ import annotations
import os
import sys
from typing import Optional
IS_WINDOWS = os.name == "nt"
# Windows 下的扩展键(以 \x00 / \xe0 开头)
_WIN_EXTENDED = {
"H": "UP",
"P": "DOWN",
"K": "LEFT",
"M": "RIGHT",
"G": "HOME",
"O": "END",
"S": "DELETE",
}
_WIN_CTRL = {
"\x03": "CTRL_C",
"\r": "ENTER",
"\n": "ENTER",
"\t": "TAB",
"\x08": "BACKSPACE",
"\x1b": "ESC",
" ": "SPACE",
}
class KeyReader:
"""把终端的原始字节流转换成按键名。"""
def __init__(self) -> None:
self.available = sys.stdin is not None and sys.stdin.isatty()
self._fd: Optional[int] = None
self._saved_term = None
if self.available and not IS_WINDOWS:
try:
import termios
import tty
self._fd = sys.stdin.fileno()
self._saved_term = termios.tcgetattr(self._fd)
# cbreak: 立即返回按键,但保留 Ctrl-C 等信号
tty.setcbreak(self._fd)
except Exception:
self.available = False
self._saved_term = None
if self.available and IS_WINDOWS:
try:
import msvcrt # noqa: F401
except Exception:
self.available = False
# ------------------------------------------------------------------ 读取
def poll(self) -> Optional[str]:
"""非阻塞读取一个按键;没有按键时返回 None。"""
if not self.available:
return None
return self._poll_windows() if IS_WINDOWS else self._poll_posix()
def _poll_windows(self) -> Optional[str]:
import msvcrt
if not msvcrt.kbhit():
return None
char = msvcrt.getwch()
if char in ("\x00", "\xe0"):
if not msvcrt.kbhit():
return None
return _WIN_EXTENDED.get(msvcrt.getwch().upper())
if char in _WIN_CTRL:
return _WIN_CTRL[char]
return char.lower()
def _poll_posix(self) -> Optional[str]:
import select
if self._fd is None:
return None
try:
ready, _, _ = select.select([self._fd], [], [], 0)
except (OSError, ValueError):
return None
if not ready:
return None
try:
data = os.read(self._fd, 8)
except OSError:
return None
if not data:
return None
text = data.decode("utf-8", errors="ignore")
if not text:
return None
# 方向键:ESC [ A/B/C/D,或 ESC O A/B/C/D
if text.startswith("\x1b["):
return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3])
if text.startswith("\x1bO"):
return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3])
char = text[0]
mapping = {
"\x03": "CTRL_C",
"\r": "ENTER",
"\n": "ENTER",
"\t": "TAB",
"\x7f": "BACKSPACE",
"\x08": "BACKSPACE",
"\x1b": "ESC",
" ": "SPACE",
}
if char in mapping:
return mapping[char]
return char.lower()
# ------------------------------------------------------------------ 清理
def close(self) -> None:
if self._fd is not None and self._saved_term is not None:
try:
import termios
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved_term)
except Exception:
pass
self._fd = None
self._saved_term = None
+324
View File
@@ -0,0 +1,324 @@
"""游戏数据模型。
设计原则:模型只负责"持有数据 + 结构化转换",所有规则判定放在 engine.py。
这样 HTTP 层和 TUI 层都能拿到同一份状态快照。
"""
from __future__ import annotations
import itertools
from typing import Any, Dict, List, Optional
from . import config
def clamp(value: float, low: float, high: float) -> float:
"""把 value 限制在 [low, high] 区间内。"""
return max(low, min(high, value))
class Plot:
"""一块土地。"""
__slots__ = ("index", "crop", "growth", "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.planted_day: int = 0
# ---------------------------------------------------------------- 查询
@property
def is_empty(self) -> bool:
return self.crop is None
@property
def grow_days(self) -> int:
if self.crop is None:
return 0
return int(config.CROPS[self.crop]["grow_days"])
@property
def is_ripe(self) -> bool:
return self.crop is not None and self.growth >= self.grow_days
@property
def days_left(self) -> int:
if self.crop is None:
return 0
return max(0, self.grow_days - int(self.growth))
@property
def stage(self) -> str:
"""人类可读的生长阶段。"""
if self.crop is None:
return "空地"
progress = self.growth / max(1, self.grow_days)
name = "已播种"
for threshold, label in config.GROWTH_STAGES:
if progress >= threshold:
name = label
return name
# ---------------------------------------------------------------- 序列化
def to_dict(self) -> Dict[str, Any]:
info = config.CROPS.get(self.crop) if self.crop else None
return {
"index": self.index,
"position": self.index + 1,
"crop": self.crop,
"crop_name": info["name"] if info else None,
"growth": round(self.growth, 2),
"grow_days": self.grow_days,
"days_left": self.days_left,
"is_ripe": self.is_ripe,
"is_empty": self.is_empty,
"moisture": self.moisture,
"water_capacity": config.WATER_CAPACITY,
"stage": self.stage,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Plot":
plot = cls(int(data.get("index", 0)))
crop = data.get("crop")
# 校验存档里的作物名仍然合法,避免旧存档导致 KeyError
plot.crop = crop if crop in config.CROPS else None
plot.growth = float(data.get("growth", 0.0))
plot.moisture = int(data.get("moisture", 0))
plot.planted_day = int(data.get("planted_day", 0))
return plot
class Animal:
"""一只牲畜。"""
_id_counter = itertools.count(1)
__slots__ = ("aid", "kind", "fed", "progress", "starve", "pending")
def __init__(self, kind: str, aid: Optional[int] = 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 # 待收集的产出数量
# ---------------------------------------------------------------- 查询
@property
def info(self) -> Dict[str, Any]:
return config.ANIMALS[self.kind]
@property
def name(self) -> str:
return self.info["name"]
@property
def product(self) -> str:
return self.info["product"]
@property
def cycle(self) -> int:
return int(self.info["cycle"])
@property
def days_to_product(self) -> int:
return max(0, self.cycle - self.progress)
@property
def is_sick(self) -> bool:
"""连续 2 天没吃东西,进入"虚弱"状态。"""
return self.starve >= 2
# ---------------------------------------------------------------- 序列化
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.aid,
"kind": self.kind,
"name": self.name,
"product": self.product,
"fed": self.fed,
"progress": self.progress,
"cycle": self.cycle,
"days_to_product": self.days_to_product,
"pending": self.pending,
"starve": self.starve,
"is_sick": self.is_sick,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> Optional["Animal"]:
kind = data.get("kind")
if kind not in config.ANIMALS:
return None
animal = cls(kind, int(data.get("id", 0)) or None)
animal.fed = bool(data.get("fed", False))
animal.progress = int(data.get("progress", 0))
animal.starve = int(data.get("starve", 0))
animal.pending = int(data.get("pending", 0))
# 恢复 id 计数器,避免新动物与旧动物 id 冲突
Animal._id_counter = itertools.count(max(animal.aid + 1, next(Animal._id_counter)))
return animal
class GameState:
"""完整的游戏状态。所有字段都必须是可 JSON 序列化的。"""
def __init__(self) -> None:
self.day: int = 1
self.gold: int = config.START_GOLD
self.level: int = config.START_LEVEL
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]] = []
# 市场价格(每天波动):key -> 当前单价
self.market: Dict[str, float] = {
key: float(info["base_price"]) for key, info in config.CROPS.items()
}
for key, info in config.ANIMALS.items():
self.market[f"animal:{key}"] = float(info["base_price"])
# 统计
self.total_harvested: int = 0
self.total_collected: int = 0
self.total_earned: int = 0
self.total_spent: int = 0
self.plots_bought: int = 0
# ---------------------------------------------------------------- 派生属性
@property
def max_ap(self) -> int:
return config.START_AP
@property
def max_plots(self) -> int:
"""当前等级允许持有的土地上限。"""
allowed = config.BASE_PLOTS + config.PLOTS_PER_LEVEL * (self.level - 1)
return int(clamp(allowed, config.BASE_PLOTS, config.MAX_PLOTS))
@property
def next_plot_price(self) -> int:
"""下一块土地的价格;已达上限时返回 -1。"""
if len(self.plots) >= self.max_plots:
return -1
return int(config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH ** self.plots_bought))
@property
def exp_needed(self) -> int:
return config.exp_to_next(self.level)
@property
def inventory_value(self) -> float:
return self.feed * config.FEED_UNIT_PRICE
@property
def livestock_value(self) -> float:
return sum(
a.pending * self.market.get(f"animal:{a.kind}", 0) + a.info["cost"] * 0.5
for a in self.animals
)
@property
def net_worth(self) -> float:
"""净资产:金币 + 已购地价 + 牲畜折价 + 库存 + 地里未收的作物。"""
land = sum(
config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH ** i)
for i in range(self.plots_bought)
)
crops = 0.0
for plot in self.plots:
if plot.crop:
crops += self.market.get(plot.crop, 0) * (plot.growth / max(1, plot.grow_days))
return self.gold + land + self.livestock_value + self.inventory_value + crops
@property
def goal_index(self) -> int:
return config.goal_index(self.net_worth)
@property
def next_goal(self) -> Optional[Dict[str, Any]]:
nxt = self.goal_index + 1
if nxt >= len(config.GOALS):
return None
amount, text = config.GOALS[nxt]
return {"amount": amount, "text": text}
# ---------------------------------------------------------------- 序列化
def to_dict(self) -> Dict[str, Any]:
return {
"day": self.day,
"gold": self.gold,
"level": self.level,
"exp": self.exp,
"exp_needed": self.exp_needed,
"ap": self.ap,
"max_ap": self.max_ap,
"feed": self.feed,
"plots": [p.to_dict() for p in self.plots],
"plot_count": len(self.plots),
"max_plots": self.max_plots,
"next_plot_price": self.next_plot_price,
"animals": [a.to_dict() for a in self.animals],
"animal_count": len(self.animals),
"max_animals": config.MAX_ANIMALS,
"market": {k: round(v, 2) for k, v in self.market.items()},
"net_worth": round(self.net_worth, 2),
"goal_index": self.goal_index,
"next_goal": self.next_goal,
"stats": {
"harvested": self.total_harvested,
"collected": self.total_collected,
"earned": self.total_earned,
"spent": self.total_spent,
},
}
@classmethod
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)))
state.level = max(1, int(data.get("level", 1)))
state.exp = max(0, int(data.get("exp", 0)))
state.feed = max(0, int(data.get("feed", 0)))
state.ap = int(clamp(int(data.get("ap", state.max_ap)), 0, state.max_ap))
plots = [Plot.from_dict(p) for p in data.get("plots", []) if isinstance(p, dict)]
# 保证至少有初始土地,且不超过硬上限
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]
for i, plot in enumerate(state.plots):
plot.index = i
animals: List[Animal] = []
for item in data.get("animals", [])[: config.MAX_ANIMALS]:
if isinstance(item, dict):
animal = Animal.from_dict(item)
if animal is not None:
animals.append(animal)
state.animals = animals
market = data.get("market", {})
for key in list(state.market.keys()):
try:
value = float(market.get(key, state.market[key]))
except (TypeError, ValueError):
continue
state.market[key] = clamp(value, 1.0, 100000.0)
stats = data.get("stats", {})
state.total_harvested = max(0, int(stats.get("harvested", 0)))
state.total_collected = max(0, int(stats.get("collected", 0)))
state.total_earned = max(0, int(stats.get("earned", 0)))
state.total_spent = max(0, int(stats.get("spent", 0)))
state.plots_bought = max(0, len(state.plots) - config.BASE_PLOTS)
return state
+63
View File
@@ -0,0 +1,63 @@
"""网络小工具:本地回环请求不该走系统代理。
为什么需要这个模块
------------------
在有 ``HTTP_PROXY`` 的机器上(公司网络、容器、CI 沙箱都常见),标准库
``urllib`` 会把 **所有** 请求——包括发给 ``127.0.0.1`` 的——都塞给代理。
于是本机自己开的游戏服务器会莫名其妙地连不上,报的还是"502 上游连接失败"
这种看不出根因的错误。
游戏客户端只应该关心"我和服务器之间"这一跳:
* 目标是回环地址(127.0.0.1 / localhost / ::1)→ 直连,不经过任何代理;
* 目标是别的机器 → 尊重用户环境里的代理设置。
WebSocket 那边不需要处理:``websockets`` 库默认不读代理环境变量。
"""
from __future__ import annotations
import urllib.parse
import urllib.request
from typing import Any, Optional, Union
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:
"""判断主机名是否是本机回环(含 127.x.x.x 网段与 IPv6 回环)。"""
if host is None:
return False
host = host.strip().lower().strip("[]")
if host in LOOPBACK_HOSTS:
return True
return host.startswith("127.")
def host_of(target: Union[str, urllib.request.Request]) -> Optional[str]:
url = target.full_url if isinstance(target, urllib.request.Request) else target
try:
return urllib.parse.urlsplit(url).hostname
except ValueError:
return None
def open_url(target: Union[str, urllib.request.Request], timeout: float = 6.0,
host: Optional[str] = None) -> Any:
"""打开一个 URL 或 Request,回环地址自动绕过代理。
返回值是 ``http.client.HTTPResponse``,用法与 ``urllib.request.urlopen`` 一致。
"""
if not isinstance(target, urllib.request.Request):
target = urllib.request.Request(target, headers={"Accept": "application/json"})
target_host = host or host_of(target)
if is_loopback(target_host):
return _no_proxy_opener.open(target, timeout=timeout)
return urllib.request.urlopen(target, timeout=timeout)
def endpoint_host(url: str, default: str = "127.0.0.1") -> str:
"""从 ``http://host:port`` 里取出 host(带默认值)。"""
parts = urllib.parse.urlsplit(url if "://" in url else "http://" + url)
return parts.hostname or default
+60
View File
@@ -0,0 +1,60 @@
"""SimpleFarm 服务器包。
把原本"一个进程一局游戏"的单机引擎,改造成一个可以多人同时在线、
可以后台常驻、既能被人玩也能被 AI 调用的小型游戏服务器。
模块划分
--------
* :mod:`farm.server.app` —— FastAPI 应用(REST + WebSocket)
* :mod:`farm.server.actions` —— 动作表:所有入口共用的唯一执行通道
* :mod:`farm.server.session` —— 多牧场会话与统一时钟
* :mod:`farm.server.hub` —— WebSocket 连接池与状态推送
* :mod:`farm.server.daemon` —— 守护进程(start/stop/status)
* :mod:`farm.server.webui` —— 内嵌网页客户端
``app`` 依赖 FastAPI,而 ``daemon`` 只依赖标准库。为了让
``python main.py stop`` 在没有装 FastAPI 的机器上也能用,这里采用**惰性导出**:
真正 import 到用例时才加载对应子模块。
"""
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",
}
__all__ = sorted(_LAZY)
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
if name in ("DaemonManager", "pid_alive"):
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"):
module = import_module("farm.server.actions")
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")
else:
module = import_module("farm.server.app")
return getattr(module, name)
def __dir__() -> list:
return sorted(set(list(globals()) + __all__))
+164
View File
@@ -0,0 +1,164 @@
"""``python -m farm.server`` —— 服务器进程入口(前台运行)。
守护进程模式会由 :class:`farm.server.daemon.DaemonManager` 用同样的命令行
再拉起一个本模块的实例,只是不带 ``--daemon``。
"""
from __future__ import annotations
import argparse
import os
import sys
from .. import config
from .daemon import DEFAULT_LOG_FILE, DEFAULT_PID_FILE, PROJECT_ROOT, DaemonManager
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m farm.server",
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("--port", type=int, default=8765, help="监听端口,默认 8765")
parser.add_argument("--token", default=None, help="访问令牌,设置后所有接口都要带")
parser.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS,
help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒")
parser.add_argument("--save-dir", default=None, help="存档目录,默认 <项目根>/saves")
parser.add_argument("--save", dest="save_file", default=None,
help="兼容旧参数:指定 default 牧场的存档文件")
parser.add_argument("--max-sessions", type=int, default=64, help="同时在线的牧场上限")
parser.add_argument("--idle-timeout", type=float, default=1800.0,
help="无人访问多少秒后把牧场移出内存(存档保留),默认 1800")
parser.add_argument("--world", default="SimpleFarm 公共农场", help="世界名称")
parser.add_argument("--log-level", default="info",
choices=["critical", "error", "warning", "info", "debug"])
parser.add_argument("--access-log", action="store_true", help="打印每个 HTTP 请求")
parser.add_argument("--no-remote-control", action="store_true",
help="禁止通过接口停机")
parser.add_argument("--daemon", action="store_true",
help="转到后台运行(等价于 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
def clamp_tick(value: float) -> float:
return max(config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, float(value)))
def dependency_hint() -> str:
return (
"缺少运行服务器所需的依赖。请先安装:\n"
" uv sync\n"
"如果不想用 uv,也可以用 pip:\n"
" pip install fastapi 'uvicorn[standard]'\n"
f"\n当前解释器:{sys.executable}"
)
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
save_dir = args.save_dir or os.path.join(PROJECT_ROOT, "saves")
if args.save_file:
save_dir = os.path.dirname(os.path.abspath(args.save_file)) or save_dir
if args.daemon:
manager = DaemonManager(
host=args.host, port=args.port, token=args.token,
tick=clamp_tick(args.tick), save_dir=save_dir,
pid_file=args.pid_file or DEFAULT_PID_FILE,
log_file=args.log_file or DEFAULT_LOG_FILE,
)
ok, message = manager.start()
print(message)
return 0 if ok else 1
try:
import uvicorn
except ImportError:
print(dependency_hint(), file=sys.stderr)
return 2
try:
import websockets # noqa: F401 只是探测,uvicorn 需要它来做 WS 协议升级
ws_ok = True
except ImportError:
ws_ok = False
from .app import ServerConfig, create_app
cfg = ServerConfig(
host=args.host,
port=args.port,
token=args.token,
tick_seconds=clamp_tick(args.tick),
save_dir=save_dir,
max_sessions=max(1, args.max_sessions),
idle_timeout=max(60.0, args.idle_timeout),
world_name=args.world,
allow_remote_control=not args.no_remote_control,
)
app = create_app(cfg)
server_config = uvicorn.Config(
app,
host=cfg.host,
port=cfg.port,
log_level=args.log_level,
access_log=args.access_log,
loop="asyncio",
lifespan="on",
)
server = uvicorn.Server(server_config)
# 让 POST /api/admin/shutdown 能触发优雅退出(lifespan 里会把存档写掉)
app.state.handle.on_stop(lambda: setattr(server, "should_exit", True))
# pid 文件由**本进程**写:父进程(守护进程管理器)拿到的 pid 未必是真正
# 在监听端口的那个(Windows 上 venv 的 python.exe 可能是转发器)。
def write_pid_file() -> None:
if not args.pid_file:
return
try:
os.makedirs(os.path.dirname(args.pid_file) or ".", exist_ok=True)
with open(args.pid_file, "w", encoding="utf-8") as handle:
handle.write(str(os.getpid()))
except OSError as exc:
print(f"⚠ 无法写入 pid 文件 {args.pid_file}:{exc}", file=sys.stderr)
app.state.handle.on_start(write_pid_file)
print(f"SimpleFarm 服务器启动中(pid {os.getpid()})…")
print(f" 监听 http://{cfg.host}:{cfg.port}/")
print(f" 存档目录 {save_dir}")
print(f" 实时通道 ws://{cfg.host}:{cfg.port}/ws?farm=<牧场名>")
if not ws_ok:
print(" ⚠ 未检测到 websockets 库,/ws 实时通道可能不可用;"
"网页端会自动降级为轮询。安装:pip install 'uvicorn[standard]'")
if cfg.token:
print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数")
server.run()
# 收尾:把自己写的 pid 文件清掉,避免下次 status 看到"幽灵进程"
if args.pid_file:
try:
if os.path.exists(args.pid_file):
with open(args.pid_file, "r", 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)
return 3
print("服务器已停止。")
return 0
if __name__ == "__main__":
sys.exit(main())
+240
View File
@@ -0,0 +1,240 @@
"""统一的动作表:HTTP、WebSocket、命令行、机器人都走这一份映射。
为什么要有这一层
----------------
引擎(``farm.engine.FarmEngine``)提供的是**方法**,但外部调用者(AI Agent、
脚本、网页)手里拿到的只有字符串动作名 + 参数字典。如果让每个入口各自写一遍
``if action == "plant"`` 的分发,规则就会散落在三四个文件里,迟早出现
"HTTP 能做、WS 不能做" 这种不一致。
所以这里集中定义:动作名 → 参数规格 → 引擎调用 → 返回结果。
新增一个玩法动作,只需要在这张表里加一行。
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from .. import config
from ..engine import FarmEngine, GameError
class Param:
"""一个动作参数的规格说明(同时用于校验与自动生成给 AI 的文档)。"""
__slots__ = ("name", "kind", "required", "default", "desc")
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.required = required
self.default = default
self.desc = desc
def coerce(self, raw: Any) -> Any:
"""把外部传来的值转成期望类型,失败时抛出带 code 的业务异常。"""
if self.kind == "bool":
if isinstance(raw, bool):
return raw
if isinstance(raw, str) and raw.lower() in ("true", "false", "1", "0"):
return raw.lower() in ("true", "1")
if isinstance(raw, int):
return bool(raw)
raise GameError("bad_param", f"参数 {self.name} 需要布尔值,收到 {raw!r}")
if self.kind == "int":
if isinstance(raw, bool):
raise GameError("bad_param", f"参数 {self.name} 需要整数,收到布尔值")
if isinstance(raw, int):
return raw
if isinstance(raw, float) and raw.is_integer():
return int(raw)
if isinstance(raw, str) and raw.strip().lstrip("+-").isdigit():
return int(raw.strip())
raise GameError("bad_param", f"参数 {self.name} 需要整数,收到 {raw!r}")
if self.kind == "str":
if isinstance(raw, (str, int, float)):
return str(raw)
raise GameError("bad_param", f"参数 {self.name} 需要字符串,收到 {raw!r}")
return raw
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"type": self.kind,
"required": self.required,
"default": self.default,
"desc": self.desc,
}
class Action:
"""一个可执行动作。"""
__slots__ = ("name", "summary", "params", "handler", "realtime")
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]:
return {
"action": self.name,
"summary": self.summary,
"params": [p.to_dict() for p in self.params],
}
# ---------------------------------------------------------------------- 处理器
def _harvest_all(engine: FarmEngine, _params: Dict[str, Any]) -> Dict[str, Any]:
"""收获所有成熟作物。逐块调用引擎,个别地块失败不影响其它地块。"""
total_gold = 0
total_units = 0
plots: List[int] = []
errors: List[str] = []
# 复制列表:harvest 会修改 state.plots 中元素的内容,但不会增删元素
for plot in list(engine.state.plots):
if not plot.is_ripe:
continue
try:
result = engine.harvest(plot.index + 1)
except GameError as exc:
errors.append(f"{plot.index + 1}号地:{exc.message}")
continue
total_gold += int(result.get("earned", 0))
total_units += int(result.get("quantity", 0))
plots.append(plot.index + 1)
if not plots and not errors:
raise GameError("nothing_to_harvest", "当前没有成熟的作物可以收获")
return {
"message": f"收获 {len(plots)} 块地共 {total_units} 份作物,+{total_gold} 金币",
"plots": plots,
"quantity": total_units,
"earned": total_gold,
"errors": errors,
}
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]:
return engine.reset()
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] = {}
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("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_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("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("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("save", "把当前农场写入存档文件", [], _save))
_register(Action("reset", "重置农场,从头开始经营", [], _reset))
def action_names() -> List[str]:
return sorted(ACTIONS)
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]:
"""校验参数并执行动作。
这是所有入口的**唯一**执行通道,因此输入校验只需要在这里做一次。
"""
if not isinstance(name, str) or not name.strip():
raise GameError("bad_param", "缺少动作名 action")
key = name.strip().lower()
action = ACTIONS.get(key)
if action is None:
raise GameError(
"unknown_action",
f"未知动作 {name!r},可用动作:{'、'.join(action_names())}",
)
if params is None:
params = {}
if not isinstance(params, dict):
raise GameError("bad_param", "params 必须是对象")
known = {p.name for p in action.params}
unknown = [k for k in params if k not in known]
if unknown:
allowed = "、".join(sorted(known)) or "无"
raise GameError(
"bad_param",
f"动作 {key} 不认识参数 {'、'.join(unknown)}(可用:{allowed})",
)
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])
elif spec.required:
raise GameError("missing_param", f"动作 {key} 缺少必需参数 {spec.name}")
elif spec.default is not None:
clean[spec.name] = spec.default
result = action.handler(engine, clean)
if not isinstance(result, dict):
result = {"message": str(result)}
result.setdefault("ok", True)
result["action"] = key
return result
+688
View File
@@ -0,0 +1,688 @@
"""FastAPI 应用:REST + WebSocket + 网页客户端。
接口设计原则
------------
1. **一切皆 JSON**:每个响应都带 ``ok`` 字段,出错时带机器可读的 ``error``
与人类可读的 ``message``,AI 可以直接拿 ``error`` 分支决策。
2. **状态紧随结果**:每个写操作都会在响应里附一份精简状态(``state``),
这样 AI 一次请求就能同时"做动作 + 看结果",省一半往返。
3. **多牧场隔离**:用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家,
每个牧场有自己的引擎、时钟与存档;互不影响。
4. **实时通道**:``/ws`` 推送状态,人和 AI 都能订阅;HTTP 与 WS 改的是
同一个引擎,所以「网页里点一下,AI 那边立刻看到」。
"""
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from .. import config
from ..engine import GameError
from . import actions as actions_mod
from .hub import Client, ConnectionHub, Publisher, next_seq
from .session import DEFAULT_FARM, FarmClock, FarmSession, SessionManager, validate_farm_id
from .webui import DASHBOARD_HTML, dashboard_text
API_VERSION = "2.0"
SERVER_NAME = "SimpleFarm"
@dataclass
class ServerConfig:
"""服务器配置(由命令行解析而来)。"""
host: str = "127.0.0.1"
port: int = 8765
token: Optional[str] = None
tick_seconds: float = config.DEFAULT_TICK_SECONDS
save_dir: str = "saves"
max_sessions: int = 64
idle_timeout: float = 1800.0
autosave_interval: float = 60.0
allow_remote_control: bool = True
world_name: str = "SimpleFarm 公共农场"
extra: Dict[str, Any] = field(default_factory=dict)
class ServerHandle:
"""让 HTTP 接口能够优雅地要求本进程退出,并暴露进程生命周期的钩子。
* ``on_start``:监听端口成功后触发(用于写 pid 文件等);
* ``on_stop``:请求关闭时触发(uvicorn 用它设置 ``should_exit``)。
"""
def __init__(self) -> None:
self._start_callbacks: List[Callable[[], None]] = []
self._stop_callbacks: List[Callable[[], None]] = []
self.stopping = False
def on_start(self, callback: Callable[[], None]) -> None:
self._start_callbacks.append(callback)
def on_stop(self, callback: Callable[[], None]) -> None:
self._stop_callbacks.append(callback)
def notify_start(self) -> None:
for callback in list(self._start_callbacks):
try:
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:
callback()
except Exception:
pass
return True
# ---------------------------------------------------------------------- 工具
def brief(session: FarmSession, hub: ConnectionHub) -> Dict[str, Any]:
"""操作后回给调用者的精简状态:够做下一步决策,又不至于太吵。"""
snap = session.snapshot()
return {
"farm": session.farm_id,
"day": snap["day"],
"gold": snap["gold"],
"level": snap["level"],
"exp": snap["exp"],
"exp_needed": snap["exp_needed"],
"ap": snap["ap"],
"max_ap": snap["max_ap"],
"feed": snap["feed"],
"plot_count": snap["plot_count"],
"max_plots": snap["max_plots"],
"next_plot_price": snap["next_plot_price"],
"animal_count": snap["animal_count"],
"max_animals": snap["max_animals"],
"ripe_plots": [p["position"] for p in snap["plots"] if p["is_ripe"]],
"hungry_animals": [a["id"] for a in snap["animals"] if not a["fed"]],
"collectable_animals": [a["id"] for a in snap["animals"] if a["pending"] > 0],
"net_worth": snap["net_worth"],
"goal_index": snap["goal_index"],
"seconds_to_tick": snap["seconds_to_tick"],
"paused": snap["paused"],
"online": hub.online(session.farm_id),
}
def rules_text() -> str:
return (
f"{SERVER_NAME} 规则速览(给 AI 玩家的上下文)\n"
"==========================================\n"
f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS:.0f} 秒自动推进一天(可配置)。"
f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n"
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天"
"(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"
"5. 牲畜:每天必须喂食才会累计产出周期;连续 3 天未进食会永久离开农场。\n"
f"6. 市场:作物与畜产品价格每天在基础价 ±{config.PRICE_VOLATILITY:.0%} 内波动,"
"高价卖出收益更高。\n"
f"7. 土地:初始 {config.BASE_PLOTS} 块,每升 1 级多 {config.PLOTS_PER_LEVEL} 块,"
f"上限 {config.MAX_PLOTS} 块;购地价格每次递增。\n"
"8. 目标:把净资产(金币 + 土地 + 牲畜 + 库存 + 地里作物)做到 10000 金币。\n"
"9. 多人:每个 farm 是一个独立牧场,用 ?farm=名字 区分,互不影响;"
"排行榜见 GET /api/world。\n"
"\n推荐决策循环(AI 最短路径):\n"
" GET /api/actions?farm=me → 拿到当前所有**合法**动作(含开销与理由)\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"
)
# ---------------------------------------------------------------------- 应用
def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI:
cfg = cfg or ServerConfig()
manager = SessionManager(
save_dir=cfg.save_dir,
tick_seconds=cfg.tick_seconds,
max_sessions=cfg.max_sessions,
idle_timeout=cfg.idle_timeout,
)
hub = ConnectionHub()
publisher = Publisher(manager, hub, autosave_interval=cfg.autosave_interval)
clock = FarmClock(manager)
handle = ServerHandle()
started_at = time.time()
counters = {"requests": 0, "actions": 0, "ws_messages": 0, "errors": 0}
async def lifespan(_app: FastAPI) -> Any:
clock.start()
publisher.start()
# 端口已经绑定成功(uvicorn 先 bind 再跑 lifespan),可以对外宣告"我在了"
handle.notify_start()
try:
yield
finally:
publisher.stop()
clock.stop()
manager.shutdown()
app = FastAPI(
title=f"{SERVER_NAME} 游戏服务器",
version=API_VERSION,
description="命令行农场游戏的服务端:HTTP 给 AI 用,WebSocket 给实时界面用。",
lifespan=lifespan,
)
app.state.cfg = cfg
app.state.manager = manager
app.state.hub = hub
app.state.publisher = publisher
app.state.clock = clock
app.state.handle = handle
app.state.started_at = started_at
app.state.counters = counters
# ------------------------------------------------------------ 异常处理
@app.exception_handler(GameError)
async def _game_error(_request: Request, exc: GameError) -> JSONResponse:
counters["errors"] += 1
return JSONResponse(exc.to_dict(), status_code=exc.status)
@app.exception_handler(json.JSONDecodeError)
async def _bad_json(_request: Request, exc: json.JSONDecodeError) -> JSONResponse:
counters["errors"] += 1
return JSONResponse(
{"ok": False, "error": "bad_json", "message": f"请求体不是合法 JSON:{exc}"},
status_code=400,
)
# ------------------------------------------------------------ 鉴权与取牧场
def authorized(supplied: Optional[str]) -> bool:
if not cfg.token:
return True
return supplied == cfg.token
def token_of(request: Request) -> Optional[str]:
return request.headers.get("x-api-token") or request.query_params.get("token")
def guard(request: Request) -> None:
counters["requests"] += 1
if not authorized(token_of(request)):
raise GameError("unauthorized", "缺少或错误的 API Token", status=401)
def session_of(request: Request, owner: str = "") -> FarmSession:
farm = request.headers.get("x-farm-id") or request.query_params.get("farm") or DEFAULT_FARM
return manager.get(farm, create=True, owner=owner)
def full_requested(request: Request) -> bool:
raw = (request.query_params.get("full") or "").lower()
return raw in ("1", "true", "yes", "on")
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}")
if not isinstance(data, dict):
raise GameError("bad_json", "请求体必须是 JSON 对象")
return data
def execute_action(session: FarmSession, name: Any, params: Any) -> Dict[str, Any]:
"""执行一个动作,返回引擎/会话级的结果字典。
``save`` 与 ``reset`` 必须由**牧场会话**处理而不是引擎:
``engine.save()`` 只会写到默认存档路径,多牧场环境下会把别人的存档覆盖掉。
这两个动作单独拦截,其余全部走统一的动作表。
"""
key = name.strip().lower() if isinstance(name, str) else ""
if key == "save":
path = session.save()
session.engine.add_log(f"已存档到 {path}", "info")
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 actions_mod.run_action(session.engine, name, params)
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["ok"] = True
payload["farm"] = session.farm_id
payload["seq"] = next_seq()
payload["state"] = brief(session, hub)
payload["log"] = session.engine.recent_log(limit=3)
if full:
payload["snapshot"] = session.snapshot()
return payload
# ================================================================ 页面
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def index() -> HTMLResponse:
return HTMLResponse(DASHBOARD_HTML)
@app.get("/text", response_class=PlainTextResponse, include_in_schema=False)
async def text_index() -> PlainTextResponse:
return PlainTextResponse(dashboard_text(cfg.host, cfg.port))
# ================================================================ 元信息
@app.get("/api/health", tags=["元信息"], summary="健康检查(无需鉴权)")
async def health() -> Dict[str, Any]:
return {
"ok": True,
"service": SERVER_NAME,
"version": API_VERSION,
"uptime": round(time.time() - started_at, 1),
"farms": manager.count(),
"clients": hub.total(),
"clock_ticks": clock.ticks,
"saves_since_start": publisher.pushes,
}
@app.get("/api/help", tags=["元信息"], summary="玩法规则与接口说明(给 AI 的说明书)")
async def help_page(request: Request) -> Dict[str, Any]:
guard(request)
return {
"ok": True,
"service": SERVER_NAME,
"version": API_VERSION,
"rules": rules_text(),
"actions": actions_mod.describe_actions(),
"crops": config.CROPS,
"animals": config.ANIMALS,
"endpoints": {
"state": "GET /api/state?farm=NAME",
"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\"}",
"realtime": "WS /ws?farm=NAME",
},
}
@app.get("/api/world", tags=["世界"], summary="世界概览:排行榜 + 在线连接 + 时钟")
async def world(request: Request) -> Dict[str, Any]:
guard(request)
return {
"ok": True,
"world": cfg.world_name,
"uptime": round(time.time() - started_at, 1),
"farms": manager.count(),
"clients": hub.total(),
"connections": hub.snapshot(),
"leaderboard": manager.leaderboard(hub.online_map()),
"tick_seconds": cfg.tick_seconds,
"started_at": started_at,
}
# ================================================================ 牧场
@app.get("/api/farms", tags=["世界"], summary="牧场排行榜")
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]:
guard(request)
body = await body_of(request)
farm = validate_farm_id(body.get("farm") or body.get("name"))
owner = str(body.get("owner") or "")
existed = manager.find(farm) is not None
session = manager.get(farm, create=True, owner=owner)
tick = body.get("tick")
if tick is not None:
try:
tick_value = float(tick)
except (TypeError, ValueError):
raise GameError("bad_param", "tick 必须是数字")
session.engine.tick_seconds = max(
config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, tick_value)
)
session.engine.next_tick_at = time.time() + session.engine.tick_seconds
return {
"ok": True,
"created": not existed,
"message": ("牧场已创建" if not existed else "牧场已在运行") + f":{farm}",
"farm": farm,
"summary": session.summary(hub.online(farm)),
"state": brief(session, hub),
}
# ================================================================ 单牧场只读
@app.get("/api/state", tags=["牧场"], summary="完整状态快照")
async def get_state(request: Request) -> Dict[str, Any]:
guard(request)
session = session_of(request)
return {
"ok": True,
"farm": session.farm_id,
"state": session.snapshot(),
"online": hub.online(session.farm_id),
}
@app.get("/api/catalog", tags=["牧场"], summary="作物/牲畜目录与实时价格")
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]:
guard(request)
session = session_of(request)
items = session.engine.available_actions()
return {
"ok": True,
"farm": session.farm_id,
"count": len(items),
"actions": items,
"state": brief(session, hub),
}
@app.get("/api/log", tags=["牧场"], summary="最近事件")
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)
entries = session.engine.recent_log(limit=limit, since=since)
return {
"ok": True,
"farm": session.farm_id,
"count": len(entries),
"entries": entries,
"last_seq": entries[-1]["seq"] if entries else since,
}
# ================================================================ 写操作
@app.post("/api/actions", tags=["动作"], summary="通用动作入口(推荐 AI 使用)")
async def post_action(request: Request) -> Dict[str, Any]:
guard(request)
body = await body_of(request)
session = session_of(request)
name = body.get("action") or body.get("name")
params = body.get("params")
if params is None:
# 允许把参数平铺在顶层:{"action":"plant","plot":1,"crop":"wheat"}
params = {k: v for k, v in body.items() if k not in ("action", "name", "params")}
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]:
"""给 AI 的省流入口:一次提交多个动作,按顺序执行。
每个动作独立成败,失败的只记错误不中断后续动作。
"""
guard(request)
body = await body_of(request)
session = session_of(request)
items = body.get("actions")
if not isinstance(items, list) or not items:
raise GameError("bad_param", "actions 必须是至少含一个元素的数组")
if len(items) > 50:
raise GameError("too_many", "单次批量最多 50 个动作")
results: List[Dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
results.append({"ok": False, "error": "bad_param", "message": "每个动作必须是对象"})
continue
try:
payload = run_and_wrap(
session, item.get("action"), item.get("params") or {}, full=False
)
results.append({k: v for k, v in payload.items() if k != "state"})
except GameError as exc:
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] = {
"ok": True,
"farm": session.farm_id,
"done": ok_count,
"failed": len(results) - ok_count,
"results": results,
"state": brief(session, hub),
}
if full_requested(request):
payload["snapshot"] = session.snapshot()
return payload
def make_action_route(action_name: str) -> Callable[[Request], Any]:
async def route(request: Request) -> Dict[str, Any]:
guard(request)
body = await body_of(request)
session = session_of(request)
return run_and_wrap(session, action_name, body, full_requested(request))
route.__name__ = f"post_{action_name}"
return route
for _name, _spec in actions_mod.ACTIONS.items():
app.add_api_route(
f"/api/{_name}",
make_action_route(_name),
methods=["POST"],
name=f"action_{_name}",
tags=["动作"],
summary=f"{_spec.summary}(等价于 POST /api/actions)",
)
# ================================================================ 管理
@app.post("/api/admin/save", tags=["管理"], summary="把所有牧场写入存档")
async def admin_save(request: Request) -> Dict[str, Any]:
guard(request)
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)
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]:
guard(request)
if not cfg.allow_remote_control:
raise GameError("forbidden", "服务器未开放远程停机", status=403)
manager.shutdown()
accepted = handle.request_stop()
return {
"ok": True,
"message": "服务器正在优雅停机,存档已写入" if accepted else "停机请求已受理(重复)",
}
# ================================================================ WebSocket
@app.websocket("/ws")
async def ws_endpoint(websocket: WebSocket) -> None:
params = websocket.query_params
supplied = websocket.headers.get("x-api-token") or params.get("token")
if not authorized(supplied):
# 4401 是自定义关闭码,客户端据此提示"令牌不对"而不是"网络断了"
await websocket.close(code=4401, reason="unauthorized")
return
try:
farm = validate_farm_id(params.get("farm") or DEFAULT_FARM)
except GameError as exc:
await websocket.close(code=4400, reason=exc.message[:120])
return
await websocket.accept()
loop = asyncio.get_running_loop()
client = Client(farm, loop, name=(params.get("name") or "")[:32])
try:
session = manager.get(farm, create=True)
except GameError as exc:
# 例如牧场数量达到上限:说清楚原因再关,别让客户端以为是网络问题
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),
})
hub.broadcast(
farm,
{"type": "notice", "farm": farm,
"text": f"{client.name or client.cid} 来到了牧场(在线 {hub.online(farm)})"},
exclude=client,
)
async def writer() -> None:
while True:
message = await client.next_message()
await websocket.send_json(message)
async def reader() -> None:
while True:
raw = await websocket.receive_text()
counters["ws_messages"] += 1
await handle_ws_message(raw)
async def handle_ws_message(raw: str) -> None:
"""处理一条客户端消息。
注意:这里只做「解析 + 派发」,真正的引擎调用丢给线程池,
因此一条慢查询不会让同一个连接的心跳/推送卡住。
"""
try:
data = json.loads(raw)
except json.JSONDecodeError as 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 对象"})
return
kind = str(data.get("type") or "").lower()
rid = data.get("id")
if kind == "ping":
await websocket.send_json({"type": "pong", "id": rid, "t": time.time()})
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),
})
return
if kind == "help":
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"),
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})
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(),
})
return
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())
try:
await asyncio.wait({writer_task, reader_task}, return_when=asyncio.FIRST_COMPLETED)
except WebSocketDisconnect:
pass
except Exception:
counters["errors"] += 1
finally:
for task in (writer_task, reader_task):
task.cancel()
hub.unregister(client)
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)})"},
)
return app
def _int(raw: Optional[str], 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}")
return max(low, min(high, value))
def build_app(host: str = "127.0.0.1", port: int = 8765, **kwargs: Any) -> FastAPI:
"""给外部(测试、嵌入式调用)用的便捷工厂。"""
return create_app(ServerConfig(host=host, port=port, **kwargs))
+351
View File
@@ -0,0 +1,351 @@
"""守护进程管理:把服务器丢到后台跑,然后像服务一样 start / stop / status。
为什么不用 nohup 或 systemd
---------------------------
需求是"允许后台运行",而这个项目要能在 Windows / macOS / Linux 上都直接跑,
不能依赖某个平台的服务管理器。所以这里用最朴素也最通用的办法:
* ``subprocess.Popen`` 拉起一个**脱离父进程**的子进程(POSIX 用新会话,
Windows 用 DETACHED_PROCESS),父进程退出后它继续活着;
* 用 ``run/server.pid`` 记住它的 pid,用 ``run/server.log`` 收走它的输出;
* 停的时候先试着走 HTTP 优雅停机(能落盘),失败再发信号。
这套做法不依赖任何第三方库,也不需要管理员权限。
"""
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, List, Optional, Tuple
from .. import net
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
RUN_DIR = os.path.join(PROJECT_ROOT, "run")
DEFAULT_PID_FILE = os.path.join(RUN_DIR, "server.pid")
DEFAULT_LOG_FILE = os.path.join(RUN_DIR, "server.log")
_STILL_ACTIVE = 259
def pid_alive(pid: int) -> bool:
"""跨平台地判断一个 pid 是否还活着。
注意:Windows 上 ``os.kill(pid, 0)`` 会**真的**把进程杀掉
(CPython 用 TerminateProcess 实现),所以这里必须走 Win32 API。
"""
if pid <= 0:
return False
if os.name == "nt":
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
if not handle:
return False
try:
code = ctypes.c_ulong()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
return False
return code.value == _STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
try:
os.kill(pid, 0)
except (OSError, ProcessLookupError):
return False
return True
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:
self.host = host
self.port = port
self.token = token
self.tick = tick
self.save_dir = save_dir or os.path.join(PROJECT_ROOT, "saves")
# 防御式默认:调用方传 None 时用标准位置,绝不让 pid_file 变成 None
self.pid_file = pid_file or DEFAULT_PID_FILE
self.log_file = log_file or DEFAULT_LOG_FILE
self.python = python or sys.executable
# ------------------------------------------------------------ pid 文件
def read_pid(self) -> Optional[int]:
try:
with open(self.pid_file, "r", encoding="utf-8") as handle:
return int(handle.read().strip())
except (OSError, ValueError):
return None
def write_pid(self, pid: int) -> None:
os.makedirs(os.path.dirname(self.pid_file), exist_ok=True)
with open(self.pid_file, "w", encoding="utf-8") as handle:
handle.write(str(pid))
def clear_pid(self) -> None:
try:
os.remove(self.pid_file)
except OSError:
pass
def current(self) -> Optional[int]:
"""返回正在运行的服务器 pid;pid 文件过期则顺手清理。"""
pid = self.read_pid()
if pid is None:
return None
if pid_alive(pid):
return pid
self.clear_pid()
return None
# ------------------------------------------------------------ 探测
def probe(self, timeout: float = 1.5) -> Optional[Dict[str, Any]]:
"""访问 /api/health,成功说明服务真的在监听。
用 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走,
否则在设了 ``HTTP_PROXY`` 的机器上会得到莫名其妙的 502。
"""
url = f"http://{self.host}:{self.port}/api/health"
try:
with net.open_url(url, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError):
return None
def world(self, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
url = f"http://{self.host}:{self.port}/api/world"
if self.token:
url += "?token=" + urllib.parse.quote(self.token)
try:
with net.open_url(url, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError):
return None
def port_busy(self) -> bool:
"""端口被别的程序占了吗(用于给出"换端口"的提示)。"""
import socket
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
# ------------------------------------------------------------ 启动
def command(self) -> List[str]:
args = [
self.python, "-m", "farm.server",
"--host", self.host,
"--port", str(self.port),
"--tick", str(self.tick),
"--save-dir", self.save_dir,
# 关键:pid 文件由**服务进程自己**写。
# Windows 上 venv 的 python.exe 可能是个转发器,父进程拿到的 pid
# 未必是真正在监听端口的那个进程,写错 pid 会导致 stop 杀错人。
"--pid-file", self.pid_file,
]
if self.token:
args += ["--token", self.token]
return args
def start(self, wait: float = 12.0) -> Tuple[bool, str]:
existing = self.current()
if existing is not None:
return False, f"服务器已经在运行(pid {existing}),无需重复启动"
if self.probe() is not None:
return False, f"端口 {self.port} 上已经有一个 SimpleFarm 服务器在跑"
if self.port_busy():
return False, f"端口 {self.port} 被其它程序占用,请改用 --port 8899"
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
env = dict(os.environ)
env["PYTHONUNBUFFERED"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
env["PYTHONPATH"] = PROJECT_ROOT + os.pathsep + env.get("PYTHONPATH", "")
kwargs: Dict[str, Any] = {
"cwd": PROJECT_ROOT,
"env": env,
"stdin": subprocess.DEVNULL,
}
if os.name == "nt":
# DETACHED_PROCESS:父进程退出后子进程继续活;
# CREATE_NEW_PROCESS_GROUP:方便单独给它发 Ctrl+Break
kwargs["creationflags"] = (
subprocess.DETACHED_PROCESS
| subprocess.CREATE_NEW_PROCESS_GROUP
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
)
else:
kwargs["start_new_session"] = True
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()
# 等它真的能响应,而不是"进程起来了但端口还没监听"
deadline = time.time() + wait
while time.time() < deadline:
if self.probe() is not None:
pid = self.current() or process.pid
return True, (
f"服务器已在后台启动(pid {pid})\n"
f" 网页 http://{self.host}:{self.port}/\n"
f" 日志 {self.log_file}\n"
f" 停止 python main.py stop"
)
if process.poll() is not None and self.read_pid() is None:
return False, (
f"服务器进程启动后立刻退出(退出码 {process.returncode})。"
f"日志:{self.log_file}"
)
time.sleep(0.25)
return False, (
f"服务器进程已启动但 {wait:.0f} 秒内没响应。请查看日志:{self.log_file}"
)
# ------------------------------------------------------------ 停止
def stop(self, force: bool = False) -> Tuple[bool, str]:
pid = self.current()
online = self.probe() is not None
if pid is None and not online:
self.clear_pid()
return True, "服务器当前没有运行"
# 先请它自己优雅退出:这条路会把所有牧场写进存档
if online and self._request_shutdown() and self._wait_offline(pid, 8.0):
self.clear_pid()
return True, f"服务器已优雅停机(pid {pid or '未知'},所有牧场已存档)"
if pid is None:
return False, (
f"服务器仍在 {self.host}:{self.port} 上响应,但 pid 文件里没有记录,"
"无法确认该结束哪个进程。请手动处理,或用 docker/任务管理器结束它。"
)
try:
os.kill(pid, signal.SIGTERM)
except OSError as exc:
self.clear_pid()
return False, f"发送停止信号失败:{exc}"
if self._wait_offline(pid, 5.0):
self.clear_pid()
return True, f"服务器已停止(pid {pid})"
if force and os.name == "nt":
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],
capture_output=True, check=False)
if self._wait_offline(pid, 3.0):
self.clear_pid()
return True, f"服务器已强制结束(pid {pid})"
elif force:
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
if self._wait_offline(pid, 3.0):
self.clear_pid()
return True, f"服务器已强制结束(pid {pid})"
return False, (
f"进程 {pid} 没有在超时内退出。"
+ ("" if force else "可加 --force 强制结束。")
+ f" 日志:{self.log_file}"
)
def restart(self) -> Tuple[bool, str]:
stopped, message = self.stop()
if not stopped:
return False, message
return self.start()
def _request_shutdown(self) -> bool:
url = f"http://{self.host}:{self.port}/api/admin/shutdown"
if self.token:
url += "?token=" + urllib.parse.quote(self.token)
request = urllib.request.Request(url, data=b"{}", method="POST",
headers={"Content-Type": "application/json"})
try:
with net.open_url(request, timeout=3.0) as response:
return response.status == 200
except (urllib.error.URLError, OSError):
return False
@staticmethod
def _wait_gone(pid: int, seconds: float) -> bool:
deadline = time.time() + seconds
while time.time() < deadline:
if not pid_alive(pid):
return True
time.sleep(0.2)
return not pid_alive(pid)
def _wait_offline(self, pid: Optional[int], seconds: float) -> bool:
"""等到进程消失 **且** 端口不再响应。
两件事都要等:进程可能已经死了但端口还被 TIME_WAIT 占着,
也可能 pid 文件缺失(拿不到 pid),那就只看端口。
"""
deadline = time.time() + seconds
while time.time() < deadline:
process_gone = pid is None or not pid_alive(pid)
if process_gone and self.probe(timeout=0.6) is None:
return True
time.sleep(0.25)
process_gone = pid is None or not pid_alive(pid)
return process_gone and self.probe(timeout=0.6) is None
# ------------------------------------------------------------ 状态
def status(self) -> Dict[str, Any]:
pid = self.current()
health = self.probe()
info: Dict[str, Any] = {
"pid": pid,
"running": pid is not None or health is not None,
"host": self.host,
"port": self.port,
"log_file": self.log_file,
"pid_file": self.pid_file,
"health": health,
}
if health:
info["url"] = f"http://{self.host}:{self.port}/"
info["farms"] = health.get("farms")
info["clients"] = health.get("clients")
info["uptime"] = health.get("uptime")
return info
def tail(self, lines: int = 40) -> List[str]:
try:
with open(self.log_file, "r", encoding="utf-8", errors="replace") as handle:
return handle.read().splitlines()[-lines:]
except OSError:
return []
+250
View File
@@ -0,0 +1,250 @@
"""实时通道:WebSocket 连接池 + 状态推送线程。
为什么选 WebSocket 而不是 SSE
-----------------------------
这个游戏是**双向**的:服务器要往下推状态(作物成熟、别人来串门),
客户端也要往上推动作(播种、喂食)。SSE 只能单向下行,上行还得再开一个
HTTP 通道,等于把一条逻辑连接拆成两套鉴权、两套重连、两套错误处理。
WebSocket 一次握手就能双向跑,还能带上 ``action`` 回执,所以这里选它。
线程模型(重点)
----------------
uvicorn 跑在 asyncio 事件循环里,而游戏引擎是同步的、跑在工作线程里。
两者之间不能直接调用对方的 API,所以:
* 每个连接持有一个 :class:`asyncio.Queue` 作为**出口缓冲区**;
* 任意线程想发消息,就 ``loop.call_soon_threadsafe()`` 把消息塞进队列;
* 每条连接有自己的写协程,只负责「从队列取出 → ws.send_json」。
这样引擎线程永远不会阻塞在网络上,事件循环也永远不会被磁盘/锁卡住。
"""
from __future__ import annotations
import asyncio
import itertools
import threading
import time
from collections import defaultdict
from typing import Any, Dict, List, Optional, Set
STATE_INTERVAL = 0.25 # 状态推送的最小间隔(秒),天然把高频操作合并成一次
QUEUE_SIZE = 32 # 单连接出口缓冲区;满了就丢最旧的(状态类消息可丢)
_seq_counter = itertools.count(1)
def next_seq() -> int:
"""全局单调递增的消息序号。
客户端靠它判断"这条状态比我看过的新",从而丢弃乱序/重复的帧。
放在 hub 里是因为推送线程和 HTTP 层都要用它。
"""
return next(_seq_counter)
class Client:
"""一条 WebSocket 连接。"""
_ids = itertools.count(1)
__slots__ = ("cid", "farm_id", "loop", "queue", "name", "joined_at", "alive",
"_sent", "_dropped")
def __init__(self, farm_id: str, loop: asyncio.AbstractEventLoop, name: str = "") -> None:
self.cid = f"c{next(Client._ids)}"
self.farm_id = farm_id
self.loop = loop
self.queue: asyncio.Queue = asyncio.Queue(maxsize=QUEUE_SIZE)
self.name = name
self.joined_at = time.time()
self.alive = True
self._sent = 0
self._dropped = 0
# ------------------------------------------------------------ 线程安全投递
def push(self, message: Dict[str, Any]) -> None:
"""从任意线程把消息排队到该连接的写协程。"""
if not self.alive:
return
try:
self.loop.call_soon_threadsafe(self._enqueue, message)
except RuntimeError:
# 事件循环已关闭(客户端刚好断开)
self.alive = False
def _enqueue(self, message: Dict[str, Any]) -> None:
if self.queue.full():
# 丢最旧的一条:实时状态宁可少一帧,也不能让连接被拖死
try:
self.queue.get_nowait()
self._dropped += 1
except asyncio.QueueEmpty:
pass
self.queue.put_nowait(message)
async def next_message(self) -> Dict[str, Any]:
message = await self.queue.get()
self._sent += 1
return message
def stats(self) -> Dict[str, Any]:
return {
"client_id": self.cid,
"farm": self.farm_id,
"name": self.name,
"sent": self._sent,
"dropped": self._dropped,
"online_seconds": round(time.time() - self.joined_at, 1),
}
class ConnectionHub:
"""按牧场分组的连接登记处。"""
def __init__(self) -> None:
self._lock = threading.RLock()
self._by_farm: Dict[str, Set[Client]] = defaultdict(set)
self._all: Set[Client] = set()
# ------------------------------------------------------------ 增删
def register(self, client: Client) -> None:
with self._lock:
self._by_farm[client.farm_id].add(client)
self._all.add(client)
def unregister(self, client: Client) -> None:
with self._lock:
client.alive = False
bucket = self._by_farm.get(client.farm_id)
if bucket is not None:
bucket.discard(client)
if not bucket:
self._by_farm.pop(client.farm_id, None)
self._all.discard(client)
# ------------------------------------------------------------ 查询
def clients(self, farm_id: str) -> List[Client]:
with self._lock:
return list(self._by_farm.get(farm_id, ()))
def online(self, farm_id: str) -> int:
with self._lock:
return len(self._by_farm.get(farm_id, ()))
def online_map(self) -> Dict[str, int]:
with self._lock:
return {farm: len(clients) for farm, clients in self._by_farm.items() if clients}
def total(self) -> int:
with self._lock:
return len(self._all)
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:
sent = 0
for client in self.clients(farm_id):
if client is exclude:
continue
client.push(message)
sent += 1
return sent
def broadcast_all(self, message: Dict[str, Any], exclude: Optional[Client] = None) -> int:
with self._lock:
targets = list(self._all)
sent = 0
for client in targets:
if client is exclude:
continue
client.push(message)
sent += 1
return sent
class Publisher(threading.Thread):
"""后台推送线程:把「状态变了」这件事节流后送给在线的客户端。
引擎的 ``_notify()`` 只在 session 上打一个 dirty 标记,真正的推送由这里
按 :data:`STATE_INTERVAL` 合并发送。好处是玩家连点十次浇水也只推一帧,
而 AI 在一秒内调二十次接口也不会把网页刷爆。
"""
def __init__(self, sessions, hub: ConnectionHub, autosave_interval: float = 60.0) -> None:
super().__init__(name="farm-publisher", daemon=True)
self.sessions = sessions
self.hub = hub
self.autosave_interval = autosave_interval
self._stop = threading.Event()
self.pushes = 0
self._last_autosave = time.time()
self._started_at = time.time()
def stop(self) -> None:
self._stop.set()
def run(self) -> None:
while not self._stop.wait(STATE_INTERVAL):
now = time.time()
try:
for session in self.sessions.all():
if self.hub.online(session.farm_id) == 0:
# 没人在看就不做序列化,省 CPU
session.dirty = False
continue
if not session.dirty:
continue
session.dirty = False
self._push_state(session, now)
except Exception:
# 推送线程绝不能因为单个牧场出错而退出
pass
if now - self._last_autosave >= self.autosave_interval:
self._last_autosave = now
try:
self.sessions.autosave_all()
except Exception:
pass
def _push_state(self, session, now: float) -> None:
payload = session.state_message(seq=next_seq())
# 在线人数放在信封上而不是 state 里:它是连接属性,不是游戏状态
payload["online"] = self.hub.online(session.farm_id)
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, payload)
self.pushes += 1
def uptime(self) -> float:
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]:
"""构造给客户端的状态消息(HTTP 与 WS 共用同一份结构)。"""
message: Dict[str, Any] = {
"type": "state",
"seq": seq,
"farm": session.farm_id,
"state": session.snapshot(),
"log": session.engine.recent_log(limit=8),
"at": time.time(),
}
if note:
message["note"] = note
if extra:
message.update(extra)
return message
+309
View File
@@ -0,0 +1,309 @@
"""多牧场会话管理——服务器版的"世界"。
一个牧场 = 一个玩家 = 一份独立存档 + 一个独立时钟。
牧场之间互不干扰(各自的作物、金币、市场),但它们共享同一个服务器进程,
因此可以互相看到排行榜、串门看别人的农场。
并发模型
--------
* 每个牧场有一个 :class:`FarmEngine`,引擎内部自带 ``RLock``;
* 同一牧场的多个请求由这把锁串行化(不会出现"两个人同时种同一块地");
* 不同牧场之间完全并行,互不阻塞;
* 所有牧场的时钟由**一个** :class:`FarmClock` 线程统一驱动,
而不是每个牧场一个线程——牧场多了也不会有一堆线程在空转。
"""
from __future__ import annotations
import os
import re
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from .. import config
from ..engine import FarmEngine, GameError
from ..storage import load_state, save_state
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)
def validate_farm_id(raw: Any, default: str = DEFAULT_FARM) -> str:
"""校验牧场 ID,返回规范化后的值。"""
if raw is None or raw == "":
return default
if isinstance(raw, (int, float)):
raw = str(raw)
if not isinstance(raw, str):
raise GameError("bad_farm", "牧场 ID 必须是字符串")
fid = raw.strip()
if not _FARM_ID_RE.match(fid):
raise GameError(
"bad_farm",
f"牧场 ID {fid!r} 不合法:只允许 1~{MAX_FARM_ID} 个汉字、字母、数字、下划线或短横线",
)
return fid
def validate_owner(raw: Any) -> str:
if raw is None:
return ""
if not isinstance(raw, (str, int, float)):
raise GameError("bad_param", "owner 必须是字符串")
text = str(raw).strip()
if len(text) > 32:
raise GameError("bad_param", "owner 最长 32 个字符")
return text
class FarmSession:
"""一个独立的农场玩家。"""
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
self.path = os.path.join(save_dir, f"{farm_id}.json")
self._lock = threading.RLock()
self.engine = FarmEngine(tick_seconds=tick_seconds)
# 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写
self.dirty = True
self.published_day = 0
self.last_events: List[str] = []
self.created_at = time.time()
self.last_active = time.time()
self.saved_at = 0.0
self.actions_taken = 0
self.load_message = ""
self.engine.subscribe(self._on_change)
self.load()
# ------------------------------------------------------------ 生命周期
def _on_change(self) -> None:
"""引擎的任何状态变化都会走到这里(可能来自任意线程)。"""
self.dirty = True
def load(self) -> str:
state, message = load_state(self.path)
if state is not None:
self.engine.state = state
self.load_message = message
self.engine.add_log(f"服务器载入存档:第 {state.day} 天继续经营", "info")
else:
self.engine.add_log(f"新的牧场「{self.farm_id}」开张,欢迎光临!", "info")
self.load_message = f"{message},已开新档"
self.published_day = self.engine.state.day
self.dirty = True
return self.load_message
def save(self) -> str:
with self._lock:
path = save_state(self.engine.state, self.path)
self.saved_at = time.time()
return path
def autosave(self) -> bool:
"""只在有变化时落盘,返回是否真的写了。"""
if not self.dirty or self.save_dir == "":
return False
if time.time() - self.saved_at < 5:
return False
try:
self.save()
return True
except OSError:
return False
def shutdown(self) -> None:
try:
self.save()
except OSError:
pass
def touch(self, count_action: bool = False) -> None:
self.last_active = time.time()
if count_action:
self.actions_taken += 1
self.dirty = True
def advance_day(self, reason: str = "时间流逝") -> List[str]:
with self._lock:
events = self.engine.advance_day(reason=reason)
self.last_events = events
self.dirty = True
return events
def reset(self) -> None:
with self._lock:
self.engine.reset()
self.touch(count_action=True)
# ------------------------------------------------------------ 查询
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]:
"""给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。"""
snap = self.engine.snapshot()
return {
"farm": self.farm_id,
"owner": self.owner,
"day": snap["day"],
"gold": snap["gold"],
"level": snap["level"],
"net_worth": snap["net_worth"],
"plots": f"{snap['plot_count']}/{snap['max_plots']}",
"animals": snap["animal_count"],
"goal_index": snap["goal_index"],
"paused": snap["paused"],
"online": online,
"actions": self.actions_taken,
"idle_seconds": round(time.time() - self.last_active, 1),
}
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)
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:
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] = {}
os.makedirs(save_dir, exist_ok=True)
# ------------------------------------------------------------ 取用
def get(self, farm_id: str, create: bool = True, owner: str = "") -> FarmSession:
farm_id = validate_farm_id(farm_id)
with self._lock:
session = self._sessions.get(farm_id)
if session is not None:
return session
if not create:
raise GameError("no_such_farm", f"牧场「{farm_id}」不存在", status=404)
if len(self._sessions) >= self.max_sessions:
raise GameError(
"too_many_farms",
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]:
with self._lock:
return self._sessions.get(validate_farm_id(farm_id))
def all(self) -> List[FarmSession]:
with self._lock:
return list(self._sessions.values())
def ids(self) -> List[str]:
with self._lock:
return sorted(self._sessions)
def count(self) -> int:
with self._lock:
return len(self._sessions)
# ------------------------------------------------------------ 世界视图
def leaderboard(self, online_map: Optional[Dict[str, int]] = 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"]))
for rank, row in enumerate(rows, start=1):
row["rank"] = rank
return rows
# ------------------------------------------------------------ 维护
def autosave_all(self) -> int:
saved = 0
for session in self.all():
if session.autosave():
saved += 1
return saved
def reap(self, online_map: Optional[Dict[str, int]] = None) -> List[str]:
"""回收长时间无人访问的牧场:先落盘,再从内存里移除。"""
online_map = online_map or {}
now = time.time()
dropped: List[str] = []
for session in self.all():
if online_map.get(session.farm_id, 0) > 0:
continue
if session.engine.paused:
continue # 暂停是人为操作,说明玩家还会回来
if now - session.last_active < self.idle_timeout:
continue
try:
session.shutdown()
except OSError:
continue # 存不下就别丢,宁可占着内存
with self._lock:
self._sessions.pop(session.farm_id, None)
dropped.append(session.farm_id)
return dropped
def shutdown(self) -> None:
for session in self.all():
session.shutdown()
class FarmClock(threading.Thread):
"""统一驱动所有牧场的游戏时间。"""
def __init__(self, manager: SessionManager, accuracy: float = 0.2) -> None:
super().__init__(name="farm-clock", daemon=True)
self.manager = manager
self.accuracy = accuracy
self._stop = threading.Event()
self.ticks = 0
def stop(self) -> None:
self._stop.set()
def run(self) -> None:
while not self._stop.wait(self.accuracy):
now = time.time()
for session in self.manager.all():
engine = session.engine
try:
if engine.paused:
# 暂停时把下一次推进顺延,恢复后仍有完整的一个 tick
engine.next_tick_at = now + engine.tick_seconds
continue
deadline = engine.next_tick_at
if deadline is None:
engine.next_tick_at = now + engine.tick_seconds
continue
if now >= deadline:
session.advance_day("时间流逝")
engine.next_tick_at = now + engine.tick_seconds
self.ticks += 1
except Exception:
# 单个牧场出错不影响其它牧场的时间
engine.next_tick_at = now + engine.tick_seconds
def stats(self) -> Tuple[int, float]:
return self.ticks, self.accuracy
+455
View File
@@ -0,0 +1,455 @@
"""内嵌的网页客户端。
为什么要内嵌而不是写一堆静态文件
--------------------------------
服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 WebSocket
协议的一份"活文档":想接自己的客户端时,照着这里的 JS 抄就行。
注意:这个字符串里既有 CSS 又有 JS,花括号很多,所以**不要**用 ``f-string``
渲染它(为此把需要拼接的部分放在 :func:`dashboard_text` 里)。
"""
from __future__ import annotations
DASHBOARD_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SimpleFarm 服务器</title>
<style>
:root {
--bg: #f4f7f2; --card: #ffffff; --line: #dde5d8; --ink: #23301f;
--dim: #6b7a66; --green: #3f8f4f; --red: #c0392b; --gold: #b8860b;
--blue: #2f6fb0; --cyan: #2b8a8a;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 14px/1.6 "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
}
header {
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
padding: 14px 20px; background: var(--card); border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 5;
}
header h1 { font-size: 18px; margin: 0; }
header h1 span { font-size: 12px; color: var(--dim); font-weight: normal; margin-left: 6px; }
.spacer { flex: 1; }
input, select, button {
font: inherit; padding: 6px 10px; border: 1px solid var(--line);
border-radius: 8px; background: #fff; color: var(--ink);
}
button { cursor: pointer; transition: .15s; }
button:hover { border-color: var(--green); color: var(--green); }
button.primary { background: var(--green); border-color: var(--green); color: #fff; }
button.primary:hover { background: #357a43; color: #fff; }
button:disabled { opacity: .45; cursor: not-allowed; }
.dot { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dim); }
.dot i { width: 9px; height: 9px; border-radius: 50%; background: #c9d2c4; display: block; }
.dot.on i { background: var(--green); box-shadow: 0 0 0 3px rgba(63,143,79,.18); }
.dot.bad i { background: var(--red); }
main { padding: 16px 20px 40px; max-width: 1180px; margin: 0 auto; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 10px; }
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; }
.stat b { display: block; font-size: 18px; }
.stat small { color: var(--dim); }
.cols { display: grid; grid-template-columns: 1.35fr 1fr; gap: 14px; margin-top: 14px; }
@media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; margin-bottom: 14px; }
.card h2 { font-size: 14px; margin: 0 0 10px; display: flex; align-items: center; gap: 8px; }
.card h2 em { font-style: normal; color: var(--dim); font-size: 12px; font-weight: normal; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(152px, 1fr)); gap: 10px; }
.plot { border: 1px solid var(--line); border-radius: 10px; padding: 9px 10px; background: #fbfdfa; }
.plot.ripe { border-color: var(--green); background: #f1faf2; }
.plot.dry { border-color: var(--red); background: #fdf3f2; }
.plot .top { display: flex; justify-content: space-between; font-weight: 600; }
.plot .meta { color: var(--dim); font-size: 12px; }
.bar { height: 6px; border-radius: 4px; background: #e7ede4; overflow: hidden; margin: 5px 0; }
.bar i { display: block; height: 100%; background: var(--green); }
.water i { background: var(--blue); }
.plot .btns { display: flex; gap: 6px; margin-top: 7px; }
.plot .btns button { flex: 1; padding: 3px 0; font-size: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 4px 6px; border-bottom: 1px solid var(--line); }
th { color: var(--dim); font-weight: normal; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.up { color: var(--red); } .down { color: var(--green); } .flat { color: var(--dim); }
.log { max-height: 190px; overflow: auto; font-size: 13px; }
.log div { padding: 1px 0; }
.log .d { color: var(--dim); margin-right: 6px; font-variant-numeric: tabular-nums; }
.k-good { color: var(--green); } .k-bad { color: var(--red); }
.k-warn { color: var(--gold); } .k-action { color: var(--cyan); }
.notice { font-size: 12px; color: var(--dim); min-height: 18px; }
.toast {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
background: #23301f; color: #fff; padding: 9px 18px; border-radius: 20px;
font-size: 13px; opacity: 0; transition: .25s; pointer-events: none; z-index: 20;
}
.toast.show { opacity: .93; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.animals li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dashed var(--line); list-style: none; }
ul { padding: 0; margin: 0; }
</style>
</head>
<body>
<header>
<h1>SimpleFarm <span>小型农场游戏服务器</span></h1>
<div class="dot" id="status"><i></i><span id="statustext">未连接</span></div>
<div class="spacer"></div>
<input id="farm" size="10" placeholder="牧场名" />
<input id="token" size="10" placeholder="令牌(可选)" />
<button class="primary" id="join">进入牧场</button>
</header>
<main>
<div class="stats" id="stats"></div>
<div class="cols">
<div>
<div class="card">
<h2>农田 <em id="farmhint"></em></h2>
<div class="grid" id="plots"></div>
</div>
<div class="card">
<h2>牧场 <em id="animalhint"></em></h2>
<ul class="animals" id="animals"></ul>
<div class="row" style="margin-top:8px">
<select id="buyanimal">
<option value="chicken">鸡 90</option>
<option value="sheep">羊 220</option>
<option value="cow">牛 480</option>
</select>
<button data-act="buy_animal">买牲畜</button>
</div>
</div>
</div>
<div>
<div class="card">
<h2>快捷动作</h2>
<div class="row">
<button class="primary" data-act="harvest_all">一键收获</button>
<button data-act="buy_plot">买地</button>
<button data-act="buy_feed">买饲料x10</button>
<button data-act="sleep">休息一天</button>
<button data-act="pause">暂停/继续</button>
<button data-act="save">存档</button>
<button data-act="reset">重置</button>
</div>
<div class="notice" id="notice"></div>
</div>
<div class="card">
<h2>市场行情 <em>红涨绿跌</em></h2>
<table id="market"></table>
</div>
<div class="card">
<h2>服务器排行榜 <em id="worldinfo"></em></h2>
<table id="board"></table>
</div>
<div class="card">
<h2>农事日志</h2>
<div class="log" id="log"></div>
</div>
</div>
</div>
</main>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
let ws = null, state = null, catalog = null, pollTimer = null, reconnectTimer = null, pending = 0;
const cropNames = {
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
corn: "玉米", pumpkin: "南瓜", strawberry: "草莓"
};
function toast(text) {
const el = $("toast");
el.textContent = text;
el.classList.add("show");
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove("show"), 2200);
}
function setStatus(text, cls) {
$("statustext").textContent = text;
$("status").className = "dot " + (cls || "");
}
function farmName() { return ($("farm").value || "default").trim() || "default"; }
function token() { return ($("token").value || "").trim(); }
function headers() {
const h = { "Content-Type": "application/json", "X-Farm-Id": farmName() };
if (token()) h["X-API-Token"] = token();
return h;
}
async function api(path, options) {
const res = await fetch(path, options);
let data = null;
try { data = await res.json(); } catch (e) { data = { ok: false, message: "响应不是 JSON" }; }
if (!res.ok || data.ok === false) {
toast(data.message || ("请求失败 " + res.status));
return null;
}
return data;
}
async function act(action, params) {
const data = await api("/api/actions" + (token() ? "?token=" + encodeURIComponent(token()) : ""), {
method: "POST", headers: headers(),
body: JSON.stringify({ action: action, params: params || {} })
});
if (data) {
toast(data.message);
if (data.state) refreshFromBrief(data.state);
}
}
/* 动作响应里只有精简状态,这里把它合并进完整状态,避免多查一次 */
function refreshFromBrief(brief) {
if (!state) { syncNow(); return; }
Object.assign(state, brief);
render();
}
function syncNow() {
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify({ type: "sync" }));
return;
}
api("/api/state?farm=" + encodeURIComponent(farmName()) +
(token() ? "&token=" + encodeURIComponent(token()) : ""), { headers: headers() })
.then((d) => { if (d) { state = d.state; render(); } });
}
function connect() {
if (ws) { try { ws.close(); } catch (e) {} }
clearTimeout(reconnectTimer);
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = proto + "//" + location.host + "/ws?farm=" + encodeURIComponent(farmName()) +
(token() ? "&token=" + encodeURIComponent(token()) : "") + "&name=web";
setStatus("连接中…", "");
ws = new WebSocket(url);
ws.onopen = () => {
setStatus("已连接(WebSocket)", "on");
clearInterval(pollTimer); pollTimer = null;
api("/api/farms", { headers: headers() });
};
ws.onmessage = (ev) => {
let msg = null;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
handle(msg);
};
ws.onclose = (ev) => {
if (ev.code === 4401) { setStatus("令牌不正确", "bad"); }
else setStatus("已断开,3 秒后重连", "bad");
if (!pollTimer) pollTimer = setInterval(syncNow, 1500); // 降级为轮询
reconnectTimer = setTimeout(connect, 3000);
};
ws.onerror = () => setStatus("连接异常", "bad");
}
function handle(msg) {
if (msg.type === "welcome") {
state = msg.state; catalog = msg.catalog;
$("farmhint").textContent = "共 " + state.plot_count + "/" + state.max_plots + " 块";
render();
} else if (msg.type === "state" || msg.type === "sync") {
state = msg.state; if (msg.catalog) catalog = msg.catalog;
renderLog(msg.log);
render();
} else if (msg.type === "result") {
toast(msg.message);
if (msg.snapshot) { state = msg.snapshot; render(); }
else if (msg.state) { refreshFromBrief(msg.state); }
renderLog(msg.log);
} else if (msg.type === "day") {
renderLog(null);
} else if (msg.type === "notice") {
$("notice").textContent = msg.text;
} else if (msg.type === "error") {
toast(msg.message || "服务器返回错误");
}
}
function render() {
if (!state) return;
const s = state;
$("stats").innerHTML = [
["第几天", s.day], ["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed], ["净资产", Math.round(s.net_worth)],
["在线", s.online !== undefined ? s.online : "—"],
["下一天", s.seconds_to_tick === null ? "已暂停" : s.seconds_to_tick + "s"]
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join("");
/* 农田 */
$("plots").innerHTML = s.plots.map((p) => {
const ratio = p.grow_days ? Math.round(p.growth / p.grow_days * 100) : 0;
const water = Math.round(p.moisture / (p.water_capacity || 3) * 100);
const cls = p.is_ripe ? "ripe" : (p.crop && p.moisture === 0 ? "dry" : "");
const title = p.is_empty ? (p.position + " 号地 · 空地")
: (p.position + " 号地 · " + (cropNames[p.crop] || p.crop) + " · " + p.stage);
const meta = p.is_empty ? "空闲" : (p.is_ripe ? "已成熟,可收获" : "还需 " + p.days_left + " 天");
let btns = "";
if (p.is_ripe) btns = '<button data-h="' + p.position + '">收获</button>';
else if (p.is_empty) btns = '<button data-p="' + p.position + '">播种</button>';
else if (p.moisture < p.water_capacity) btns = '<button data-w="' + p.position + '">浇水</button>';
return '<div class="plot ' + cls + '"><div class="top"><span>' + title +
'</span></div><div class="meta">' + meta + '</div>' +
'<div class="bar"><i style="width:' + ratio + '%"></i></div>' +
'<div class="bar water"><i style="width:' + water + '%"></i></div>' +
'<div class="btns">' + btns + '</div></div>';
}).join("");
/* 牧场 */
$("animalhint").textContent = s.animal_count + "/" + s.max_animals + " 只";
$("animals").innerHTML = s.animals.length ? s.animals.map((a) =>
'<li><b>#' + a.id + ' ' + a.name + '</b><span class="meta">' + (
a.pending > 0 ? '<span class="up">' + a.product + ' x' + a.pending + ' 待收</span>'
: a.is_sick ? '<span class="down">虚弱,急需喂食</span>'
: a.fed ? '已喂食' : '<span class="k-warn">今天还没喂</span>') +
'</span><span class="spacer"></span>' +
(a.fed ? '' : '<button data-f="' + a.id + '">喂食</button>') +
(a.pending > 0 ? '<button data-c="' + a.id + '">收取</button>' : '') + '</li>'
).join("") : '<li style="color:#6b7a66">牧场空空的,先买一只吧</li>';
/* 市场 */
const rows = Object.entries(s.market).filter((kv) => kv[0].indexOf("animal:") !== 0);
const base = { wheat: 14, carrot: 26, tomato: 52, corn: 95, pumpkin: 180, strawberry: 320 };
$("market").innerHTML = '<tr><th>作物</th><th class="num">现价</th><th class="num">基准</th></tr>' +
rows.sort((a, b) => b[1] - a[1]).map((kv) => {
const b = base[kv[0]] || kv[1];
const cls = kv[1] > b * 1.03 ? "up" : (kv[1] < b * 0.97 ? "down" : "flat");
const arrow = cls === "up" ? "↑" : (cls === "down" ? "↓" : "→");
return '<tr><td>' + (cropNames[kv[0]] || kv[0]) + '</td><td class="num ' + cls + '">' +
kv[1].toFixed(0) + " " + arrow + '</td><td class="num">' + b + '</td></tr>';
}).join("");
renderLog(null);
renderBoard();
}
function renderLog(entries) {
if (entries) entries.forEach((e) => logAppend(e));
else if (!logAppend._cache) {
api("/api/log?limit=12&farm=" + encodeURIComponent(farmName()), { headers: headers() })
.then((d) => { if (d) d.entries.forEach(logAppend); });
}
}
function logAppend(e) {
if (logAppend._last && e.seq <= logAppend._last) return;
logAppend._last = e.seq;
logAppend._cache = true;
const box = $("log");
const div = document.createElement("div");
div.className = "k-" + e.kind;
div.innerHTML = '<span class="d">D' + e.day + '</span>' + e.text;
box.insertBefore(div, box.firstChild);
while (box.childNodes.length > 60) box.removeChild(box.lastChild);
}
async function renderBoard() {
const d = await api("/api/world", { headers: headers() });
if (!d) return;
$("worldinfo").textContent = d.farms + " 个牧场 · " + d.clients + " 个连接 · 运行 " +
Math.round(d.uptime) + " 秒";
$("board").innerHTML = '<tr><th>#</th><th>牧场</th><th class="num">净资产</th>' +
'<th class="num">天</th><th class="num">在线</th></tr>' +
d.leaderboard.slice(0, 10).map((r) =>
'<tr><td>' + r.rank + '</td><td>' + r.farm + '</td><td class="num">' +
Math.round(r.net_worth) + '</td><td class="num">' + r.day + '</td><td class="num">' +
r.online + '</td></tr>').join("");
}
/* ---- 事件委托:地块按钮 / 牲畜按钮 / 快捷动作 ---- */
document.addEventListener("click", (ev) => {
const t = ev.target;
if (t.tagName !== "BUTTON") return;
if (t.dataset.w) act("water", { plot: +t.dataset.w });
else if (t.dataset.h) act("harvest", { plot: +t.dataset.h });
else if (t.dataset.p) {
const plot = +t.dataset.p;
const pool = state ? Object.keys(cropNames).filter((k) => {
const c = (catalog || { crops: [] }).crops.find((x) => x.key === k);
return !c || !c.locked;
}) : [];
const pick = window.prompt("要种什么?输入编号:\n" +
pool.map((k, i) => (i + 1) + ". " + cropNames[k]).join("\n"), "1");
const idx = parseInt(pick, 10);
if (!idx || idx < 1 || idx > pool.length) return;
act("plant", { plot: plot, crop: pool[idx - 1] });
}
else if (t.dataset.f) act("feed", { animal_id: +t.dataset.f });
else if (t.dataset.c) act("collect", { animal_id: +t.dataset.c });
else if (t.dataset.act === "buy_animal") act("buy_animal", { kind: $("buyanimal").value });
else if (t.dataset.act === "buy_feed") act("buy_feed", { quantity: 10 });
else if (t.dataset.act === "reset") { if (confirm("确定重置牧场?所有进度都会清空")) act("reset"); }
else if (t.dataset.act) act(t.dataset.act);
});
$("join").addEventListener("click", () => {
localStorage.setItem("sf.farm", farmName());
localStorage.setItem("sf.token", token());
fetch("/api/farms", {
method: "POST", headers: headers(),
body: JSON.stringify({ farm: farmName() })
}).then((r) => r.json()).then((d) => { toast(d.message || ""); });
connect();
});
document.addEventListener("keydown", (e) => { if (e.key === "Enter") $("join").click(); });
/* ---- 启动 ---- */
$("farm").value = localStorage.getItem("sf.farm") || "default";
$("token").value = localStorage.getItem("sf.token") || "";
connect();
setInterval(renderBoard, 5000);
setInterval(() => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" })); }, 20000);
</script>
</body>
</html>
"""
def dashboard_text(host: str, port: int) -> str:
"""给终端/curl 看的纯文本索引。"""
return (
"SimpleFarm 游戏服务器\n"
"======================\n"
f"网页客户端:http://{host}:{port}/\n"
f"接口文档 :http://{host}:{port}/docs\n"
f"健康检查 :http://{host}:{port}/api/health\n"
f"实时通道 :ws://{host}:{port}/ws?farm=<牧场名>\n"
"\n"
"给 AI / 脚本的最短路径\n"
"----------------------\n"
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"
"\n"
"读取类\n"
"------\n"
"GET /api/world 世界概览 + 排行榜\n"
"GET /api/farms 牧场列表\n"
"GET /api/state 完整状态\n"
"GET /api/catalog 作物/牲畜/价格\n"
"GET /api/actions 当前合法动作(AI 推荐入口)\n"
"GET /api/log 最近事件\n"
"GET /api/help 规则 + 动作清单 + 参数说明\n"
"\n"
"写入类\n"
"------\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/admin/save 全部存档\n"
"POST /api/admin/shutdown 优雅停机\n"
)
+129
View File
@@ -0,0 +1,129 @@
"""存档读写。
存档是纯 JSON,方便人工检查,也方便外部工具/AI 直接读取分析。
写入采用"临时文件 + 原子替换",避免中途崩溃导致存档损坏。
"""
from __future__ import annotations
import json
import os
import time
from typing import Any, Dict, Optional, Tuple
from .models import GameState
SAVE_VERSION = 1
def default_save_path() -> str:
"""默认存档位置:项目根目录下的 saves/save.json。"""
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(root, "saves", "save.json")
def save_state(state: GameState, path: Optional[str] = None) -> str:
"""把状态写入磁盘,返回实际写入路径。"""
path = path or default_save_path()
directory = os.path.dirname(os.path.abspath(path))
os.makedirs(directory, exist_ok=True)
payload = {
"version": SAVE_VERSION,
"saved_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"state": state.to_dict(),
}
# 注意:to_dict() 里的派生字段在读取时会被忽略,这里只取原始字段
payload["state"] = _raw_state(state)
tmp_path = path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, path)
return path
def _raw_state(state: GameState) -> Dict[str, Any]:
"""导出可完整还原的原始字段(与 to_dict 的展示字段不同)。"""
return {
"day": state.day,
"gold": state.gold,
"level": state.level,
"exp": state.exp,
"ap": state.ap,
"feed": state.feed,
"plots": [
{
"index": p.index,
"crop": p.crop,
"growth": p.growth,
"moisture": p.moisture,
"planted_day": p.planted_day,
}
for p in state.plots
],
"animals": [
{
"id": a.aid,
"kind": a.kind,
"fed": a.fed,
"progress": a.progress,
"starve": a.starve,
"pending": a.pending,
}
for a in state.animals
],
"market": state.market,
"stats": {
"harvested": state.total_harvested,
"collected": state.total_collected,
"earned": state.total_earned,
"spent": state.total_spent,
},
}
def load_state(path: Optional[str] = None) -> Tuple[Optional[GameState], str]:
"""读取存档。
返回 ``(state, message)``;失败时 ``state`` 为 None 并给出原因,
调用方应当降级为"开新档"而不是崩溃。
"""
path = path or default_save_path()
if not os.path.exists(path):
return None, f"未找到存档 {path}"
try:
with open(path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
return None, f"存档无法解析:{exc}"
if not isinstance(payload, dict):
return None, "存档格式错误:根节点不是对象"
version = payload.get("version", 0)
if version != SAVE_VERSION:
return None, f"存档版本不兼容(文件 v{version},当前 v{SAVE_VERSION})"
raw = payload.get("state")
if not isinstance(raw, dict):
return None, "存档缺少 state 字段"
try:
state = GameState.from_dict(raw)
except (TypeError, ValueError, KeyError) as exc:
return None, f"存档内容异常:{exc}"
return state, f"已载入存档(第 {state.day} 天)"
def delete_save(path: Optional[str] = None) -> bool:
path = path or default_save_path()
try:
os.remove(path)
return True
except OSError:
return False
+784
View File
@@ -0,0 +1,784 @@
"""终端用户界面(TUI)。
渲染策略
--------
不做整屏清空,而是把光标归位后逐行重写并用 ``\\x1b[K`` 清理行尾,
这样刷新时几乎没有闪烁。
宽度处理
--------
中文是双宽字符,所有对齐都基于 :func:`display_width` 计算,
保证边框在 Windows Terminal / iTerm / VSCode 终端下都不歪。
"""
from __future__ import annotations
import os
import re
import shutil
import sys
import time
import unicodedata
from typing import Any, Dict, List, Optional, Tuple
from . import config
from .engine import GameError
from .keys import KeyReader
from .storage import default_save_path
# ---------------------------------------------------------------- 颜色
C_RESET = "\x1b[0m"
C_DIM = "\x1b[38;5;242m"
C_GRAY = "\x1b[38;5;246m"
C_WHITE = "\x1b[38;5;253m"
C_GOLD = "\x1b[38;5;221m"
C_GREEN = "\x1b[38;5;114m"
C_RED = "\x1b[38;5;203m"
C_BLUE = "\x1b[38;5;111m"
C_CYAN = "\x1b[38;5;80m"
C_ORANGE = "\x1b[38;5;215m"
C_PURPLE = "\x1b[38;5;176m"
C_BOLD = "\x1b[1m"
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
CELL_WIDTH = 17 # 单块农田格子的显示宽度
CELL_INNER = CELL_WIDTH - 2
SIDE_WIDTH = 36 # 右侧栏宽度
MIN_TERM_WIDTH = 76
MAX_VISIBLE_ANIMALS = 5 # 侧栏同时最多展示的牲畜数量
def strip_ansi(text: str) -> str:
return ANSI_RE.sub("", text)
def display_width(text: str) -> int:
"""计算字符串在终端中占用的列数(中文算 2 列)。"""
width = 0
for char in strip_ansi(text):
if unicodedata.combining(char):
continue
width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
return width
def pad(text: str, width: int, align: str = "left", fill: str = " ") -> str:
"""按显示宽度补空格。``align`` 可为 left / right / center。
内容比 ``width`` 还宽时就地截断,保证任何一行都不会挤破边框。
"""
gap = width - display_width(text)
if gap < 0:
text = truncate(text, width)
gap = max(0, width - display_width(text))
if align == "right":
return fill * gap + text
if align == "center":
left = gap // 2
return fill * left + text + fill * (gap - left)
return text + fill * gap
def truncate(text: str, width: int, ellipsis: str = "…") -> str:
"""按显示宽度截断(不破坏 ANSI 色彩)。"""
if display_width(text) <= width:
return text
out = []
used = 0
limit = max(0, width - 1)
for char in strip_ansi(text):
char_width = 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
if used + char_width > limit:
break
out.append(char)
used += char_width
return "".join(out) + ellipsis
def color(text: str, code: str, enabled: bool = True) -> str:
return f"{code}{text}{C_RESET}" if enabled else text
def bar(filled: int, total: int, width: int, on: str = "█", off: str = "░") -> str:
"""生成进度条字符串(不含颜色)。"""
if total <= 0:
total = 1
filled = max(0, min(width, int(round(width * filled / total))))
return on * filled + off * (width - filled)
# ---------------------------------------------------------------- 终端控制
def enable_ansi() -> bool:
"""在 Windows 上开启 VT 转义序列与 UTF-8 输出,返回是否成功。"""
if os.name != "nt":
return True
try:
import ctypes
kernel32 = ctypes.windll.kernel32
# 输出代码页切到 UTF-8,避免中文/制表符乱码
kernel32.SetConsoleOutputCP(65001)
handle = kernel32.GetStdHandle(-11)
mode = ctypes.c_uint32()
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
return True
except Exception:
return False
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]:
"""画一个带标题的方框,返回行列表(宽度精确等于 ``width``)。"""
inner = max(0, width - 2)
head = f" {title} "
if use_color and accent:
head = color(head, accent)
fill_len = max(0, inner - 1 - display_width(head))
top = "┌─" + head + "─" * fill_len + "┐"
bottom = "└" + "─" * inner + "┘"
lines = [top]
for row in body:
lines.append("│" + pad(truncate(row, inner), inner) + "│")
lines.append(bottom)
return lines
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)
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:
# engine 既可以是本地 FarmEngine,也可以是远程 RemoteFarm——只要接口一致
self.engine = engine
self.save_path = save_path or default_save_path()
self.use_color = use_color
self.autosave = autosave
self.server_label = server_label
self.reader = KeyReader()
self.running = True
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_title = ""
self.pending_action = ""
self.message = ""
self.message_color = C_WHITE
self.message_until = 0.0
self._last_render = 0.0
# ================================================================ 生命周期
def _enter(self) -> None:
if enable_ansi() and os.name == "nt":
try:
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
self.reader.close()
sys.stdout.write(C_RESET + "\x1b[?25h\n")
sys.stdout.flush()
def run(self) -> None:
self._enter()
try:
while self.running:
key = self.reader.poll()
if key:
try:
self._handle_key(key)
except GameError as exc:
self._flash(exc.message, C_RED)
except Exception as exc: # 任何意外都不能让界面崩溃
self._flash(f"内部错误:{exc}", C_RED)
now = time.time()
if now - self._last_render >= 0.1:
self._render()
self._last_render = now
time.sleep(0.03)
except KeyboardInterrupt:
pass
finally:
self._exit()
# ================================================================ 提示
def _flash(self, text: str, tone: str = C_GREEN, seconds: float = 4.0) -> None:
self.message = text
self.message_color = tone
self.message_until = time.time() + seconds
def _act(self, func, *args) -> None:
"""统一执行引擎动作并显示结果。"""
try:
result = func(*args)
self._flash(result.get("message", "完成"), C_GREEN)
except GameError as exc:
self._flash(exc.message, C_RED)
# ================================================================ 输入
def _handle_key(self, key: str) -> None:
if key in ("CTRL_C",):
self.running = False
return
if self.mode == "help":
self.mode = "main"
return
if self.mode == "confirm_reset":
if key == "y":
self.engine.reset()
self.plot_cursor = 0
self.animal_cursor = 0
self._flash("农场已重置,重新开始吧", C_GOLD)
else:
self._flash("已取消重置", C_GRAY)
self.mode = "main"
return
if self.mode in ("select_crop", "select_animal"):
self._handle_menu_key(key)
return
# ---- 主模式
handler = {
"q": self._quit,
"?": self._open_help,
"up": lambda: self._move_plot(-self.grid_cols),
"down": lambda: self._move_plot(self.grid_cols),
"left": lambda: self._move_plot(-1),
"right": lambda: self._move_plot(1),
"w": lambda: self._act(self.engine.water, self.plot_cursor + 1),
"h": lambda: self._act(self.engine.harvest, self.plot_cursor + 1),
"e": self._harvest_all,
"p": self._open_crop_menu,
"b": self._open_animal_menu,
"f": self._feed,
"c": self._collect,
"l": lambda: self._act(self.engine.buy_plot),
"k": lambda: self._act(self.engine.buy_feed, config.FEED_BULK),
"n": lambda: self._act(self.engine.sleep),
"s": self._save,
"r": self._ask_reset,
" ": self._toggle_pause,
"tab": self._toggle_focus,
"tab\t": self._toggle_focus,
}
action = handler.get(key)
if action:
action()
def _quit(self) -> None:
self.running = False
def _open_help(self) -> None:
self.mode = "help"
def _move_plot(self, delta: int) -> None:
count = len(self.engine.state.plots)
if count == 0:
return
self.plot_cursor = max(0, min(count - 1, self.plot_cursor + delta))
def _toggle_pause(self) -> None:
result = self.engine.pause()
self._flash("时间已暂停" if result["paused"] else "时间恢复流动", C_ORANGE)
def _toggle_focus(self) -> None:
animals = self.engine.state.animals
if not animals:
self._flash("牧场里还没有动物", C_GRAY)
return
self.animal_cursor = (self.animal_cursor + 1) % len(animals)
def _harvest_all(self) -> None:
"""一键收获所有成熟作物。"""
engine = self.engine
targets = [p.index + 1 for p in engine.state.plots if p.is_ripe]
if not targets:
self._flash("暂时没有成熟的作物", C_GRAY)
return
total = 0
count = 0
for number in targets:
try:
result = engine.harvest(number)
total += result["earned"]
count += result["quantity"]
except GameError:
continue
self._flash(f"收获 {count} 份作物,共 +{total} 金币", C_GREEN)
# ---------------------------------------------------------- 菜单交互
def _open_crop_menu(self) -> None:
plot = self.engine.state.plots[self.plot_cursor] if self.engine.state.plots else None
if plot is None:
return
if plot.crop is not None:
self._flash(f"{plot.index + 1} 号地已种了作物,先收获再说", C_RED)
return
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, {})
price = entry.get("price", info["base_price"])
profit = price - info["seed_cost"]
items.append(
{
"key": key,
"label": info["name"],
"cost": info["seed_cost"],
"detail": (
f"种子{info['seed_cost']:>3} {info['grow_days']:>2}天 "
f"售价{price:>5.0f} 净赚 {profit:+.0f}"
),
"locked": self.engine.state.level < info["level_req"],
"lock_hint": f"需 Lv.{info['level_req']}",
"affordable": self.engine.state.gold >= info["seed_cost"],
}
)
self.menu = items
self.menu_title = f"选择要种在 {plot.index + 1} 号地的作物"
self.mode = "select_crop"
def _open_animal_menu(self) -> None:
if len(self.engine.state.animals) >= config.MAX_ANIMALS:
self._flash("牧场已满,无法再养", C_RED)
return
catalog = {a["key"]: a for a in self.engine.catalog()["animals"]}
items = []
for key, info in config.ANIMALS.items():
entry = catalog.get(key, {})
items.append(
{
"key": key,
"label": info["name"],
"cost": info["cost"],
"detail": (
f"{info['cost']:>4}金币 产{info['product']}"
f" 每{info['cycle']}天 日净收益{entry.get('profit_per_day', 0):.0f}"
),
"locked": self.engine.state.level < info["level_req"],
"lock_hint": f"需 Lv.{info['level_req']}",
"affordable": self.engine.state.gold >= info["cost"],
}
)
self.menu = items
self.menu_title = "选择要购买的动物"
self.mode = "select_animal"
def _handle_menu_key(self, key: str) -> None:
if key in ("esc", "q"):
self.mode = "main"
self._flash("已取消", C_GRAY)
return
if not key.isdigit():
return
index = int(key)
if index == 0 or index > len(self.menu):
self._flash("没有这个选项", C_RED)
return
item = self.menu[index - 1]
if item["locked"]:
self._flash(f"尚未解锁({item['lock_hint']})", C_RED)
return
if self.mode == "select_crop":
self._act(self.engine.plant, self.plot_cursor + 1, item["key"])
else:
self._act(self.engine.buy_animal, item["key"])
self.mode = "main"
def _feed(self) -> None:
animals = self.engine.state.animals
if not animals:
self._flash("牧场里还没有动物,按 B 购买", C_GRAY)
return
self.animal_cursor = min(self.animal_cursor, len(animals) - 1)
self._act(self.engine.feed, animals[self.animal_cursor].aid)
def _collect(self) -> None:
animals = self.engine.state.animals
if not animals:
self._flash("牧场里还没有动物", C_GRAY)
return
self.animal_cursor = min(self.animal_cursor, len(animals) - 1)
self._act(self.engine.collect, animals[self.animal_cursor].aid)
def _save(self) -> None:
try:
result = self.engine.save(self.save_path)
message = result.get("message", "已存档")
self._flash(message if len(message) < 46 else "已存档", C_GREEN)
except OSError as exc:
self._flash(f"存档失败:{exc}", C_RED)
except GameError as exc:
self._flash(f"存档失败:{exc.message}", C_RED)
def _ask_reset(self) -> None:
self.mode = "confirm_reset"
# ================================================================ 渲染
def _render(self) -> None:
width, height = terminal_size()
state = self.engine.state
snapshot = self.engine.snapshot()
# 地块光标可能因为增删而越界
if state.plots:
self.plot_cursor = max(0, min(self.plot_cursor, len(state.plots) - 1))
# 先把两栏宽度定死,保证 hstack 之后总宽正好等于终端宽度
side_width = min(SIDE_WIDTH, max(28, width // 3))
left_width = max(20, width - side_width - 1)
side_col = self._render_side(snapshot, side_width)
farm_col = self._render_farm(snapshot, left_width)
middle = hstack(farm_col, side_col, gap=1)
bottom = self._render_bottom(snapshot, width)
lines: List[str] = []
lines.append(self._render_header(snapshot, width))
lines.extend(self._render_status(snapshot, width))
lines.extend(middle)
lines.extend(bottom)
if len(lines) > height - 1:
lines = lines[: height - 1]
out = ["\x1b[H"]
for line in lines:
out.append(truncate(line, width) + "\x1b[K\r\n")
# 清掉本轮剩余行
out.append("\x1b[J")
sys.stdout.write("".join(out))
sys.stdout.flush()
# ---------------------------------------------------------- header
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)
else:
title += color(" 农场经营 ", C_GRAY, self.use_color)
left = "╭─" + title
online = snap.get("online")
if online is not None and online > 1:
tag = color(f"👥{online} ", C_ORANGE, self.use_color)
else:
tag = ""
if snap["paused"]:
right = tag + color("⏸ 已暂停 ", C_ORANGE, self.use_color)
else:
countdown = snap.get("seconds_to_tick")
if countdown is None:
right = tag + color("手动模式 ", C_GRAY, self.use_color)
else:
right = tag + color(
f"第 {snap['day']} 天 · 下一日 {countdown:.1f}s ", C_CYAN, self.use_color
)
gap = max(1, width - display_width(left) - display_width(right) - 1)
line = left + "─" * gap + right + "╮"
return line
# ---------------------------------------------------------- 状态区
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)
ap_bar = bar(snap["ap"], snap["max_ap"], 10)
line1 = (
color(f" {snap['gold']:>5}", C_BOLD + C_GOLD, self.use_color)
+ color(" 金币", C_GRAY, self.use_color)
+ " "
+ color(f"Lv.{snap['level']}", C_BOLD + C_PURPLE, self.use_color)
+ color(f" {snap['exp']}/{snap['exp_needed']} exp", C_GRAY, self.use_color)
+ " "
+ color("行动点 ", C_GRAY, self.use_color)
+ color(ap_bar, ap_color, self.use_color)
+ color(f" {snap['ap']}/{snap['max_ap']}", C_WHITE, self.use_color)
+ " "
+ color(f"饲料 {snap['feed']}", C_ORANGE, self.use_color)
)
if goal:
progress = bar(int(net_worth), int(goal["amount"]), 12)
pct = min(100, int(100 * net_worth / max(1, goal["amount"])))
goal_text = (
color(" 目标 ", C_GRAY, self.use_color)
+ color(goal["text"], C_CYAN, self.use_color)
+ " "
+ color(progress, C_BLUE, self.use_color)
+ color(f" {pct}%", C_WHITE, self.use_color)
)
else:
goal_text = color(" 目标 ", C_GRAY, self.use_color) + color(
"全部达成!你是真正的农业大亨", C_GOLD, self.use_color
)
line2 = (
color(f" 净资产 {net_worth:>7.0f}", C_GRAY, self.use_color)
+ " "
+ goal_text
+ " "
+ color(f"地块 {snap['plot_count']}/{snap['max_plots']}", C_GRAY, self.use_color)
+ " "
+ color(f"牲畜 {snap['animal_count']}/{snap['max_animals']}", C_GRAY, self.use_color)
)
return [pad(line1, width), pad(line2, width)]
# ---------------------------------------------------------- 农田
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] = []
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]
for line_index in range(3):
row = " ".join(part[line_index] for part in parts if line_index < len(part))
rows.append(pad(row, inner))
rows.append("") # 行间距
if rows and rows[-1] == "":
rows.pop()
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:
selected = plot["index"] == self.plot_cursor
border = C_BOLD + C_GREEN if selected else C_DIM
if plot["is_empty"]:
name_text = color(" 空地", C_GRAY, self.use_color)
state_text = color("空闲", C_DIM, self.use_color)
progress = color("·" * 10, C_DIM, self.use_color)
water = color("░" * config.WATER_CAPACITY, C_DIM, self.use_color)
else:
info = config.CROPS.get(plot["crop"], {"name": "?"})
if plot["is_ripe"]:
state_text = color("可收获", C_BOLD + C_GREEN, self.use_color)
elif plot["moisture"] == 0:
state_text = color("缺水!", C_RED, self.use_color)
else:
state_text = color(f"剩{plot['days_left']}天", C_WHITE, self.use_color)
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)
water_level = plot["moisture"]
water = color(
"▓" * water_level + "░" * (config.WATER_CAPACITY - water_level),
C_BLUE if water_level else C_RED,
self.use_color,
)
spacer = max(0, CELL_INNER - display_width(name_text) - display_width(state_text))
line1 = name_text + " " * spacer + state_text
line2 = progress + " " + water
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),
]
return "\n".join([top] + body + [bottom])
# ---------------------------------------------------------- 侧栏
def _render_side(self, snap: Dict[str, Any], side_width: int = SIDE_WIDTH) -> List[str]:
animals = snap["animals"]
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))
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)
elif animal["is_sick"]:
status = color("虚弱!", C_RED, self.use_color)
elif not animal["fed"]:
status = color("未喂食", C_ORANGE, self.use_color)
elif animal["days_to_product"] <= 1:
status = color("即将产出", C_GREEN, self.use_color)
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)
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)
# ---- 市场行情(中国习惯:涨红跌绿)
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:")),
key=lambda item: -item[1],
)[:4]
for key, price in ranked:
base = config.CROPS[key]["base_price"]
if price > base * 1.06:
trend, tone = "涨", C_RED
elif price < base * 0.94:
trend, tone = "跌", C_GREEN
else:
trend, tone = "平", C_GRAY
label = color(f" {name_by_key.get(key, key)}", C_WHITE, self.use_color)
value = color(f"{price:>6.0f} {trend}", tone, self.use_color)
spacer = max(1, side_width - 4 - display_width(label) - display_width(value))
market_rows.append(label + " " * spacer + value)
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)
return pasture + market
# ---------------------------------------------------------- 底部
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] = []
now = time.time()
if self.message and now < self.message_until:
rows.append(color(" " + self.message, self.message_color, self.use_color))
else:
rows.append(color(" 按 ? 查看全部快捷键与玩法说明", C_DIM, self.use_color))
for entry in self.engine.recent_log(limit=5):
tone = {
"good": C_GREEN,
"bad": C_RED,
"warn": C_ORANGE,
"action": C_CYAN,
}.get(entry["kind"], C_GRAY)
rows.append(
color(f" [D{entry['day']}] ", C_DIM, self.use_color)
+ color(entry["text"], tone, self.use_color)
)
log_box = draw_box("农事日志", rows, width, accent=C_PURPLE, use_color=self.use_color)
keys = " [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获"
keys2 = " [F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料"
keys3 = " [N]休息 [空格]暂停 [S]存档 [R]重置 [Q]退出"
hint = [
color(keys, C_GRAY, self.use_color),
color(keys2, C_GRAY, self.use_color),
color(keys3, C_GRAY, self.use_color),
]
return log_box + [pad(h, width) for h in hint]
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"]:
label = color(f" [{i}] {item['label']}", C_DIM, self.use_color)
detail = color(f"{item['detail']} ✗ {item['lock_hint']}", C_DIM, self.use_color)
else:
label = color(f" [{i}] {item['label']}", C_BOLD + C_WHITE, self.use_color)
tone = C_GREEN if item["affordable"] else C_RED
note = "" if item["affordable"] else " 金币不足"
detail = color(f"{item['detail']}{note}", tone, self.use_color)
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]:
rows = [
color(" 玩法目标:靠种地和养殖把净资产做到 10000 金币,解锁全部 5 个里程碑。", C_WHITE, self.use_color),
"",
color(" 时间", C_BOLD + C_GOLD, 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(" 行动点", C_BOLD + C_GOLD, 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(" 市场", 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(" ", 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(" ", C_GRAY, self.use_color)
+ color(" AI 与你在同一个农场里操作(见 README「给 AI 的接口」)。",
C_GRAY, self.use_color),
"",
color(" 按任意键返回", C_DIM, self.use_color),
]
return draw_box("帮助 · 玩法说明", rows, width, accent=C_BLUE, use_color=self.use_color)