chore: 工程化改造前的基线快照
在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前, 先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。 此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。
This commit is contained in:
commit
a7725f8812
35 files changed
+11871
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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__))
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
Reference in new issue
Block a user