Files
SimpleFarm/farm/server/webui.py
T
snowy-yang 2676927c1a fix(webui): 中文用户名登录后所有写操作静默失败
为什么:headers() 把用户名塞进 X-Farm-Id 请求头,而 HTTP 头只允许
Latin-1 字符——中文用户名(如"阳")会让 fetch 在发送前直接抛
TypeError(String contains non ISO-8859-1 code point)。登录时
me 还是 null(头为 ASCII 的 default)所以能登录成功,登录之后
me 变成中文,所有走 api() 的写操作(播种/浇水/收获/卖出)全部
静默失败;页面靠 SSE 推送照常渲染,看起来一切正常,用户表现为
"点播种没反应"。

- headers():非 ASCII 的牧场名/令牌不再塞头。登录态本来就由
  Cookie 鉴权,X-Farm-Id 只是匿名脚本模式的备用通道;ASCII 名
  (default、bot 等)行为不变。
- api():捕获 fetch 网络异常并 toast 提示。此前 Promise 拒绝
  无人接住,这类故障完全不可见,正是本 bug 难排查的原因。
- localStorage 读写加 try/catch 兜底:sandbox iframe 若不带
  allow-same-origin,裸访问即抛 SecurityError,会炸掉整个启动
  脚本(启动段就有三处裸调用)。

验证:headless Chrome 真机复现 → 修复 → UI 全流程回归(登录
"阳" → 点播种 → 选小麦 → toast 与地块状态正确更新);ruff /
pyright / 528 项测试全绿。
2026-09-23 22:28:40 +08:00

620 lines
26 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; }
/* 播种选作物 / 二次确认的页面内弹层(不依赖 window.prompt,嵌入预览里也能用) */
.mask { position: fixed; inset: 0; background: rgba(30,40,30,.35);
display: none; align-items: center; justify-content: center; z-index: 30; }
.mask.show { display: flex; }
.pick { background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 14px 16px; min-width: 230px; box-shadow: 0 10px 30px rgba(0,0,0,.18); }
.pick h3 { margin: 0 0 10px; font-size: 14px; }
.pick .row { display: flex; flex-wrap: wrap; gap: 8px; }
.pick button.danger { border-color: var(--red); color: var(--red); }
.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; }
.badge { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 5px 12px; font-size: 13px; }
.hidden { display: none; }
.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>
<span class="badge" id="who">未登录</span>
<input id="token" size="10" placeholder="令牌(可选)" />
<button id="logout" class="hidden">退出</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>
<div class="mask" id="loginmask">
<div class="pick">
<h3>登录到你的牧场</h3>
<p style="color:#6b7a66;margin:6px 0 10px">输入用户名即可——第一次用会自动开一个新牧场。</p>
<div class="row" style="justify-content:center">
<input id="loginname" size="14" placeholder="用户名" />
<button class="primary" id="dologin">登录</button>
</div>
</div>
</div>
<div class="mask" id="pickmask">
<div class="pick">
<h3 id="picktitle">要种什么?</h3>
<div class="row" id="pickcrops"></div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
let es = null, state = null, catalog = null, pollTimer = null, me = 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;
}
/* sandbox iframe 可能禁用 localStorage(无 allow-same-origin 时访问即抛
SecurityError),任何一处裸调用都会炸掉整个启动脚本。 */
function lsGet(key) {
try { return localStorage.getItem(key); } catch (e) { return null; }
}
function lsSet(key, val) {
try { localStorage.setItem(key, val); } catch (e) {}
}
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 me || "default"; }
function token() { return ($("token").value || "").trim(); }
/* HTTP 头只允许 Latin-1 字符:中文用户名塞进 X-Farm-Id 会让 fetch 在发送前
就抛 TypeError,登录后所有写操作静默失败(表现为“点播种没反应”)。
登录态由 Cookie 鉴权,非 ASCII 的值一律不塞头。 */
function latin1(s) {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 32 || c > 126) return false;
}
return true;
}
function headers() {
const h = { "Content-Type": "application/json" };
if (latin1(farmName())) h["X-Farm-Id"] = farmName();
if (token() && latin1(token())) h["X-API-Token"] = token();
return h;
}
async function api(path, options) {
let res;
try {
res = await fetch(path, options);
} catch (e) {
toast("网络错误:" + e.message);
return null;
}
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.plant !== undefined) {
$("pickmask").classList.remove("show");
if (t.dataset.plant && pickPlot) act("plant", { plot: pickPlot, crop: t.dataset.plant });
}
else if (t.dataset.p) {
openCropPick(+t.dataset.p);
}
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 (t.dataset.armed) {
t.dataset.armed = ""; t.textContent = "重置";
act("reset");
} else {
t.dataset.armed = "1"; t.textContent = "再点一次确认";
setTimeout(() => { t.dataset.armed = ""; t.textContent = "重置"; }, 3000);
}
}
else if (t.dataset.act) act(t.dataset.act);
});
/* 播种选作物:页面内弹层。window.prompt 在 sandbox iframe(内置预览面板)里
会被静默屏蔽——不弹窗、直接返回 null,表现为"点播种没反应"。 */
let pickPlot = 0;
function openCropPick(plot) {
if (!state) { toast("还没连接上服务器"); return; }
const pool = Object.keys(cropNames).filter((k) => {
const c = (catalog || { crops: [] }).crops.find((x) => x.key === k);
return !c || !c.locked;
});
if (!pool.length) { toast("还没有可种的作物"); return; }
pickPlot = plot;
$("picktitle").textContent = plot + " 号地 · 要种什么?";
$("pickcrops").innerHTML = pool.map((k) =>
'<button data-plant="' + k + '">' + cropNames[k] + "</button>").join("") +
'<button class="danger" data-plant="">取消</button>';
$("pickmask").classList.add("show");
}
$("pickmask").addEventListener("click", (ev) => {
if (ev.target.id === "pickmask") $("pickmask").classList.remove("show");
});
/* ---- 登录:用户名即牧场身份,Cookie 由服务器签发 ---- */
async function whoami() {
const d = await api("/api/me");
me = (d && d.user) || null;
applyIdentity();
}
function applyIdentity() {
$("who").textContent = me ? "🌾 " + me : "未登录";
$("logout").classList.toggle("hidden", !me);
$("loginmask").classList.toggle("show", !me);
if (me) {
lsSet("sf.farm", me);
connect();
} else if (es) {
try { es.close(); } catch (e) {}
es = null;
clearInterval(pollTimer);
setStatus("未登录", "");
}
}
async function doLogin() {
const name = ($("loginname").value || "").trim();
if (!name) { toast("先填一个用户名"); return; }
const d = await api("/api/login", {
method: "POST", headers: headers(),
body: JSON.stringify({ username: name })
});
if (d && d.ok) { me = d.user; toast(d.message); applyIdentity(); }
}
$("dologin").addEventListener("click", doLogin);
$("loginname").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
$("logout").addEventListener("click", async () => {
await api("/api/logout", { method: "POST", headers: headers() });
me = null;
applyIdentity();
$("loginname").focus();
});
/* ---- 启动 ---- */
$("token").value = lsGet("sf.token") || "";
$("token").addEventListener("change", () => {
lsSet("sf.token", token());
if (me) connect();
});
whoami();
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"
"登录(网页用;脚本可以不带 Cookie 继续 ?farm=)\n"
"----------------------------------------------\n"
'POST /api/login {"username":"名字"} → 设置 Cookie,登录后只能操作自己的牧场\n'
"GET /api/me 当前登录用户(未登录为 null)\n"
"POST /api/logout 退出登录\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"
)