Files
SimpleFarm/tools/preview_tui.py
T
snowy-yang f8e093c98f 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)。
2026-09-19 12:55:45 +08:00

152 lines
5.3 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.
#!/usr/bin/env python3
"""把 TUI 渲染结果输出到终端,用于检查排版与配色。
不进入交互循环,也不读取键盘——只是把某一时刻的完整画面画出来。
用法:
python tools/preview_tui.py # 使用默认的中等规模农场(本地引擎)
python tools/preview_tui.py --width 100 # 指定终端宽度
python tools/preview_tui.py --color # 输出带 ANSI 颜色
python tools/preview_tui.py --scene empty # 初始状态
python tools/preview_tui.py --scene rich # 后期大农场
python tools/preview_tui.py --scene menu # 播种菜单
python tools/preview_tui.py --url http://127.0.0.1:8765 --farm alice
# 渲染**真实服务器**上的牧场
"""
from __future__ import annotations
import argparse
import contextlib
import io
import os
import random
import re
import sys
from typing import TYPE_CHECKING
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from farm import config
from farm.engine import FarmEngine
from farm.tui import TuiApp
if TYPE_CHECKING: # 只在类型检查时导入,运行时不引入依赖
from farm.client import RemoteFarm
SCENES = ("empty", "mixed", "rich", "menu", "help")
def build_remote(url: str, farm: str, token: str | None) -> tuple[RemoteFarm, TuiApp]:
"""连到真实服务器,取一份现状来渲染。"""
from farm.client import RemoteFarm
client = RemoteFarm(endpoint=url, farm=farm, token=token, realtime=False)
app = TuiApp(
client, use_color=True, autosave=False, server_label=f"{client.farm} @ {client.host}"
)
app.show_keyboard_notice = False # 定格画面:按键提示要照常画出来
return client, app
def build_engine(scene: str) -> tuple[FarmEngine, TuiApp]:
random.seed(42)
engine = FarmEngine(tick_seconds=config.DEFAULT_TICK_SECONDS)
app = TuiApp(engine, use_color=True, autosave=False)
app.show_keyboard_notice = False # 定格画面:按键提示要照常画出来
if scene == "empty":
return engine, app
# ---- 制造一个"进行中"的农场
engine.state.level = 2
engine.state.gold = 340
engine.state.feed = 4
engine.plant(1, "wheat")
engine.water(1)
engine.plant(2, "carrot")
engine.plant(3, "wheat")
engine.plant(4, "carrot")
engine.advance_day()
engine.advance_day()
engine.buy_animal("chicken")
engine.feed(1)
if scene == "rich":
engine.state.level = 4
engine.state.gold = 2400
engine.state.feed = 22
for _ in range(2):
engine.state.gold = 99999
engine.buy_plot()
engine.plant(5, "corn")
engine.plant(6, "tomato")
engine.water(5)
engine.state.gold = 2400
engine.buy_animal("sheep")
engine.buy_animal("cow")
engine.advance_day()
engine.advance_day()
engine.add_log("收获 2 份小麦(丰收!),+31 金币", "good")
engine.add_log("升级到 Lv.4!奖励 240 金币,解锁:南瓜", "good")
if scene == "menu":
# 找第一块空地,把播种菜单打开
empty = next((p.index for p in engine.state.plots if p.is_empty), 0)
app.plot_cursor = empty
app._open_crop_menu()
if scene == "help":
app.mode = "help"
return engine, app
def main() -> int:
parser = argparse.ArgumentParser(description="渲染一帧 TUI 画面")
parser.add_argument("--width", type=int, default=112, help="终端宽度")
parser.add_argument("--height", type=int, default=32, help="终端高度")
parser.add_argument("--scene", choices=SCENES, default="mixed", help="场景")
parser.add_argument("--color", action="store_true", help="保留 ANSI 颜色")
parser.add_argument(
"--url", default=None, help="连到真实服务器渲染(例如 http://127.0.0.1:8765)"
)
parser.add_argument("--farm", default="default", help="--url 模式下要看的牧场")
parser.add_argument("--token", default=None, help="--url 模式下的访问令牌")
args = parser.parse_args()
os.environ["COLUMNS"] = str(args.width)
os.environ["LINES"] = str(args.height)
if args.url:
engine, app = build_remote(args.url, args.farm, args.token)
state = engine.snapshot()
print(
f"# 来自真实服务器 {args.url} · 牧场 {args.farm} · 第 {state.get('day')} 天 · "
f"金币 {state.get('gold')} · 在线 {state.get('online')}\n"
)
else:
engine, app = build_engine(args.scene)
# 预览脚本没有跑 Scheduler,这里手动给个倒计时,让画面更接近真实运行状态
if engine.next_tick_at is None and not engine.paused:
import time as _time
# 假装这一天已经过去了 12 秒,倒计时才会显示成 39:48 这种真实样子
engine.next_tick_at = _time.time() + config.DEFAULT_TICK_SECONDS - 12
app.use_color = args.color
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
app._render()
output = buffer.getvalue()
# 去掉清屏、光标控制与行尾清理符,预览里只保留可见内容
output = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", output)
print(output)
return 0
if __name__ == "__main__":
sys.exit(main())