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

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

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

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

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

505 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""内嵌的网页客户端。
为什么要内嵌而不是写一堆静态文件
--------------------------------
服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 SSE
协议的一份"活文档":想接自己的客户端时,照着这里的 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="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="granaryhint"></em></h2>
<table id="granary"></table>
<div class="row" style="margin-top:8px">
<button class="primary" data-act="sell">全部卖出</button>
</div>
</div>
<div class="card">
<h2>建筑 <em>花金币换自动化</em></h2>
<div class="row" id="buildings"></div>
</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 es = null, state = null, catalog = null, pollTimer = null;
const cropNames = {
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
corn: "玉米", pumpkin: "南瓜", strawberry: "草莓"
};
function goodsName(key) {
const g = ((catalog && catalog.goods) || []).find((x) => x.key === key);
if (g) return g.name;
if (key.indexOf("animal:") === 0) return key.slice(7);
return cropNames[key] || key;
}
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() {
api("/api/state?farm=" + encodeURIComponent(farmName()) +
(token() ? "&token=" + encodeURIComponent(token()) : ""), { headers: headers() })
.then((d) => { if (d) { state = d.state; render(); } });
}
/* 实时通道:SSE 长连接(EventSource 自带断线重连,不用自己写重连逻辑) */
function connect() {
if (es) { try { es.close(); } catch (e) {} }
clearInterval(pollTimer); pollTimer = null;
const url = "/api/stream?farm=" + encodeURIComponent(farmName()) +
(token() ? "&token=" + encodeURIComponent(token()) : "") + "&name=web";
setStatus("连接中…", "");
es = new EventSource(url);
es.onopen = () => {
setStatus("已连接(SSE 实时)", "on");
clearInterval(pollTimer); pollTimer = null;
api("/api/farms", { headers: headers() });
};
es.onmessage = (ev) => {
let msg = null;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
handle(msg);
};
es.onerror = () => {
/* EventSource 会自己重连;这期间先用轮询顶上,体验不中断 */
setStatus("推送断开,轮询中…", "bad");
if (!pollTimer) pollTimer = setInterval(syncNow, 1500);
};
}
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 === "day") {
renderLog(null);
} else if (msg.type === "notice") {
$("notice").textContent = msg.text;
} else if (msg.type === "error") {
toast(msg.message || "服务器返回错误");
}
}
/* 一天 40 分钟,直接显示秒数是一串大数,换算成 m:ss */
function fmtLeft(sec) {
if (sec === null || sec === undefined) return "已暂停";
const t = Math.max(0, Math.round(sec));
if (t < 60) return t + "s";
return Math.floor(t / 60) + ":" + String(t % 60).padStart(2, "0");
}
function render() {
if (!state) return;
const s = state;
$("stats").innerHTML = [
["第几天", s.day],
["天气", (s.weather_name || "—") + " → " + (s.forecast_name || "?")],
["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed],
["仓储", (s.storage_used || 0) + "/" + (s.granary_capacity || 0)],
["净资产", Math.round(s.net_worth)],
["在线", s.online !== undefined ? s.online : "—"],
["下一天", fmtLeft(s.seconds_to_tick)]
].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>';
/* 市场:基准价直接用 catalog.goods,别再在 JS 里抄一份常量表 */
const gmap = {};
((catalog && catalog.goods) || []).forEach((g) => { gmap[g.key] = g; });
$("market").innerHTML = '<tr><th>货物</th><th class="num">现价</th><th class="num">基准</th></tr>' +
Object.entries(s.market).sort((a, b) => b[1] - a[1]).map((kv) => {
const g = gmap[kv[0]] || {};
const b = g.base_price || 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>' + goodsName(kv[0]) + '</td><td class="num ' + cls + '">' +
kv[1].toFixed(0) + " " + arrow + '</td><td class="num">' + b.toFixed(0) + '</td></tr>';
}).join("");
/* 谷仓:卖货是唯一把存货变成金币的出口,所以每个存项都直接给一个卖出按钮 */
const inv = Object.entries(s.inventory || {}).filter((kv) => kv[1] > 0);
$("granaryhint").textContent =
"仓容 " + (s.storage_used || 0) + "/" + (s.granary_capacity || 0);
$("granary").innerHTML =
'<tr><th>货物</th><th class="num">存量</th><th class="num">现价</th>' +
'<th class="num">基准</th><th></th></tr>' +
(inv.length ? inv.sort((a, b) => b[1] - a[1]).map((kv) => {
const g = gmap[kv[0]] || {};
const price = g.price !== undefined ? g.price : ((s.market || {})[kv[0]] || 0);
const b = g.base_price || 0;
const cls = b && price > b * 1.03 ? "up" : (b && price < b * 0.97 ? "down" : "flat");
return '<tr><td>' + goodsName(kv[0]) + '</td><td class="num">' + kv[1] +
'</td><td class="num ' + cls + '">' + price.toFixed(0) + '</td>' +
'<td class="num">' + (b ? b.toFixed(0) : "—") + '</td>' +
'<td class="num"><button data-s="' + kv[0] + '">卖</button></td></tr>';
}).join("")
: '<tr><td colspan="5" style="color:#6b7a66">空仓:收获与收取的产出会自动入库</td></tr>');
/* 建筑 */
const builds = (catalog && catalog.buildings) || [];
$("buildings").innerHTML = builds.length ? builds.map((b) => {
const disabled = b.locked || b.maxed || s.gold < b.next_cost;
const label = b.name + " " + (b.maxed ? "已满" : b.next_cost + " 金") +
"(" + b.owned + "/" + b.max_level + ")";
return '<button data-b="' + b.key + '"' + (disabled ? " disabled" : "") +
' title="' + b.effect + '">' + label + '</button>';
}).join("") : '<span style="color:#6b7a66">目录加载中…</span>';
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.s) act("sell", { key: t.dataset.s });
else if (t.dataset.b) act("build", { key: t.dataset.b });
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);
</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"实时通道 :http://{host}:{port}/api/stream?farm=<牧场名>(SSE 单向推送)\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|sell|build|pause|save|reset 便捷入口\n"
'POST /api/farms {"farm":"名字"} 创建/进入牧场\n'
"POST /api/admin/save 全部存档\n"
"POST /api/admin/shutdown 优雅停机\n"
)