Files
SimpleFarm/main.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

705 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.
#!/usr/bin/env python3
"""SimpleFarm 统一入口。
一个进程管两件事:**服务器**(后台常驻,人多人在线)和**客户端**(你自己玩、或者脚本/AI 调接口)。
快速开始
--------
uv sync # 建好环境(只有服务端需要第三方依赖)
uv run main.py serve --daemon # 后台启动小型游戏服务器
uv run main.py play # 打开终端界面玩自己的牧场
uv run main.py play --farm alice # 换个牧场名就是另一个玩家
uv run main.py status # 看服务器状态
uv run main.py stop # 优雅停机(会把所有牧场存档)
给脚本 / AI 用的
----------------
uv run main.py actions # 我当前能做哪些事
uv run main.py do plant '{"plot":1,"crop":"wheat"}'
uv run main.py state --json # 完整状态(机器可读)
uv run main.py watch # 实时滚动状态与日志
uv run main.py bot --farm ai-1 # 让规则机器人替你经营一个牧场
curl http://127.0.0.1:8765/api/actions # 直接打接口也行
浏览器打开 http://127.0.0.1:8765/ 可以看到网页版客户端。
没装 uv?用 `python main.py ...` 效果完全一样,命令里的 "uv run " 去掉即可。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from typing import Any
from farm import config
from farm.models import format_duration
DEFAULT_URL = "http://127.0.0.1:8765"
BANNER = r"""
____ _ _ _____
/ ___|(_)_ __ ___ _ __ | | ___ | ___|_ _ _ __ _ __ ___
\___ \| | '_ ` _ \| '_ \| |/ _ \| |_ / _` | '__| '_ ` _ \
___) | | | | | | | |_) | | __/| _| (_| | | | | | | | |
|____/|_|_| |_| |_| .__/|_|\___||_| \__,_|_| |_| |_| |_|
|_|
一个小型农场游戏服务器:人在终端玩,AI 走接口玩
"""
# ---------------------------------------------------------------------- 输出
def _supports_color() -> bool:
if os.environ.get("NO_COLOR"):
return False
return sys.stdout.isatty()
def paint(text: str, code: str) -> str:
if not _supports_color():
return text
return f"\x1b[{code}m{text}\x1b[0m"
def ok(text: str) -> str:
return paint(text, "32")
def warn(text: str) -> str:
return paint(text, "33")
def bad(text: str) -> str:
return paint(text, "31")
def dim(text: str) -> str:
return paint(text, "90")
def die(message: str, hint: str = "", code: int = 1) -> int:
print(bad("✗ ") + message, file=sys.stderr)
if hint:
print(dim(" " + hint.replace("\n", "\n ")), file=sys.stderr)
return code
# ---------------------------------------------------------------------- 参数
def build_parser() -> argparse.ArgumentParser:
"""命令行总表:服务器那几个子命令 + 客户端那几个子命令。"""
parser = argparse.ArgumentParser(
prog="uv run main.py",
description="SimpleFarm:终端农场游戏 + 小型多人游戏服务器(HTTP + SSE 供 AI 调用)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=BANNER,
)
sub = parser.add_subparsers(dest="command", metavar="<命令>")
_add_server_parsers(sub)
_add_client_parsers(sub)
return parser
def _add_server_parsers(sub) -> None:
"""serve / stop / status / logs —— 管服务器本身的那几个。"""
p_serve = sub.add_parser("serve", help="启动游戏服务器(可后台运行)")
p_serve.add_argument("--daemon", action="store_true", help="转到后台运行(守护进程)")
p_serve.add_argument(
"--host", default="127.0.0.1", help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0"
)
p_serve.add_argument("--port", type=int, default=8765, help="监听端口,默认 8765")
p_serve.add_argument("--token", default=None, help="访问令牌,设置后接口需要携带")
p_serve.add_argument(
"--tick",
type=float,
default=config.DEFAULT_TICK_SECONDS,
help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒",
)
p_serve.add_argument("--save-dir", default=None, help="存档目录,默认 <项目>/saves")
p_serve.add_argument("--world", default="SimpleFarm 公共农场", help="世界名称")
p_serve.add_argument("--max-sessions", type=int, default=64, help="同时在线的牧场上限")
p_serve.add_argument(
"--idle-timeout",
type=float,
default=1800.0,
help="无人访问多少秒后把牧场移出内存(存档保留)",
)
p_serve.add_argument("--no-remote-control", action="store_true", help="禁止接口停机")
p_serve.add_argument("--access-log", action="store_true", help="打印每个 HTTP 请求")
p_serve.add_argument(
"--log-level", default="info", choices=["critical", "error", "warning", "info", "debug"]
)
p_serve.add_argument("--log-file", default=None, help="守护进程日志路径")
p_serve.add_argument("--pid-file", default=None, help="守护进程 pid 文件路径")
p_stop = sub.add_parser("stop", help="停止后台服务器(会先落盘存档)")
_add_endpoint_args(p_stop)
p_stop.add_argument("--force", action="store_true", help="超时后强制结束进程")
p_status = sub.add_parser("status", help="查看服务器状态")
_add_endpoint_args(p_status)
p_status.add_argument("--logs", action="store_true", help="同时打印日志尾部")
p_status.add_argument("--json", action="store_true", help="输出机器可读的 JSON")
p_logs = sub.add_parser("logs", help="查看服务器日志尾部")
p_logs.add_argument("-n", "--lines", type=int, default=40)
def _add_client_parsers(sub) -> None:
"""play / solo / do / batch / actions / state / log / watch / bot / farms / help。"""
p_play = sub.add_parser("play", help="打开终端界面(TUI)连接服务器玩")
_add_client_args(p_play)
p_play.add_argument("--name", default="", help="在服务器上显示的昵称")
p_play.add_argument("--no-color", action="store_true")
p_play.add_argument("--no-realtime", action="store_true", help="不用 SSE 推送,改走 HTTP 轮询")
p_solo = sub.add_parser("solo", help="离线单机模式(本地引擎 + 终端界面,不开服务器)")
p_solo.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS)
p_solo.add_argument("--save", default=None)
p_solo.add_argument("--new", action="store_true", help="忽略旧存档")
p_solo.add_argument("--no-color", action="store_true")
p_do = sub.add_parser("do", help="执行一个动作(脚本/AI 用途)")
p_do.add_argument("action", help="动作名:plant / water / harvest / ...")
p_do.add_argument(
"params", nargs="?", default="{}", help='JSON 参数,例如 \'{"plot":1,"crop":"wheat"}\''
)
_add_client_args(p_do)
p_batch = sub.add_parser("batch", help="批量执行动作(一个请求干多件事)")
p_batch.add_argument("actions", help='JSON 数组,例如 \'[{"action":"plant","params":{...}}]\'')
_add_client_args(p_batch)
p_actions = sub.add_parser("actions", help="列出当前所有合法动作")
_add_client_args(p_actions)
p_state = sub.add_parser("state", help="查看当前状态")
_add_client_args(p_state)
p_state.add_argument("--json", action="store_true", help="输出完整 JSON")
p_log = sub.add_parser("log", help="查看最近事件")
_add_client_args(p_log)
p_log.add_argument("-n", "--lines", type=int, default=20)
p_watch = sub.add_parser("watch", help="实时滚动状态与日志(适合观察 AI 玩)")
_add_client_args(p_watch)
p_watch.add_argument("--interval", type=float, default=1.0)
p_bot = sub.add_parser("bot", help="启动规则机器人替你经营一个牧场")
_add_client_args(p_bot)
p_bot.add_argument("--rounds", type=int, default=0, help="最多执行多少轮,0 表示不限")
p_bot.add_argument("--minutes", type=float, default=0.0, help="最多跑多少分钟,0 表示不限")
p_bot.add_argument("--pause", type=float, default=0.25, help="每个动作之间的间隔秒数")
p_bot.add_argument(
"--wait", type=float, default=0.0, help="没事可做时最多等多少秒(等下一天),0 表示直接收工"
)
p_farms = sub.add_parser("farms", help="查看服务器上的牧场排行榜")
_add_client_args(p_farms)
p_help = sub.add_parser("help", help="玩法与接口说明")
_add_client_args(p_help)
def _add_client_args(parser: argparse.ArgumentParser) -> None:
"""连服务器要用的三件套:地址、牧场名、令牌。"""
parser.add_argument("--url", default=DEFAULT_URL, help=f"服务器地址,默认 {DEFAULT_URL}")
parser.add_argument("--farm", default="default", help="牧场名,默认 default")
parser.add_argument("--token", default=None, help="访问令牌")
def _add_endpoint_args(parser: argparse.ArgumentParser) -> None:
"""只用来"找到那个服务器"的参数,stop / status 共用。"""
parser.add_argument("--host", default="127.0.0.1", help="服务器地址,默认 127.0.0.1")
parser.add_argument("--port", type=int, default=8765, help="服务器端口,默认 8765")
parser.add_argument("--token", default=None, help="访问令牌")
def _leaderboard_lines(rows: list[dict[str, Any]], limit: int = 0) -> list[str]:
"""排行榜表格行,``cmd_farms`` 与 ``cmd_status`` 共用。
列宽只在这里定义一次,加一列时不必去两个地方各调一次格式。
"""
header = f"{'#':>2} {'牧场':<14} {'净资产':>9} {'金币':>7} {'天':>4} {'等级':>4} {'在线':>4}"
selected = rows[:limit] if limit else rows
return [header] + [
f"{row['rank']:>2} {row['farm']:<14} {row['net_worth']:>9.0f} "
f"{row['gold']:>7} {row['day']:>4} {row['level']:>4} {row['online']:>4}"
for row in selected
]
# ---------------------------------------------------------------------- 客户端
def make_client(args, realtime: bool = False):
"""构造远程客户端;连不上时给出可执行的下一步建议。"""
from farm.client import RemoteFarm
from farm.engine import GameError
try:
return RemoteFarm(
endpoint=args.url,
farm=getattr(args, "farm", "default"),
token=getattr(args, "token", None),
name=getattr(args, "name", "") or "cli",
realtime=realtime,
autostart=True,
)
except GameError as exc:
hint = (
f"服务器地址:{args.url}\n"
"先启动服务器:uv run main.py serve --daemon\n"
"再确认地址端口:uv run main.py status"
)
raise SystemExit(die(f"无法连接服务器:{exc.message}", hint)) from None
# ---------------------------------------------------------------------- 子命令
def cmd_serve(args) -> int:
from farm.server.__main__ import main as server_main
argv = [
"--host",
args.host,
"--port",
str(args.port),
"--tick",
str(args.tick),
"--world",
args.world,
"--max-sessions",
str(args.max_sessions),
"--idle-timeout",
str(args.idle_timeout),
"--log-level",
args.log_level,
]
if args.save_dir:
argv += ["--save-dir", args.save_dir]
if args.token:
argv += ["--token", args.token]
if args.daemon:
argv.append("--daemon")
if args.no_remote_control:
argv.append("--no-remote-control")
if args.access_log:
argv.append("--access-log")
if args.log_file:
argv += ["--log-file", args.log_file]
if args.pid_file:
argv += ["--pid-file", args.pid_file]
return server_main(argv)
def cmd_stop(args) -> int:
from farm.server.daemon import DaemonManager
manager = DaemonManager(host=args.host, port=args.port, token=args.token)
success, message = manager.stop(force=args.force)
print((ok("✓ ") if success else warn("• ")) + message)
return 0 if success else 1
def cmd_status(args) -> int:
from farm.server.daemon import DaemonManager
manager = DaemonManager(host=args.host, port=args.port, token=args.token)
info = manager.status()
if args.json:
print(json.dumps(info, ensure_ascii=False, indent=2))
return 0
if not info["running"]:
print(warn("• 服务器未运行"))
print(dim(" 启动:uv run main.py serve --daemon"))
print(dim(f" 地址:http://{args.host}:{args.port}/"))
return 1
health = info.get("health") or {}
print(ok("✓ 服务器运行中"))
print(f" 进程 pid {info['pid'] or '未知(不是本脚本启动的)'}")
print(f" 地址 http://{info['host']}:{info['port']}/")
if health:
print(f" 运行时长 {health.get('uptime', 0):.0f} 秒")
print(f" 牧场 {health.get('farms', 0)} 个 · 在线连接 {health.get('clients', 0)} 个")
print(f" 时钟 已推进 {health.get('clock_ticks', 0)} 天")
print(f" 存档目录 {manager.save_dir}")
print(f" 日志 {info['log_file']}")
world = manager.world()
if world and world.get("leaderboard"):
print(" 排行榜")
for line in _leaderboard_lines(world["leaderboard"], limit=5):
print(" " + line)
if args.logs:
print(dim(" ---- 日志尾部 ----"))
for line in manager.tail(15):
print(" " + line)
return 0
def cmd_logs(args) -> int:
from farm.server.daemon import DaemonManager
manager = DaemonManager()
lines = manager.tail(args.lines)
if not lines:
print(warn("• 还没有日志(服务器从未在后台启动过)"))
return 1
for line in lines:
print(line)
return 0
def cmd_play(args) -> int:
from farm.tui import TuiApp
if not (sys.stdin is not None and sys.stdin.isatty()):
return die(
"当前终端不是交互式终端,无法启动界面。",
"在真实终端里运行,或用命令行模式:uv run main.py actions / do / watch",
)
client = make_client(args, realtime=not args.no_realtime)
label = f"{client.farm} @ {client.host}"
app = TuiApp(
client, save_path=None, use_color=not args.no_color, autosave=True, server_label=label
)
try:
app.run()
finally:
client.stop()
return 0
def cmd_solo(args) -> int:
from farm.engine import FarmEngine, Scheduler
from farm.storage import default_save_path, load_state
from farm.tui import TuiApp
if not (sys.stdin is not None and sys.stdin.isatty()):
return die("当前终端不是交互式终端,无法启动界面。")
tick = max(config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, args.tick))
engine = FarmEngine(tick_seconds=tick)
save_path = args.save or default_save_path()
if args.new:
engine.add_log("新的一天,新的开始。欢迎来到 SimpleFarm!", "info")
else:
state, message = load_state(save_path)
if state is not None:
engine.state = state
engine.add_log(f"欢迎回来,第 {state.day} 天继续经营", "info")
print(message)
else:
engine.add_log("新的一天,新的开始。欢迎来到 SimpleFarm!", "info")
print(f"{message},已开新档。")
scheduler = Scheduler(engine, enabled=True)
scheduler.start()
app = TuiApp(engine, save_path=save_path, use_color=not args.no_color)
try:
app.run()
finally:
scheduler.stop()
return 0
def cmd_do(args) -> int:
from farm.engine import GameError
client = make_client(args)
try:
params = json.loads(args.params) if args.params else {}
except json.JSONDecodeError as exc:
return die(
f"参数不是合法 JSON:{exc}",
'例子:uv run main.py do plant \'{"plot":1,"crop":"wheat"}\'',
)
if not isinstance(params, dict):
return die("参数必须是 JSON 对象")
started = time.time()
try:
result = client._action(args.action, params)
except GameError as exc:
return die(
f"{args.action} 失败({exc.code}):{exc.message}",
"看看现在能做什么:uv run main.py actions",
)
print(ok("✓ ") + str(result.get("message", "完成")) + dim(f" ({time.time() - started:.2f}s)"))
extra = {
k: v
for k, v in result.items()
if k not in ("ok", "message", "state", "log", "action") and not isinstance(v, (dict, list))
}
if extra:
print(dim(" " + " ".join(f"{k}={v}" for k, v in extra.items())))
state = result.get("state") or {}
if state:
print(
f" 第 {state.get('day')} 天 · 金币 {state.get('gold')} · "
f"行动点 {state.get('ap')}/{state.get('max_ap')} · 净资产 {state.get('net_worth')}"
)
return 0
def cmd_batch(args) -> int:
from farm.engine import GameError
client = make_client(args)
try:
items = json.loads(args.actions)
except json.JSONDecodeError as exc:
return die(
f"actions 不是合法 JSON:{exc}",
"例子:uv run main.py batch "
'\'[{"action":"harvest_all"},{"action":"buy_feed","params":{"quantity":5}}]\'',
)
if not isinstance(items, list):
return die("actions 必须是 JSON 数组")
try:
data = client.batch(items)
except GameError as exc:
return die(f"批量执行失败:{exc.message}")
for item in data.get("results", []):
mark = ok("✓") if item.get("ok") else bad("✗")
print(f" {mark} {item.get('action')}: {item.get('message', item.get('error'))}")
print(f" 共 {data.get('done')} 个成功 / {data.get('failed')} 个失败")
return 0
def cmd_actions(args) -> int:
client = make_client(args)
state = client.snapshot()
items = client.available_actions()
print(
f"牧场 {client.farm} · 第 {state.get('day')} 天 · 金币 {state.get('gold')} · "
f"行动点 {state.get('ap')}/{state.get('max_ap')}"
)
if not items:
print(warn("• 当前没有可执行的动作"))
print(dim(" 多半是行动点用完了:时间走完一天就会恢复(默认一天 40 分钟)"))
print(dim(" 也可能是作物还没熟:uv run main.py state 看进度"))
return 0
print(f"共 {len(items)} 个候选动作:\n")
for index, item in enumerate(items, start=1):
cost = item.get("cost") or {}
cost_text = " ".join(f"{k}:{v}" for k, v in cost.items()) or "免费"
params_text = json.dumps(item["params"], ensure_ascii=False)
print(f" [{index:>2}] {item['action']:<12} {params_text:<42} ({cost_text})")
print(dim(f" {item.get('reason', '')}"))
print(dim("\n执行方式:uv run main.py do <动作名> '<参数JSON>'"))
return 0
def cmd_state(args) -> int:
client = make_client(args)
if args.json:
print(json.dumps(client.snapshot(), ensure_ascii=False, indent=2))
return 0
snap = client.snapshot()
print(f"牧场 {snap.get('farm')}")
print(
f"第 {snap.get('day')} 天"
+ (
f" · {format_duration(snap.get('seconds_to_tick'))} 后进入下一天"
if snap.get("seconds_to_tick") is not None
else " · 已暂停"
)
)
print(
f"金币 {snap.get('gold')} 等级 Lv.{snap.get('level')} "
f"({snap.get('exp')}/{snap.get('exp_needed')})"
)
print(f"行动点 {snap.get('ap')}/{snap.get('max_ap')} 饲料 {snap.get('feed')}")
print(f"净资产 {snap.get('net_worth')}")
print(f"土地 {snap.get('plot_count')}/{snap.get('max_plots')} 块")
for plot in snap.get("plots", []):
if plot["is_empty"]:
continue
status = "可收获" if plot["is_ripe"] else f"还需{plot['days_left']}天"
print(
f" {plot['position']:>2} 号地 {plot['crop_name']:<6} 生长 {plot['growth']}"
f"/{plot['grow_days']} 水分 {plot['moisture']} · {status}"
)
for animal in snap.get("animals", []):
note = (
f"{animal['product']} x{animal['pending']} 待收"
if animal["pending"]
else ("已喂食" if animal["fed"] else "未喂食")
)
print(f" #{animal['id']} {animal['name']:<4} {note}")
return 0
def cmd_log(args) -> int:
client = make_client(args)
entries = client.recent_log(limit=args.lines)
for entry in entries:
print(f"[D{entry['day']:>3}] {entry['text']}")
return 0
def cmd_watch(args) -> int:
"""实时滚动:状态每轮刷新,日志增量打印。适合盯着 AI 玩。"""
client = make_client(args, realtime=True)
print(BANNER)
print(f"正在观察牧场「{client.farm}」({client.url('')}),Ctrl+C 退出\n")
try:
last_seq = 0
while True:
snap = client.snapshot()
for entry in client.recent_log(limit=0, since=last_seq):
last_seq = max(last_seq, int(entry.get("seq", 0)))
print(f"[D{entry['day']:>3}] {entry['text']}")
online = snap.get("online")
head = (
f"第 {snap.get('day')} 天 | 金币 {snap.get('gold')} | "
f"Lv.{snap.get('level')} | 行动点 {snap.get('ap')}/{snap.get('max_ap')} | "
f"净资产 {snap.get('net_worth')}"
)
if online is not None:
head += f" | 在线 {online}"
print("\r" + head + " " * 8, end="", flush=True)
time.sleep(max(0.2, args.interval))
except KeyboardInterrupt:
print("\n\n已退出观察。")
finally:
client.stop()
return 0
def cmd_bot(args) -> int:
from farm.bot import RuleBot
client = make_client(args, realtime=False)
bot = RuleBot(client, pause=args.pause, name=args.farm)
try:
summary = bot.run(rounds=args.rounds, max_minutes=args.minutes, wait_seconds=args.wait)
except KeyboardInterrupt:
bot.stop()
print("\n已中断。")
summary = {"actions": bot.actions_executed, "failures": bot.failures}
print(json.dumps(summary, ensure_ascii=False))
print(dim("提示:机器人和你调的是同一套接口,随时可以用 uv run main.py actions 接手。"))
return 0
def cmd_farms(args) -> int:
client = make_client(args)
world = client.world()
print(
f"世界:{world.get('world')} · 已运行 {world.get('uptime', 0):.0f} 秒 · "
f"牧场 {world.get('farms')} 个 · 连接 {world.get('clients')} 个"
)
print(f"时钟:每 {world.get('tick_seconds')} 秒推进一天\n")
for line in _leaderboard_lines(world.get("leaderboard", [])):
print(" " + line)
return 0
def cmd_help(args) -> int:
client = make_client(args)
print(client.help_text())
return 0
# ---------------------------------------------------------------------- 主入口
QUICKSTART = """
快速开始
--------
0. 建好环境(只有服务端需要依赖) uv sync
1. 启动服务器(后台常驻) uv run main.py serve --daemon
2. 打开网页客户端 http://127.0.0.1:8765/
3. 终端里玩自己的牧场 uv run main.py play --farm 你的名字
4. 让 AI / 机器人一起玩 uv run main.py bot --farm ai-1
5. 收工 uv run main.py stop
改代码之后跑这些
----------------
uv run ruff check . # 静态检查(未用导入、可疑写法……)
uv run ruff format . # 统一格式
uv run pyright # 类型检查
直接调接口(AI / 脚本)
-----------------------
curl 'http://127.0.0.1:8765/api/actions?farm=default'
curl -X POST 'http://127.0.0.1:8765/api/actions?farm=default' \\
-H 'Content-Type: application/json' \\
-d '{"action":"plant","params":{"plot":1,"crop":"wheat"}}'
uv run main.py actions # 我当前能做什么
uv run main.py do harvest '{"plot":1}'
uv run main.py state --json # 机器可读的完整状态
uv run main.py watch # 实时看别人/机器人玩
SSE 实时推送(只读)
--------------------
curl -N 'http://127.0.0.1:8765/api/stream?farm=default'
服务器在状态变化时推 {"type":"state", ...},每帧一行 data: JSON;
没有消息时每 15 秒发一行 ": keep-alive" 心跳。
写操作走 HTTP:POST /api/actions,两条路改的是同一个引擎。
"""
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
handlers = {
"serve": cmd_serve,
"stop": cmd_stop,
"status": cmd_status,
"logs": cmd_logs,
"play": cmd_play,
"solo": cmd_solo,
"do": cmd_do,
"batch": cmd_batch,
"actions": cmd_actions,
"state": cmd_state,
"log": cmd_log,
"watch": cmd_watch,
"bot": cmd_bot,
"farms": cmd_farms,
"help": cmd_help,
}
if not args.command:
print(BANNER)
try:
from farm.server.daemon import DaemonManager
info = DaemonManager().status()
if info["running"]:
health = info.get("health") or {}
print(
ok("✓ 服务器正在运行")
+ f" http://{info['host']}:{info['port']}/ "
+ dim(f"牧场 {health.get('farms', 0)} 个 · 在线 {health.get('clients', 0)}")
)
else:
print(warn("• 服务器未运行"))
except Exception:
pass
print(QUICKSTART)
return 0
handler = handlers.get(args.command)
if handler is None:
return die(f"未知命令 {args.command}", "uv run main.py --help 查看全部命令")
try:
return handler(args)
except SystemExit as exc: # make_client 里的友好退出
return int(exc.code or 0)
except KeyboardInterrupt:
print("\n已中断。")
return 130
if __name__ == "__main__":
sys.exit(main())