feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径
三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。 实时通道:WebSocket -> SSE - hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye, 250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。 - 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 + 客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。 - 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。 服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2 - 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式 http="zttp",否则 Config.load() 直接 raise。 - 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖), 不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。 - 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例 「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。 - 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被 except OSError 吞掉,导致每次正常退出都留 stale pid 文件。 节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」 - DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。 - engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉, 时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。 - 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。 键盘:修复方向键/空格/Tab/ESC 全部静默失效 - keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串 (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。 - 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。 测试:重建 500 项 - test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 / test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑, 所以是全绿而不是靠跳过伪装。 - 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」 (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要 预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。 优化:先量后改,依据与前后数字都在 tools/bench.py - 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化 整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、 GET /api/world -31%、POST /api/actions?full=1 -16%。 - 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、 build_parser 与 available_actions 分组、__main__.main 160->7 行。 - 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的 broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json; main.py 的 _add_endpoint_args/_leaderboard_lines。 - tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。 门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。
This commit is contained in:
1 parent
47dd818360
commit
f8e093c98f
36 files changed
+6865
-2204
No files matched your search
+25
-30
@@ -3,7 +3,7 @@
|
||||
为什么要内嵌而不是写一堆静态文件
|
||||
--------------------------------
|
||||
服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理
|
||||
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 WebSocket
|
||||
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 SSE
|
||||
协议的一份"活文档":想接自己的客户端时,照着这里的 JS 抄就行。
|
||||
|
||||
注意:这个字符串里既有 CSS 又有 JS,花括号很多,所以**不要**用 ``f-string``
|
||||
@@ -132,7 +132,6 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
<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>
|
||||
@@ -159,7 +158,7 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let ws = null, state = null, catalog = null, pollTimer = null, reconnectTimer = null, pending = 0;
|
||||
let es = null, state = null, catalog = null, pollTimer = null;
|
||||
|
||||
const cropNames = {
|
||||
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
|
||||
@@ -218,40 +217,34 @@ function refreshFromBrief(brief) {
|
||||
}
|
||||
|
||||
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(); } });
|
||||
}
|
||||
|
||||
/* 实时通道:SSE 长连接(EventSource 自带断线重连,不用自己写重连逻辑) */
|
||||
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()) +
|
||||
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("连接中…", "");
|
||||
ws = new WebSocket(url);
|
||||
ws.onopen = () => {
|
||||
setStatus("已连接(WebSocket)", "on");
|
||||
es = new EventSource(url);
|
||||
es.onopen = () => {
|
||||
setStatus("已连接(SSE 实时)", "on");
|
||||
clearInterval(pollTimer); pollTimer = null;
|
||||
api("/api/farms", { headers: headers() });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
es.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);
|
||||
es.onerror = () => {
|
||||
/* EventSource 会自己重连;这期间先用轮询顶上,体验不中断 */
|
||||
setStatus("推送断开,轮询中…", "bad");
|
||||
if (!pollTimer) pollTimer = setInterval(syncNow, 1500);
|
||||
};
|
||||
ws.onerror = () => setStatus("连接异常", "bad");
|
||||
}
|
||||
|
||||
function handle(msg) {
|
||||
@@ -263,11 +256,6 @@ function handle(msg) {
|
||||
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") {
|
||||
@@ -277,6 +265,14 @@ function handle(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 一天 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;
|
||||
@@ -284,7 +280,7 @@ function render() {
|
||||
["第几天", 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"]
|
||||
["下一天", fmtLeft(s.seconds_to_tick)]
|
||||
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join("");
|
||||
|
||||
/* 农田 */
|
||||
@@ -409,7 +405,6 @@ $("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>
|
||||
@@ -424,7 +419,7 @@ def dashboard_text(host: str, port: int) -> str:
|
||||
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"
|
||||
f"实时通道 :http://{host}:{port}/api/stream?farm=<牧场名>(SSE 单向推送)\n"
|
||||
"\n"
|
||||
"给 AI / 脚本的最短路径\n"
|
||||
"----------------------\n"
|
||||
@@ -448,7 +443,7 @@ def dashboard_text(host: str, port: int) -> str:
|
||||
'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"
|
||||
" collect|buy_feed|buy_plot|pause|save|reset 便捷入口\n"
|
||||
'POST /api/farms {"farm":"名字"} 创建/进入牧场\n'
|
||||
"POST /api/admin/save 全部存档\n"
|
||||
"POST /api/admin/shutdown 优雅停机\n"
|
||||
|
||||
Reference in new issue
Block a user