三件事凑成一次不兼容变更: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)。
152 lines
5.3 KiB
Python
152 lines
5.3 KiB
Python
#!/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())
|