Files
SimpleFarm/farm/bot.py
T
snowy-yang fcd4cb4649 style: 引入 ruff,474 项告警清零
配置见 pyproject.toml:
- line-length=100:贴着现有代码定。若用默认的 88 会重排 122 行,
  噪音淹没真正有价值的修复。
- 启用 E/W/F/I/UP/B/C4/SIM/RET/PERF/RUF;**不**启用 PTH。因为本项目的路径
  多是"字符串身份"——要塞进 subprocess 命令行、写进 JSON 存档、打印给用户,
  全换 pathlib 只是包一层再拆一层,换来 70 处纯风格改动。
- 忽略 RUF001/002/003:中文全角标点是正确写法,不是"歧义字符"。
- 忽略 PERF203:剩下两处都是"逐项容错"语义(逐个牧场存档要立刻上报失败、
  收获全部要跳过失败地块)。把 try 提到循环外会毁掉"一项失败不影响其它项"
  这个行为,是错的优化。

自动修复 358 项:Dict/List/Optional 换成 PEP 585/604 写法、导入排序、
失效的 noqa、空占位 f-string 等。

手工修复的(这些是真问题,不是风格偏好):
- B904 x9:except 中 raise 补异常链。刻意区分——网络/IO 失败用 from exc 保留
  根因;参数解析失败用 from None,因为底层 ValueError 对玩家只是噪音。
- RUF012 x2:StateView 的 plots/animals 原本是**类级共享可变列表**,
  改成在 __init__ 里初始化(apply({}) 会提前返回,所以必须显式摆好)。
- F841:TUI 里一个赋值后再没用过的死变量。
- SIM105 x11:try/except/pass 统一改成 contextlib.suppress。
- SIM115:日志句柄改用 with。Popen 已把 fd 复制给子进程,父进程可安全关闭。
- E501 x7:拆超长行;main.py 那处顺手提取变量,避免超长嵌套表达式。
- E741:把 l/r 改成 left_line/right_line。
- 其余:RUF046 / PERF401 / RET504 / RUF005 / UP031 / B007。

ruff format 重排 24 个文件。

验证:97 项测试全绿。
2026-09-18 21:18:22 +08:00

225 lines
8.1 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.
"""规则机器人玩家——"AI 也能玩"的最小可用示例。
它的意义不在于"内置了 AI",而在于证明一件事:
**服务器把状态和合法动作都摊开了,外面是谁在决策根本不重要。**
这个机器人做决策的方式是"给候选动作打分",而一个 LLM 做决策的方式是
"读同一份 ``/api/actions``,用语言模型选一个"。两者的接口调用序列一模一样。
所以:
* :meth:`RuleBot.choose` 里那段打分逻辑 = 可以整体替换成 LLM 的"策略函数";
* :meth:`RuleBot.step` 的执行路径 = LLM 将来要走的那条路(读动作 → 执行 → 看回执)。
它只依赖 :mod:`farm.client`,因此既能在本机跑,也能从别处连上服务器跑——
换个 ``--url`` 就是一个独立的外挂玩家。
"""
from __future__ import annotations
import random
import threading
import time
from typing import Any
from .engine import GameError
class RuleBot:
"""一个会种地、会养殖、会投资的规则机器人。"""
def __init__(
self, client, verbose: bool = True, pause: float = 0.25, name: str = "bot"
) -> None:
self.client = client # 任何与 RemoteFarm 接口一致的对象
self.verbose = verbose
self.pause = pause
self.name = name
self.done: list[str] = []
self.failures = 0
self.actions_executed = 0
self._stop = threading.Event()
# ------------------------------------------------------------ 输出
def say(self, text: str) -> None:
if self.verbose:
print(f"[{time.strftime('%H:%M:%S')}] {text}", flush=True)
def stop(self) -> None:
self._stop.set()
@property
def stopped(self) -> bool:
return self._stop.is_set()
# ------------------------------------------------------------ 打分
def score(
self, action: dict[str, Any], state: dict[str, Any], catalog: dict[str, Any]
) -> float:
"""给一个候选动作打分。分数越高越先执行。
这张表就是整个"策略":先做免费的,再喂饱牲畜,再补水,再扩张。
换成 LLM 时,把这里换成一次模型调用即可,其余代码不用动。
"""
name = action.get("action")
params = action.get("params") or {}
gold = state.get("gold", 0)
feed = state.get("feed", 0)
ap = state.get("ap", 0)
if name == "harvest":
return 100.0
if name == "collect":
return 96.0
if name == "feed":
return 92.0
if name == "water":
return 84.0
if name == "plant":
# 优先种"每天净收益"最高的作物
crop = params.get("crop")
entry = next((c for c in catalog.get("crops", []) if c["key"] == crop), None)
per_day = entry["profit_per_day"] if entry else 0.0
return 60.0 + max(0.0, per_day)
if name == "buy_feed":
# 饲料快见底才补,且别把钱花光
if feed <= 2 and gold >= 200:
return 40.0
return 5.0
if name == "buy_plot":
price = state.get("next_plot_price") or 0
# 地太贵就别买,留下周转资金
if price > 0 and gold >= price * 3:
return 34.0
return 4.0
if name == "buy_animal":
info = next(
(a for a in catalog.get("animals", []) if a["key"] == params.get("kind")), None
)
cost = info["cost"] if info else 0
if cost and gold >= cost * 3 and ap >= 1:
return 30.0 + (info.get("profit_per_day", 0) if info else 0)
return 3.0
return 1.0
def choose(self) -> dict[str, Any] | None:
"""在服务器给出的合法动作里挑一个最优的。"""
actions = self.client.available_actions()
if not actions:
return None
state = self.client.snapshot()
catalog = self.client.catalog()
best = max(actions, key=lambda item: self.score(item, state, catalog))
if self.score(best, state, catalog) <= 1.0:
return None
return best
# ------------------------------------------------------------ 单步
def step(self) -> str | None:
"""执行一个动作,返回动作名;没得做或做不了返回 None。"""
action = self.choose()
if action is None:
return None
name = action["action"]
params = action.get("params") or {}
try:
result = self.client._action(name, params) # 走通用动作入口
except GameError as exc:
self.failures += 1
if exc.code == "cooldown":
# 休息有冷却;这是正常节奏,不该记成失败
self.failures -= 1
self.say(f"冷却中… {exc.message}")
time.sleep(1.0)
return None
self.say(f"✗ {name} 失败({exc.code}):{exc.message}")
return None
self.actions_executed += 1
self.done.append(name)
self.say(f"✓ {name} {params} → {result.get('message', '')}")
if self.pause:
time.sleep(self.pause)
return name
def rest(self) -> bool:
"""行动点用完时推进一天。"""
try:
result = self.client.sleep()
except GameError as exc:
if exc.code == "cooldown":
time.sleep(1.0)
return True
self.say(f"✗ sleep 失败:{exc.message}")
return False
self.say(f"— {result.get('message', '进入下一天')}")
return True
# ------------------------------------------------------------ 主循环
def run(self, rounds: int = 0, max_minutes: float = 0.0, idle_limit: int = 8) -> dict[str, Any]:
"""跑机器人。
``rounds`` 为 0 表示不限轮数;``max_minutes`` 为 0 表示不限时间。
"""
deadline = (time.time() + max_minutes * 60) if max_minutes > 0 else None
start_day = self.client.snapshot().get("day", 1)
idle = 0
round_no = 0
self.say(f"机器人 {self.name} 上线(第 {start_day} 天,{self.client.transport_label()})")
while not self._stop.is_set():
round_no += 1
if rounds and round_no > rounds:
break
if deadline and time.time() > deadline:
self.say("到时间了,收工。")
break
state = self.client.snapshot()
if state.get("ap", 0) <= 0:
# 行动点用完:休息推进一天,顺便等冷却
self.rest()
continue
did = self.step()
if did is None:
idle += 1
if idle >= idle_limit:
self.say("暂时没有可做的事,休息一天再看。")
self.rest()
idle = 0
else:
time.sleep(0.4)
else:
idle = 0
end_day = self.client.snapshot().get("day", start_day)
summary = {
"name": self.name,
"days": end_day - start_day,
"actions": self.actions_executed,
"failures": self.failures,
"net_worth": self.client.snapshot().get("net_worth"),
"gold": self.client.snapshot().get("gold"),
}
self.say(
f"机器人 {self.name} 下线:跑了 {summary['days']} 天、"
f"执行 {summary['actions']} 个动作、净资产 {summary['net_worth']}"
)
return summary
def random_bot(client, **kwargs) -> RuleBot:
"""随机策略机器人:用来做对照实验(证明"有策略"确实比乱按强)。"""
bot = RuleBot(client, **kwargs)
original = bot.choose
def choose() -> dict[str, Any] | None:
actions = client.available_actions()
if not actions:
return None
return random.choice(actions)
bot.choose = choose # type: ignore[assignment]
bot.name = kwargs.get("name", "random")
del original
return bot