为什么:游戏的时间(40 分钟一天、只能自然流动)本身就是唯一的行动节奏 阀门,再叠一层"每天 10 点行动点"纯属重复惩罚——浇水还要体力既不拟真 也不好玩。删掉之后,稀缺的只剩时间本身,放置玩法的定位反而更纯粹。 改动范围(引擎到界面连根拔): - config 删 START_AP/AP_COST;models 删 ap 字段与 max_ap,存档 payload 里的旧 ap 键被 from_dict 自然忽略; - engine 删 _spend_ap 与全部 8 个调用点、每日恢复逻辑与日志; available_actions 不再按行动点筛,cost 只剩金币; - storage 建表去掉 ap 列,老库在 connect() 里 ALTER TABLE DROP COLUMN 无痛迁移(实测 saves/farm.db 已自动迁完); - bot 删 AP 分支(无 AP 后 available_actions 恒可用);client/TUI/CLI/ README/帮助文本同步清理。 网页端顺带两件事: - "下一天"倒计时改为客户端本地走秒——SSE 只在有变化时推送,以前倒计时 只在推送瞬间跳变,现在每秒都在动(记基点 + 1s 定时器补齐); - 字体优化:页面字体栈补齐 MiSans/HarmonyOS Sans/PingFang,正文字号 14→15,统计数字 tabular-nums;canvas 小字统一字体栈并加粗提对比度。 测试适配:删 2 个 AP 专属用例(无 AP 可测)、改名/收敛 5 个, 532 项全绿(skip 39),ruff/pyright 干净。真机验证:老库迁移、 统计条无行动点、倒计时逐秒递减(39:04→39:02)、动作不再报 no_ap。
735 lines
27 KiB
Python
735 lines
27 KiB
Python
#!/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="存档目录(里面是 SQLite 库 farm.db),默认 <项目>/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('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('net_worth')}"
|
||
)
|
||
if not items:
|
||
print(warn("• 当前没有可执行的动作"))
|
||
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')})"
|
||
)
|
||
day_weather = snap.get("weather_name")
|
||
if day_weather:
|
||
desc = snap.get("weather_desc")
|
||
tomorrow = snap.get("forecast_name")
|
||
print(f"天气 {day_weather}({desc})· 明日 {tomorrow}")
|
||
print(f"饲料 {snap.get('feed')}")
|
||
print(f"净资产 {snap.get('net_worth')}")
|
||
print(f"土地 {snap.get('plot_count')}/{snap.get('max_plots')} 块")
|
||
|
||
used = snap.get("storage_used") or 0
|
||
capacity = snap.get("granary_capacity") or 0
|
||
tail = " ← 已满,先 sell 卖点存货" if (snap.get("granary_free") or 0) <= 0 else ""
|
||
print(f"谷仓 {used}/{capacity} 格{tail}")
|
||
market = snap.get("market") or {}
|
||
for key, count in sorted((snap.get("inventory") or {}).items()):
|
||
price = float(market.get(key, 0.0))
|
||
base = config.base_price(key)
|
||
ratio = (price / base - 1) * 100 if base else 0.0
|
||
print(
|
||
f" {config.goods_name(key):<6} x{count:<4} 现价 {price:>7.1f}"
|
||
f"(基准 {base:>6.1f},{ratio:+.0f}%)"
|
||
)
|
||
built = {k: v for k, v in (snap.get("buildings") or {}).items() if v}
|
||
if built:
|
||
names = {k: v["name"] for k, v in config.BUILDINGS.items()}
|
||
print("建筑 " + " ".join(f"{names[k]}×{v}" for k, v in built.items()))
|
||
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")
|
||
day_no = snap.get("day")
|
||
weather = snap.get("weather_name") or "—"
|
||
used = snap.get("storage_used") or 0
|
||
capacity = snap.get("granary_capacity") or 0
|
||
head = (
|
||
f"第 {day_no} 天 {weather} | 金币 {snap.get('gold')} | "
|
||
f"Lv.{snap.get('level')} | "
|
||
f"仓储 {used}/{capacity} | 净资产 {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 do sell '{"key":"wheat"}' # 卖货才换得到金币
|
||
uv run main.py do build '{"key":"well"}' # 造建筑换自动化
|
||
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())
|