chore: 工程化改造前的基线快照

在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前,
先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。

此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。
This commit is contained in:
snowy-yang committed 2026-09-18 21:12:40 +08:00
commit a7725f8812
35 files changed
+11871

No files matched your search

+627
View File
@@ -0,0 +1,627 @@
#!/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 farm import config
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="python main.py",
description="SimpleFarm:终端农场游戏 + 小型多人游戏服务器(HTTP/WebSocket 供 AI 调用)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=BANNER,
)
sub = parser.add_subparsers(dest="command", metavar="<命令>")
# ---- 服务器
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="停止后台服务器(会先落盘存档)")
p_stop.add_argument("--port", type=int, default=8765)
p_stop.add_argument("--host", default="127.0.0.1")
p_stop.add_argument("--token", default=None)
p_stop.add_argument("--force", action="store_true", help="超时后强制结束进程")
p_status = sub.add_parser("status", help="查看服务器状态")
p_status.add_argument("--port", type=int, default=8765)
p_status.add_argument("--host", default="127.0.0.1")
p_status.add_argument("--token", default=None)
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)
# ---- 客户端
p_play = sub.add_parser("play", help="打开终端界面(TUI)连接服务器玩")
p_play.add_argument("--url", default=DEFAULT_URL, help=f"服务器地址,默认 {DEFAULT_URL}")
p_play.add_argument("--farm", default="default", help="牧场名,默认 default")
p_play.add_argument("--token", default=None)
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="不用 WebSocket,改走 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_farms = sub.add_parser("farms", help="查看服务器上的牧场排行榜")
_add_client_args(p_farms)
p_help = sub.add_parser("help", help="玩法与接口说明")
_add_client_args(p_help)
return parser
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 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"
"先启动服务器:python main.py serve --daemon\n"
"再确认地址端口:python main.py status"
)
raise SystemExit(die(f"无法连接服务器:{exc.message}", hint))
# ---------------------------------------------------------------------- 子命令
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(f" 启动:python 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 row in world["leaderboard"][:5]:
print(f" #{row['rank']} {row['farm']:<14} 净资产 {row['net_worth']:>8.0f}"
f" · 第 {row['day']} 天 · Lv.{row['level']} · 在线 {row['online']}")
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(
"当前终端不是交互式终端,无法启动界面。",
"在真实终端里运行,或用命令行模式:python 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}", '例子:python 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}",
"看看现在能做什么:python 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}",
'例子:python 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(" 试试:python main.py do sleep (推进一天)"))
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 "免费"
print(f" [{index:>2}] {item['action']:<12} {json.dumps(item['params'], ensure_ascii=False):<42}"
f" ({cost_text})")
print(dim(f" {item.get('reason', '')}"))
print(dim("\n执行方式:python 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" · {snap.get('seconds_to_tick')}s 后进入下一天"
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 玩。"""
import threading
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)
except KeyboardInterrupt:
bot.stop()
print("\n已中断。")
summary = {"actions": bot.actions_executed, "failures": bot.failures}
print(json.dumps(summary, ensure_ascii=False))
print(dim("提示:机器人和你调的是同一套接口,随时可以用 python 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")
print(f" {'#':>2} {'牧场':<14} {'净资产':>9} {'金币':>7} {'天':>4} {'等级':>4} {'在线':>4}")
for row in world.get("leaderboard", []):
print(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}")
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 # 类型检查
uv run python -m unittest discover -s tests # 全部测试
直接调接口(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 # 实时看别人/机器人玩
WebSocket(实时推送)
---------------------
ws://127.0.0.1:8765/ws?farm=default
服务器在状态变化时推送 {"type":"state", ...};
也可以在 WS 上直接发动作:{"type":"action","action":"plant",
"params":{"plot":1,"crop":"wheat"},"id":1},会收到 {"type":"result",...} 回执。
"""
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}", "python 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())