Files
snowy-yang 15d290a60a 移除行动点系统,倒计时本地走秒,字体优化
为什么:游戏的时间(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。
2026-09-25 22:44:08 +08:00

258 lines
9.7 KiB
Python
Raw Permalink 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`` 就是一个独立的外挂玩家。
节奏完全由服务器时钟决定。服务器**没有**"手动推进一天"的接口,没事可做时
它只能等时间流动(或收工)::meth:`RuleBot.run` 的 ``wait_seconds``
就是"最多等多久",默认 0——一天 40 分钟,干等不划算。
"""
from __future__ import annotations
import random
import threading
import time
from typing import Any
from .engine import GameError
from .models import format_duration
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)
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:
return 30.0 + (info.get("profit_per_day", 0) if info else 0)
return 3.0
if name == "sell":
# 卖货是唯一的现金流入口(收获不再直接给钱),所以优先级跟着"缺钱"
# 和"仓快满"走:仓满了不腾地方,下一茬根本收不进来。
used = state.get("storage_used", 0) or 0
capacity = state.get("granary_capacity", 1) or 1
if gold < 150:
return 90.0
if used >= capacity * 0.8:
return 88.0
return 30.0
if name == "build":
key = params.get("key")
used = state.get("storage_used", 0) or 0
capacity = state.get("granary_capacity", 1) or 1
if key == "granary" and used >= capacity * 0.7:
return 46.0
if key in ("well", "feeder"):
# 这两个买的是"以后不用天天手动做",放置玩法里稳赚
return 38.0
return 22.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
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 wait_for_next_day(self, timeout: float) -> bool:
"""没得可做时等时间自己流动到下一天。
返回 True 表示等到了新的一天;``timeout`` 为 0 表示不等待、直接收工
(机器人本来就该"把能做的做完就走",不像真人会挂机守着)。
"""
state = self.client.snapshot()
if state.get("paused"):
self.say("— 时间被暂停了,再等也不会有下一天,先收工。")
return False
if timeout <= 0:
left = format_duration(state.get("seconds_to_tick"))
self.say(f"— 暂时没事可做,距离下一天还有 {left},先收工。")
return False
start_day = state.get("day")
self.say(f"— 等时间流动(最多 {timeout:.0f} 秒)…")
deadline = time.time() + timeout
while not self._stop.is_set() and time.time() < deadline:
time.sleep(0.25)
day = self.client.snapshot().get("day")
if day != start_day:
self.say(f"— 天亮了:第 {day} 天")
return True
self.say("— 没等到下一天,先收工。")
return False
# ------------------------------------------------------------ 主循环
def run(
self,
rounds: int = 0,
max_minutes: float = 0.0,
idle_limit: int = 8,
wait_seconds: float = 0.0,
) -> dict[str, Any]:
"""跑机器人。
``rounds`` 为 0 表示不限轮数;``max_minutes`` 为 0 表示不限时间;
``wait_seconds`` 是"没事可做时最多等多久"(等下一天),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
did = self.step()
if did is None:
idle += 1
if idle >= idle_limit:
if not self.wait_for_next_day(wait_seconds):
break
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