Files
SimpleFarm/farm/engine.py
T
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

1358 lines
57 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.
"""核心游戏引擎。
这一层是纯粹的"游戏规则",不依赖终端、不依赖网络。
TUI 与 HTTP/gRPC 接口都调用同一组方法,因此三方共享完全一致的裁判逻辑。
线程模型
--------
所有对状态的读写都在 ``self._lock`` 保护下进行,TUI 主线程、
自动推进线程、HTTP 工作线程可以安全并发调用。
错误约定
--------
业务规则不满足时抛出 :class:`GameError`(带机器可读的 ``code``),
调用方据此返回 4xx JSON 或界面提示。
"""
from __future__ import annotations
import contextlib
import random
import threading
import time
from collections.abc import Callable
from typing import Any
from . import config
from .models import Animal, GameState, Plot, clamp
class GameError(Exception):
"""业务规则错误。``code`` 供 API 调用方程序化判断。"""
def __init__(self, code: str, message: str, status: int = 400) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status = status
def to_dict(self) -> dict[str, Any]:
return {"ok": False, "error": self.code, "message": self.message}
class FarmEngine:
"""农场游戏引擎。"""
def __init__(self, tick_seconds: float = config.DEFAULT_TICK_SECONDS) -> None:
self._lock = threading.RLock()
self.state = GameState()
self.tick_seconds = clamp(
float(tick_seconds), config.MIN_TICK_SECONDS, config.MAX_TICK_SECONDS
)
self._log_seq = 0
self.paused = False
# 由 Scheduler 维护:下一次自动推进的绝对时间戳(无调度器时为 None)
self.next_tick_at: float | None = None
# 状态变化回调(TUI 用它触发重绘)
self._listeners: list[Callable[[], None]] = []
# 开局先把今天与明天的天气掷好:否则第一天会顶着 DEFAULT_WEATHER 的
# 占位值开局,而"预报"字段又和它对不上。
self.state.weather = self._roll_weather()
self.state.forecast = self._roll_weather(previous=self.state.weather)
# ================================================================ 订阅
def subscribe(self, callback: Callable[[], None]) -> None:
with self._lock:
self._listeners.append(callback)
def _notify(self) -> None:
for callback in list(self._listeners):
# 监听器异常不能影响游戏主流程
with contextlib.suppress(Exception):
callback()
# ================================================================ 日志
def add_log(self, text: str, kind: str = "info") -> None:
with self._lock:
self._log_seq += 1
self.state.log.append(
{
"seq": self._log_seq,
"day": self.state.day,
"kind": kind, # info / good / bad / warn / action
"text": text,
}
)
if len(self.state.log) > config.MAX_LOG_ENTRIES:
del self.state.log[: len(self.state.log) - config.MAX_LOG_ENTRIES]
def recent_log(self, limit: int = 12, since: int = 0) -> list[dict[str, Any]]:
with self._lock:
entries = [e for e in self.state.log if e["seq"] > since]
return entries[-limit:] if limit > 0 else entries
# ================================================================ 查询
def snapshot(self) -> dict[str, Any]:
"""返回完整状态快照(供 TUI 渲染与 API 响应)。"""
with self._lock:
data = self.state.to_dict()
data["tick_seconds"] = self.tick_seconds
data["paused"] = self.paused
data["seconds_to_tick"] = self.seconds_to_tick
data["last_event"] = self.state.log[-1] if self.state.log else None
return data
@property
def seconds_to_tick(self) -> float | None:
if self.paused or self.next_tick_at is None:
return None
return round(max(0.0, self.next_tick_at - time.time()), 1)
def brief_source(self) -> dict[str, Any]:
"""精简状态(动作回执、排行榜、`status`)要用的字段。
刻意**不走** :meth:`snapshot`:那些场景只要十几个标量,而完整快照会把
每块地、每只牲畜都序列化成 dict,再被调用方整批丢掉。实测每个请求因此
白花 ~60µs;排行榜更夸张——N 个牧场就是 N 次全量序列化。
这里的键是 ``farm.server.app.BRIEF_KEYS`` 的超集:加字段时两边一起改。
"""
with self._lock:
state = self.state
# net_worth 是这里最贵的一项(要遍历地块与牲畜),算一次给两处用
worth = state.net_worth
return {
"day": state.day,
"gold": state.gold,
"level": state.level,
"exp": state.exp,
"exp_needed": state.exp_needed,
"feed": state.feed,
"plot_count": len(state.plots),
"max_plots": state.max_plots,
"next_plot_price": state.next_plot_price,
"animal_count": len(state.animals),
"max_animals": config.MAX_ANIMALS,
"net_worth": round(worth, 2),
"goal_index": config.goal_index(worth),
"seconds_to_tick": self.seconds_to_tick,
"paused": self.paused,
"tick_seconds": self.tick_seconds,
"weather": state.weather,
"weather_name": config.weather_info(state.weather)["name"],
"forecast": state.forecast,
"forecast_name": config.weather_info(state.forecast)["name"],
"storage_used": state.storage_used,
"granary_capacity": state.granary_capacity,
"granary_free": state.granary_free,
# 存货是给 AI 的"卖什么"清单;只带非空项,通常只有几行
"inventory": dict(state.inventory),
"building_count": sum(state.buildings.values()),
# 待办清单是特意为 AI 准备的:直接读模型对象,不再过一遍序列化
"ripe_plots": [p.index + 1 for p in state.plots if p.is_ripe],
"hungry_animals": [a.aid for a in state.animals if not a.fed],
"collectable_animals": [a.aid for a in state.animals if a.pending > 0],
}
def market_price(self, key: str) -> float:
with self._lock:
return round(self.state.market.get(key, 0.0), 2)
def catalog(self) -> dict[str, Any]:
"""返回所有可种植/可养殖对象及其在市场上的实时价格。"""
with self._lock:
crops = []
for key, info in config.CROPS.items():
price = self.market_price(key)
gross = price
days = int(info["grow_days"])
crops.append(
{
"key": key,
"name": info["name"],
"seed_cost": info["seed_cost"],
"grow_days": days,
"price": price,
"exp": info["exp"],
"level_req": info["level_req"],
"locked": self._is_locked(info),
"profit": round(gross - info["seed_cost"], 2),
"profit_per_day": round((gross - info["seed_cost"]) / days, 2),
}
)
animals = []
for key, info in config.ANIMALS.items():
price = self.market_price(f"animal:{key}")
feed_cost = info["feed_per_day"] * config.FEED_UNIT_PRICE * info["cycle"]
animals.append(
{
"key": key,
"name": info["name"],
"cost": info["cost"],
"product": info["product"],
"price": price,
"cycle": info["cycle"],
"feed_per_day": info["feed_per_day"],
"exp": info["exp"],
"level_req": info["level_req"],
"locked": self._is_locked(info),
"profit_per_day": round((price - feed_cost) / max(1, info["cycle"]), 2),
}
)
goods = self._goods_catalog()
return {
"crops": crops,
"animals": animals,
"goods": goods,
"buildings": self._building_catalog(),
"weather": self._weather_catalog(),
"granary": {
"used": self.state.storage_used,
"capacity": self.state.granary_capacity,
},
"feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY,
}
# ---- catalog 的三个子表。拆开是因为它们的"变化频率"各不相同:
# goods 随市价与存货变,buildings 随等级与已建数量变,weather 每天都在变。
def _goods_catalog(self) -> list[dict[str, Any]]:
"""所有可卖货物(作物 + 畜产品)的现价、基准价与仓库存量。
合成一张表是刻意的:``sell`` 的 key 空间就是这张表的 key 空间,
客户端不必自己拼 "animal:chicken" 这种前缀。
"""
state = self.state
goods: list[dict[str, Any]] = []
keys = list(config.CROPS) + [f"animal:{kind}" for kind in config.ANIMALS]
for key in keys:
price = self.market_price(key)
base = config.base_price(key)
stock = state.inventory.get(key, 0)
goods.append(
{
"key": key,
"name": config.goods_name(key),
"kind": "animal" if key.startswith("animal:") else "crop",
"price": price,
"base_price": base,
"ratio": round(price / base, 3) if base else 1.0,
"stock": stock,
"value": round(price * stock, 2),
}
)
return goods
def _building_catalog(self) -> list[dict[str, Any]]:
return [self._building_entry(self.state, key) for key in config.BUILDINGS]
def _building_entry(self, state: GameState, key: str) -> dict[str, Any]:
"""一座建筑的完整状态:报价、已建、上限、解锁。
catalog 与 available_actions 都读它——否则"报价"和"建议"两处
一旦各写一遍,迟早就出现"建议建、真去建却说太贵"这种矛盾。
"""
info = config.BUILDINGS[key]
owned = state.building_count(key)
maxed = owned >= info["max_level"]
return {
"key": key,
"name": info["name"],
"effect": info["effect"],
"cost": info["cost"],
"next_cost": -1 if maxed else config.build_price(key, owned),
"level_req": info["level_req"],
"locked": state.level < info["level_req"],
"owned": owned,
"max_level": info["max_level"],
"maxed": maxed,
}
def _weather_catalog(self) -> dict[str, Any]:
"""今天的天气、明天的预报,以及这张天气表本身(给 AI 讲清规则用)。"""
state = self.state
return {
"today": state.weather,
"today_name": config.weather_info(state.weather)["name"],
"forecast": state.forecast,
"forecast_name": config.weather_info(state.forecast)["name"],
"evaporation": self._evaporation(state, config.weather_info(state.weather)),
"table": [
{
"key": key,
"name": info["name"],
"desc": info["desc"],
"evaporation": info["evaporation"],
"water_gain": info["water_gain"],
"damage": info["damage"],
}
for key, info in config.WEATHER.items()
],
}
def available_actions(self) -> list[dict[str, Any]]:
"""列出当前**合法**的动作,供 AI 直接决策使用。
每个动作包含:``action``(接口名)、``params``(参数模板)、
``cost``(金币开销)、``reason``(为什么值得做)。
"""
with self._lock:
state = self.state
actions: list[dict[str, Any]] = []
actions.extend(self._plot_actions(state))
actions.extend(self._animal_actions(state))
actions.extend(self._market_actions(state))
actions.extend(self._investment_actions(state))
actions.extend(self._build_actions(state))
return actions
# ---- available_actions 的三个分区;拆开是为了各自能独立阅读/测试
def _plot_actions(self, state: GameState) -> list[dict[str, Any]]:
"""每块地当前能做的事:播种 / 收获 / 浇水。"""
# 能种什么只取决于等级和金币,与**具体是哪块地**无关,
# 所以整趟只筛一次,不要放进地块循环里做 O(地块 × 作物) 次判断。
plantable = [
(key, info)
for key, info in config.CROPS.items()
if not self._is_locked(info) and state.gold >= info["seed_cost"]
]
actions: list[dict[str, Any]] = []
for plot in state.plots:
position = plot.index + 1
if plot.crop is None:
for key, info in plantable:
actions.append(
{
"action": "plant",
"params": {"plot": position, "crop": key},
"cost": {"gold": info["seed_cost"]},
"reason": f"{position} 号地空闲,可种{info['name']}",
}
)
elif plot.is_ripe:
actions.append(
{
"action": "harvest",
"params": {"plot": position},
"cost": {"gold": 0},
"reason": f"{position} 号地的{config.CROPS[plot.crop]['name']}已成熟",
}
)
elif self._needs_water(plot):
reason = (
f"{position} 号地缺水,作物已停止生长"
if plot.moisture <= 0
else f"{position} 号地水分只够撑到今天,不浇明天就会停长"
)
actions.append(
{
"action": "water",
"params": {"plot": position},
"cost": {"gold": 0},
"reason": reason,
}
)
return actions
def _animal_actions(self, state: GameState) -> list[dict[str, Any]]:
"""每只牲畜当前能做的事:收取产出 / 喂食。"""
actions: list[dict[str, Any]] = []
for animal in state.animals:
if animal.pending > 0:
actions.append(
{
"action": "collect",
"params": {"animal_id": animal.aid},
"cost": {"gold": 0},
"reason": f"{animal.name}有 {animal.pending} 份{animal.product}待收取",
}
)
elif not animal.fed:
need = animal.info["feed_per_day"]
cost = 0 if state.feed >= need else need * config.FEED_EMERGENCY_PRICE
if state.gold >= cost:
actions.append(
{
"action": "feed",
"params": {"animal_id": animal.aid},
"cost": {"gold": cost},
"reason": f"{animal.name}(#{animal.aid})今天还没喂食",
}
)
return actions
def _investment_actions(self, state: GameState) -> list[dict[str, Any]]:
"""与具体地块/牲畜无关的长期投入:开垦土地、补饲料、买牲畜。"""
actions: list[dict[str, Any]] = []
price = state.next_plot_price
if price > 0 and state.gold >= price:
actions.append(
{
"action": "buy_plot",
"params": {},
"cost": {"gold": price},
"reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地",
}
)
bulk_cost = config.FEED_UNIT_PRICE * config.FEED_BULK
if state.feed < 5 and state.gold >= bulk_cost:
actions.append(
{
"action": "buy_feed",
"params": {"quantity": config.FEED_BULK},
"cost": {"gold": bulk_cost},
"reason": "饲料库存偏低,建议补货",
}
)
for key, info in config.ANIMALS.items():
if self._is_locked(info):
continue
if len(state.animals) >= config.MAX_ANIMALS or state.gold < info["cost"]:
continue
actions.append(
{
"action": "buy_animal",
"params": {"kind": key},
"cost": {"gold": info["cost"]},
"reason": f"可购买{info['name']}({info['cost']} 金币)",
}
)
return actions
def _market_actions(self, state: GameState) -> list[dict[str, Any]]:
"""该不该卖货:谷仓快满了要腾地方,价格明显高于基准价则值得出手。
这是"择时卖出"在 AI 侧的落点:把"现价 vs 基准价"直接算好写进 reason,
机器人/LLM 不需要自己记住每种货的基准价,也就能真的做出囤或抛的判断。
"""
actions: list[dict[str, Any]] = []
if not state.inventory:
return actions
used, capacity = state.storage_used, state.granary_capacity
tight = used >= capacity * 0.8
for key, count in sorted(state.inventory.items()):
if count <= 0:
continue
price = self.market_price(key)
base = config.base_price(key)
ratio = price / base if base else 1.0
reasons = [f"谷仓有 {count} 份{config.goods_name(key)},现价 {price}(基准 {base})"]
if tight:
reasons.append(f"谷仓已用 {used}/{capacity},该腾地方了")
if ratio >= 1.1:
reasons.append(f"比基准价高 {(ratio - 1) * 100:.0f}%,适合出手")
if len(reasons) == 1:
# 价格没优势、仓也不挤 → 建议继续囤着。不推荐就别出声,
# 否则候选动作里全是"卖了吧",AI 反而挑不出重点。
continue
actions.append(
{
"action": "sell",
"params": {"key": key, "quantity": count},
"cost": {"gold": 0},
"reason": ";".join(reasons),
}
)
return actions
def _build_actions(self, state: GameState) -> list[dict[str, Any]]:
"""能造什么:等级够、没建满、且钱够。"""
actions: list[dict[str, Any]] = []
for key in config.BUILDINGS:
entry = self._building_entry(state, key)
if entry["locked"] or entry["maxed"] or state.gold < entry["next_cost"]:
continue
actions.append(
{
"action": "build",
"params": {"key": key},
"cost": {"gold": entry["next_cost"]},
"reason": f"可造第 {entry['owned'] + 1} 座{entry['name']}:{entry['effect']}",
}
)
return actions
# ================================================================ 内部工具
def _is_locked(self, info: dict[str, Any]) -> bool:
"""这件作物/牲畜在当前等级是否还没解锁。
"等级门槛"这一条规则原本在 catalog / available_actions / plant /
buy_animal / _gain_exp 里各写了一遍,改文案或改判定就得追五处。
现在只在这里定义,别处一律调它。
"""
return self.state.level < info["level_req"]
def _require_level(self, info: dict[str, Any]) -> None:
"""等级不够就抛 locked。播种与买牲畜都要过这一关。"""
if self._is_locked(info):
raise GameError(
"locked",
f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}",
)
def _newly_unlocked(self, level: int) -> list[str]:
"""正好在 level 这一级解锁的作物与牲畜名字(先作物后牲畜)。"""
return [
info["name"]
for table in (config.CROPS, config.ANIMALS, config.BUILDINGS)
for info in table.values()
if info["level_req"] == level
]
def _get_plot(self, plot_no: Any) -> Plot:
"""把 1-based 的地块编号转成 Plot,越界即报错。"""
try:
number = int(plot_no)
except (TypeError, ValueError):
# 底层 ValueError 的细节对调用者没意义,只保留友好提示
raise GameError("bad_param", f"地块编号必须是整数,收到 {plot_no!r}") from None
if number < 1 or number > len(self.state.plots):
raise GameError(
"no_such_plot",
f"地块编号 {number} 不存在(当前共 {len(self.state.plots)} 块地)",
)
return self.state.plots[number - 1]
def _get_animal(self, animal_id: Any) -> Animal:
try:
aid = int(animal_id)
except (TypeError, ValueError):
raise GameError("bad_param", f"动物编号必须是整数,收到 {animal_id!r}") from None
for animal in self.state.animals:
if animal.aid == aid:
return animal
raise GameError("no_such_animal", f"没有编号为 {aid} 的动物")
def _spend_gold(self, amount: int, what: str) -> None:
amount = int(amount)
if amount < 0:
raise GameError("bad_param", "金额不能为负")
if self.state.gold < amount:
raise GameError(
"not_enough_gold",
f"金币不足:{what}需要 {amount},你只有 {self.state.gold}",
)
self.state.gold -= amount
self.state.total_spent += amount
def _earn(self, amount: float, reason: str) -> int:
earned = max(0, round(amount))
self.state.gold += earned
self.state.total_earned += earned
return earned
def _gain_exp(self, amount: int) -> list[str]:
"""增加经验并处理连续升级,返回升级提示文本。"""
messages: list[str] = []
self.state.exp += int(amount)
guard = 0
while self.state.exp >= self.state.exp_needed and guard < 50:
guard += 1
self.state.exp -= self.state.exp_needed
self.state.level += 1
bonus = config.level_up_bonus(self.state.level)
self.state.gold += bonus
unlocked = self._newly_unlocked(self.state.level)
text = f"升级到 Lv.{self.state.level}!奖励 {bonus} 金币"
if unlocked:
text += f",解锁:{'、'.join(unlocked)}"
messages.append(text)
self.add_log(text, "good")
return messages
def _roll_yield(self) -> int:
"""丰收回合判定:常规产出 1,暴击产出 2。"""
return 2 if random.random() < config.LUCKY_CHANCE else 1
def _store(self, key: str, amount: int) -> None:
"""把货物放进谷仓;装不下就抛 granary_full,且**不改动任何状态**。
先判容量再落袋,所以收获撞上满仓时作物还好好地留在地里,
玩家卖掉一点东西就能重来,不会"收了个寂寞"。
"""
amount = int(amount)
if amount <= 0:
return
state = self.state
free = state.granary_free
if amount > free:
raise GameError(
"granary_full",
f"谷仓装不下:还差 {amount - free} 格"
f"(已用 {state.storage_used}/{state.granary_capacity})。"
"先 sell 卖掉点存货,或者 build 一座谷仓。",
)
state.inventory[key] = state.inventory.get(key, 0) + amount
def _needs_water(self, plot: Plot) -> bool:
"""这块地现在该不该浇水。
* 已经干透 → 今天就长不了,必须浇;
* 水分刚好够今天蒸发、而明天又不下雨 → 明天会停长,提前浇能少浪费一天。
第二条用到了明天的预报——这就是"预报"这个字段存在的意义:
让"今天浇不浇"变成能提前算清的决策,而不是撞运气。
"""
if plot.crop is None:
return False
if plot.moisture <= 0:
return True
forecast = config.weather_info(self.state.forecast)
if forecast["water_gain"] > 0:
# 明天有雨,今天浇水等于把水倒进雨里
return False
return plot.moisture <= self._evaporation(self.state, forecast)
# ================================================================ 动作
def plant(self, plot_no: Any, crop: Any) -> dict[str, Any]:
with self._lock:
if not isinstance(crop, str) or crop not in config.CROPS:
raise GameError(
"unknown_crop",
f"未知作物 {crop!r},可选:{'、'.join(config.CROPS)}",
)
info = config.CROPS[crop]
self._require_level(info)
plot = self._get_plot(plot_no)
if plot.crop is not None:
raise GameError(
"plot_busy", f"{plot.index + 1} 号地已经种了{config.CROPS[plot.crop]['name']}"
)
self._spend_gold(info["seed_cost"], f"购买{info['name']}种子")
plot.crop = crop
plot.growth = 0.0
plot.moisture = config.PLANT_INITIAL_MOISTURE
plot.planted_day = self.state.day
self.add_log(f"在 {plot.index + 1} 号地播下{info['name']}种子", "action")
self._notify()
return {
"ok": True,
"message": (
f"{plot.index + 1} 号地已种下{info['name']},{info['grow_days']} 天后成熟"
),
"plot": plot.to_dict(),
}
def water(self, plot_no: Any) -> dict[str, Any]:
with self._lock:
plot = self._get_plot(plot_no)
if plot.crop is None:
raise GameError("plot_empty", f"{plot.index + 1} 号地是空地,不需要浇水")
if plot.moisture >= config.WATER_CAPACITY:
raise GameError(
"already_watered",
f"{plot.index + 1} 号地水分已满({plot.moisture}/{config.WATER_CAPACITY})",
)
plot.moisture = config.WATER_CAPACITY
self.add_log(f"给 {plot.index + 1} 号地浇水(水分 {config.WATER_CAPACITY})", "action")
self._notify()
return {"ok": True, "message": f"{plot.index + 1} 号地已浇水", "plot": plot.to_dict()}
def harvest(self, plot_no: Any) -> dict[str, Any]:
with self._lock:
plot = self._get_plot(plot_no)
if plot.crop is None:
raise GameError("plot_empty", f"{plot.index + 1} 号地是空地")
if not plot.is_ripe:
raise GameError(
"not_ripe",
f"{config.CROPS[plot.crop]['name']}还差 {plot.days_left} 天成熟",
)
crop_key = plot.crop
info = config.CROPS[crop_key]
quantity = self._roll_yield()
# 先入库再清地:谷仓满了会在这里抛 granary_full,作物原地不动,
# 玩家卖掉一点就能重来,不会"收了个寂寞"。
self._store(crop_key, quantity)
price = self.market_price(crop_key)
value = round(price * quantity, 2)
plot.crop = None
plot.growth = 0.0
plot.moisture = 0
self.state.total_harvested += quantity
lucky = "(丰收!)" if quantity > 1 else ""
self.add_log(f"收获 {quantity} 份{info['name']}{lucky}入库,现价 {price}", "good")
levelups = self._gain_exp(info["exp"])
self._notify()
return {
"ok": True,
"message": (
f"收获 {quantity} 份{info['name']}{lucky}入谷仓,现价 {price}"
f"(约 {value} 金币,需 sell 才能换成钱)"
),
"quantity": quantity,
"price": price,
"value": value,
"exp": info["exp"],
"level_ups": levelups,
"storage_used": self.state.storage_used,
}
def buy_animal(self, kind: Any) -> dict[str, Any]:
with self._lock:
if not isinstance(kind, str) or kind not in config.ANIMALS:
raise GameError(
"unknown_animal",
f"未知动物 {kind!r},可选:{'、'.join(config.ANIMALS)}",
)
info = config.ANIMALS[kind]
self._require_level(info)
if len(self.state.animals) >= config.MAX_ANIMALS:
raise GameError("pasture_full", f"牧场已满(上限 {config.MAX_ANIMALS} 只)")
self._spend_gold(info["cost"], f"购买{info['name']}")
animal = Animal(kind)
self.state.animals.append(animal)
self.add_log(f"买了一只{info['name']}(#{animal.aid}),{info['cost']} 金币", "action")
self._notify()
return {
"ok": True,
"message": f"购入{info['name']} #{animal.aid},记得每天喂食",
"animal": animal.to_dict(),
}
def feed(self, animal_id: Any, quantity: Any = None) -> dict[str, Any]:
with self._lock:
animal = self._get_animal(animal_id)
if animal.fed:
raise GameError("already_fed", f"{animal.name} #{animal.aid} 今天已经吃过了")
need = int(animal.info["feed_per_day"])
if quantity is None:
quantity = need
try:
quantity = int(quantity)
except (TypeError, ValueError):
raise GameError("bad_param", "喂食数量必须是整数") from None
if quantity <= 0:
raise GameError("bad_param", "喂食数量必须大于 0")
if quantity > need:
quantity = need # 多喂无益,自动收窄到实际需求
if self.state.feed < quantity:
shortfall = quantity - self.state.feed
cost = shortfall * config.FEED_EMERGENCY_PRICE
self._spend_gold(cost, f"临时采购 {shortfall} 份饲料")
self.state.feed += shortfall
self.add_log(f"饲料不足,临时高价补货 {shortfall} 份(-{cost} 金币)", "warn")
self.state.feed -= quantity
animal.fed = True
animal.starve = 0
self.add_log(f"喂了{animal.name} #{animal.aid} {quantity} 份饲料", "action")
self._notify()
return {
"ok": True,
"message": f"{animal.name} #{animal.aid} 吃饱了,剩余饲料 {self.state.feed}",
"feed_left": self.state.feed,
"animal": animal.to_dict(),
}
def collect(self, animal_id: Any) -> dict[str, Any]:
with self._lock:
animal = self._get_animal(animal_id)
if animal.pending <= 0:
raise GameError(
"nothing_to_collect",
f"{animal.name} #{animal.aid} 暂时没有可收取的{animal.product}"
f"(还需 {animal.days_to_product} 天)",
)
quantity = animal.pending
key = f"animal:{animal.kind}"
# 与收获同理:先确认装得下,再清空待收,免得产出凭空消失
self._store(key, quantity)
animal.pending = 0
price = self.market_price(key)
value = round(price * quantity, 2)
self.state.total_collected += quantity
self.add_log(
f"收取 {quantity} 份{animal.product}({animal.name} #{animal.aid})入库,"
f"现价 {price}",
"good",
)
levelups = self._gain_exp(animal.info["exp"] * quantity)
self._notify()
return {
"ok": True,
"message": (
f"收取 {quantity} 份{animal.product}入谷仓,现价 {price}(约 {value} 金币)"
),
"quantity": quantity,
"price": price,
"value": value,
"level_ups": levelups,
"storage_used": self.state.storage_used,
}
def buy_feed(self, quantity: Any = config.FEED_BULK) -> dict[str, Any]:
with self._lock:
try:
quantity = int(quantity)
except (TypeError, ValueError):
raise GameError("bad_param", "饲料数量必须是整数") from None
if quantity <= 0:
raise GameError("bad_param", "饲料数量必须大于 0")
if quantity > config.MAX_FEED_PURCHASE:
raise GameError(
"too_many",
f"单次最多购买 {config.MAX_FEED_PURCHASE} 份饲料(请求 {quantity})",
)
cost = quantity * config.FEED_UNIT_PRICE
self._spend_gold(cost, f"购买 {quantity} 份饲料")
self.state.feed += quantity
self.add_log(f"购买 {quantity} 份饲料(-{cost} 金币)", "action")
self._notify()
return {
"ok": True,
"message": f"购入 {quantity} 份饲料,库存 {self.state.feed}",
"feed": self.state.feed,
}
def buy_plot(self) -> dict[str, Any]:
with self._lock:
price = self.state.next_plot_price
if price < 0:
raise GameError(
"plot_limit",
f"土地已达当前等级上限({self.state.max_plots} 块),升级后可继续开垦",
)
self._spend_gold(price, "开垦新土地")
plot = Plot(len(self.state.plots))
self.state.plots.append(plot)
self.state.plots_bought += 1
self.add_log(f"开垦了第 {plot.index + 1} 块地(-{price} 金币)", "action")
self._notify()
return {
"ok": True,
"message": f"新增 {plot.index + 1} 号地,共 {len(self.state.plots)} 块",
"plot": plot.to_dict(),
"next_plot_price": self.state.next_plot_price,
}
def sell(self, key: Any = None, quantity: Any = None) -> dict[str, Any]:
"""把谷仓里的货物按**当前市价**卖掉。
``key`` 省略 = 能卖的都卖;``quantity`` 省略 = 该货物全卖。
这是存货变回金币的唯一出口,所以 ``granary_full`` 时它是唯一的解法——
``available_actions`` 会在谷仓快满或价格见好时主动推荐它。
"""
with self._lock:
state = self.state
if key is None:
if quantity is not None:
raise GameError("bad_param", "不指定 key 时不能只卖指定数量")
if not state.inventory:
raise GameError("nothing_to_sell", "谷仓还是空的,没东西可卖")
return self._sell_everything(state)
if not isinstance(key, str) or config.goods_info(key) is None:
raise GameError(
"unknown_goods",
f"未知货物 {key!r},可选:{'、'.join(sorted(state.market))}",
)
name = config.goods_name(key)
count = state.inventory.get(key, 0)
if count <= 0:
raise GameError("nothing_to_sell", f"谷仓里没有{name}")
if quantity is None:
quantity = count
try:
quantity = int(quantity)
except (TypeError, ValueError):
raise GameError("bad_param", "卖出数量必须是整数") from None
if quantity <= 0:
raise GameError("bad_param", "卖出数量必须大于 0")
quantity = min(quantity, count)
price = self.market_price(key)
earned = self._earn(price * quantity, name)
left = count - quantity
if left > 0:
state.inventory[key] = left
else:
del state.inventory[key]
text = f"卖出 {quantity} 份{name}(单价 {price}),+{earned} 金币"
self.add_log(text, "good")
self._notify()
return {
"ok": True,
"message": text,
"key": key,
"quantity": quantity,
"price": price,
"earned": earned,
"gold": state.gold,
"storage_used": state.storage_used,
}
def _sell_everything(self, state: GameState) -> dict[str, Any]:
"""一键清仓。带上明细,回执才能说清"到底卖了什么、各卖了多少"。"""
sold: list[dict[str, Any]] = []
total = 0
for key in sorted(state.inventory):
count = state.inventory[key]
price = self.market_price(key)
earned = round(price * count)
total += earned
sold.append(
{
"key": key,
"name": config.goods_name(key),
"quantity": count,
"price": price,
"earned": earned,
}
)
state.inventory.clear()
self._earn(total, "卖出存货")
names = "、".join(f"{item['name']}×{item['quantity']}" for item in sold)
text = f"卖出 {names},共 +{total} 金币"
self.add_log(text, "good")
self._notify()
return {
"ok": True,
"message": text,
"earned": total,
"sold": sold,
"gold": state.gold,
"storage_used": state.storage_used,
}
def build(self, key: Any) -> dict[str, Any]:
"""造一座建筑。价格与解锁等级见 ``config.BUILDINGS``。"""
with self._lock:
if not isinstance(key, str) or key not in config.BUILDINGS:
raise GameError(
"unknown_building",
f"未知建筑 {key!r},可选:{'、'.join(config.BUILDINGS)}",
)
entry = self._building_entry(self.state, key)
if entry["locked"]:
raise GameError(
"locked",
f"{entry['name']}需要 Lv.{entry['level_req']},当前 Lv.{self.state.level}",
)
if entry["maxed"]:
raise GameError("build_limit", f"{entry['name']}已建满({entry['max_level']} 座)")
self._spend_gold(entry["next_cost"], f"建造{entry['name']}")
self.state.buildings[key] = entry["owned"] + 1
built = self._building_entry(self.state, key)
self.add_log(
f"建成第 {built['owned']} 座{built['name']}(-{entry['next_cost']} 金币)",
"good",
)
self._notify()
return {
"ok": True,
"message": (
f"{built['name']} 已建成({built['owned']}/{built['max_level']}):"
f"{built['effect']}"
),
"building": built,
"gold": self.state.gold,
}
def pause(self, paused: bool | None = None) -> dict[str, Any]:
with self._lock:
self.paused = bool(paused) if paused is not None else not self.paused
self.add_log("时间已暂停" if self.paused else "时间继续流动", "warn")
self._notify()
return {"ok": True, "paused": self.paused}
def reset(self, keep_tick: bool = True) -> dict[str, Any]:
with self._lock:
tick = self.tick_seconds
self.state = GameState()
self._log_seq = 0
self.paused = False
if keep_tick:
self.tick_seconds = tick
self.state.weather = self._roll_weather()
self.state.forecast = self._roll_weather(previous=self.state.weather)
self.add_log("新的一天,新的开始。欢迎来到 SimpleFarm!", "info")
self._notify()
return {"ok": True, "message": "农场已重置"}
def save(self, path: str | None = None) -> dict[str, Any]:
"""把当前状态写入磁盘。
单机模式下由 TUI 直接调用;服务器模式里存档位置由牧场会话决定,
但保留同名方法是为了让界面代码不必区分"本地/远程"。
"""
from .storage import save_state
target = save_state(self.state, path)
self.add_log(f"已存档到 {target}", "info")
return {"ok": True, "message": f"已存档到 {target}", "path": target}
# ================================================================ 天气与建筑
def _roll_weather(self, previous: str | None = None) -> str:
"""按权重掷一种天气;同一种不连着出现(最多重掷 WEATHER_REROLLS 次)。
权重已经表达了稀有度,连续性约束是额外补的一层手感——防的是
"一连五天干旱"这种既难玩、又不像真天气的序列。
"""
keys = list(config.WEATHER)
weights = [float(config.WEATHER[key]["weight"]) for key in keys]
pick = keys[0]
for _ in range(config.WEATHER_REROLLS):
pick = random.choices(keys, weights=weights, k=1)[0]
if pick != previous:
return pick
return pick
def _resolve_weather(self, state: GameState, forced: str | None) -> dict[str, Any]:
"""定下今天的天气,并把预报翻到明天,返回今天天气的定义。
"预报"是刻意做成**提前一天可知**的:这样"明天要干旱,今晚先浇一遍"
才是一个算得清的决策,而不是撞运气。正常玩法走预报;``forced``
只给测试与调试用(钉住"今天下雨"这类场景,否则断言会随掷骰抖动)。
"""
if forced is not None and forced in config.WEATHER:
today = forced
elif state.forecast in config.WEATHER:
today = state.forecast
else:
today = self._roll_weather()
state.weather = today
state.forecast = self._roll_weather(previous=today)
return config.WEATHER[today]
def _evaporation(self, state: GameState, info: dict[str, Any]) -> int:
"""当天实际蒸发量;温室让蒸发减半(向下取整)。
这是温室"省水"的那一半效果,另一半是免疫暴风雨(见 _storm_damage)。
"""
evaporate = int(info["evaporation"])
if state.building_count("greenhouse"):
evaporate //= 2
return evaporate
def _run_buildings(self, state: GameState) -> list[str]:
"""结算建筑的每日自动动作。
顺序是一条流水线:浇水(水井/喷灌)保住作物 → 收割机把熟了的收进
谷仓 → 磨坊把陈粮磨成饲料 → 喂食器喂牲口 → 售货机趁高价清库存。
共同的设计底线:**只替代玩家本来要花工夫做的事,绝不自动花钱**
——喂食器缺饲料停摆、收割机遇满仓停摆,都宁可停也不硬来。
"""
return (
self._wells(state)
+ self._sprinklers(state)
+ self._harvesters(state)
+ self._mill(state)
+ self._feeders(state)
+ self._vendor(state)
)
def _sprinklers(self, state: GameState) -> list[str]:
"""喷灌系统:水井的全量版,一口气浇完所有干透的地(限 1 座)。"""
if state.building_count("sprinkler") <= 0:
return []
events: list[str] = []
for plot in state.plots:
if plot.crop is None or plot.moisture > 0:
continue
plot.moisture = config.WATER_CAPACITY
events.append(f"喷灌系统给 {plot.index + 1} 号地浇了水")
return events
def _harvesters(self, state: GameState) -> list[str]:
"""自动收割机:每天替玩家收熟地,入库逻辑与手动收获完全一致。
刻意**不给经验**——机器收的不算你的劳动,手动收获才涨等级,
这是自动化换手感时留的手动价值。谷仓满了就停摆(同喂食器哲学),
作物留在地里,卖了就能恢复。
"""
quota = state.building_count("harvester") * config.HARVESTER_PLOTS
if quota <= 0:
return []
events: list[str] = []
for plot in state.plots:
if quota <= 0:
break
if plot.crop is None or not plot.is_ripe:
continue
crop_key = plot.crop
quantity = self._roll_yield()
try:
self._store(crop_key, quantity)
except GameError:
events.append("谷仓满了,自动收割机停摆")
break
plot.crop = None
plot.growth = 0.0
plot.moisture = 0
state.total_harvested += quantity
quota -= 1
events.append(
f"自动收割机把 {plot.index + 1} 号地的{config.CROPS[crop_key]['name']}收进了谷仓"
)
return events
def _mill(self, state: GameState) -> list[str]:
"""饲料磨坊:把 1 份库存作物磨成饲料,挑**最便宜的**下手。
排在喂食器前面:早上磨好,喂食器当天就能吃上。只磨谷仓里的存货,
绝不自动买粮——"自动系统悄悄花光金币"是本项目的红线。
"""
if state.building_count("mill") <= 0 or not state.inventory:
return []
key = min(state.inventory, key=config.base_price)
state.inventory[key] -= 1
if state.inventory[key] <= 0:
del state.inventory[key]
state.feed += config.MILL_OUTPUT
return [f"饲料磨坊把 1 份{config.goods_name(key)}磨成了 {config.MILL_OUTPUT} 份饲料"]
def _vendor(self, state: GameState) -> list[str]:
"""自动售货机:市价高于基准价的库存自动出手,越贵越先卖。
卖出是赚钱不是花钱,所以它是唯一"主动碰金币"的自动化——但每天
有份数上限,且基准价以下的存货绝不碰,留着等高价。
"""
quota = state.building_count("vendor") * config.VENDOR_QUOTA
if quota <= 0 or not state.inventory:
return []
offers = []
for key in sorted(state.inventory):
base = config.base_price(key)
if base <= 0:
continue
ratio = self.market_price(key) / base
if ratio > 1.0:
offers.append((ratio, key))
offers.sort(reverse=True)
events: list[str] = []
for _, key in offers:
if quota <= 0:
break
qty = min(state.inventory[key], quota)
earned = self._earn(self.market_price(key) * qty, config.goods_name(key))
left = state.inventory[key] - qty
if left > 0:
state.inventory[key] = left
else:
del state.inventory[key]
quota -= qty
events.append(f"自动售货机卖出 {qty} 份{config.goods_name(key)},+{earned} 金币")
return events
def _wells(self, state: GameState) -> list[str]:
quota = state.building_count("well")
if quota <= 0:
return []
events: list[str] = []
for plot in state.plots:
if quota <= 0:
break
if plot.crop is None or plot.moisture > 0:
continue
plot.moisture = config.WATER_CAPACITY
quota -= 1
events.append(f"水井给 {plot.index + 1} 号地浇了水")
return events
def _feeders(self, state: GameState) -> list[str]:
quota = state.building_count("feeder")
if quota <= 0:
return []
events: list[str] = []
for animal in state.animals:
if quota <= 0:
break
if animal.fed:
continue
need = int(animal.info["feed_per_day"])
if state.feed < need:
# 只报一次就停:饲料见底时每只都报一条会刷爆日志
events.append("饲料不够,自动喂食器停摆")
break
state.feed -= need
animal.fed = True
animal.starve = 0
quota -= 1
events.append(f"自动喂食器喂了{animal.name} #{animal.aid}")
return events
def _storm_damage(self, state: GameState, info: dict[str, Any]) -> list[str]:
"""暴风雨:每块**未成熟**的作物独立判定,被毁则地块直接清空。
成熟的作物不参与判定——"等你来收的东西被天灾吃掉"只会让人关掉游戏。
温室建成后整段失效。
"""
damage = float(info["damage"])
if damage <= 0 or state.building_count("greenhouse"):
return []
events: list[str] = []
for plot in state.plots:
if plot.crop is None or plot.is_ripe or random.random() >= damage:
continue
name = config.CROPS[plot.crop]["name"]
plot.crop = None
plot.growth = 0.0
plot.moisture = 0
plot.planted_day = 0
text = f"暴风雨毁了 {plot.index + 1} 号地的{name}"
events.append(text)
self.add_log(text, "bad")
return events
# ================================================================ 时间推进
def advance_day(self, reason: str = "自动", weather: str | None = None) -> list[str]:
"""推进游戏内一天,返回本日发生的事件文本。
``weather`` 是留给测试与调试的显式入口:不传就按"昨天的预报"结算
(正常玩法走这条),传了就强制使用该天气。
"""
with self._lock:
state = self.state
info = self._resolve_weather(state, weather)
state.day += 1
events: list[str] = []
# ---- 建筑自动化。必须排在生长结算之前:水井救的正是"今天就断水"的
# 那块地,晚一步它今天就白等了。
events.extend(self._run_buildings(state))
# ---- 作物生长:水分消耗改由天气决定(0~2),浇水于是变成看天吃饭
evaporate = self._evaporation(state, info)
gain = int(info["water_gain"])
for plot in state.plots:
if plot.crop is None:
continue
if gain:
# 雨天/暴风雨先补水再蒸发,等于免费替你浇了一遍
plot.moisture = min(config.WATER_CAPACITY, plot.moisture + gain)
if plot.moisture <= 0:
events.append(f"{plot.index + 1} 号地缺水,作物停止生长")
continue
plot.moisture = max(0, plot.moisture - evaporate)
plot.growth = min(plot.grow_days, plot.growth + 1)
if plot.is_ripe:
events.append(f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟")
# ---- 天灾:暴风雨只毁**还没成熟**的作物
events.extend(self._storm_damage(state, info))
# ---- 牲畜
dead: list[Animal] = []
for animal in state.animals:
if animal.fed:
animal.fed = False
animal.starve = 0
animal.progress += 1
if animal.progress >= animal.cycle:
animal.progress = 0
animal.pending += 1
else:
animal.starve += 1
if animal.starve >= 3:
dead.append(animal)
elif animal.starve == 2:
events.append(f"{animal.name} #{animal.aid} 已经两天没吃东西了")
for animal in dead:
state.animals.remove(animal)
events.append(f"{animal.name} #{animal.aid} 因长期饥饿离你而去")
self.add_log(f"{animal.name} #{animal.aid} 因长期饥饿离开农场", "bad")
# ---- 市场波动
self._shuffle_market()
# ---- 写入日志(最多 4 条,避免刷屏)
for text in events[:4]:
self.add_log(text, "warn")
today = f"第 {state.day} 天开始({reason}),今日{info['name']}"
if events:
self.add_log(f"{today},新的一天开始了", "info")
else:
self.add_log(f"{today},一切安好", "info")
self._notify()
return events
def _shuffle_market(self) -> None:
"""每天让市场价格围绕基准价波动。"""
vol = config.PRICE_VOLATILITY
for key, info in config.CROPS.items():
base = float(info["base_price"])
factor = random.uniform(1 - vol, 1 + vol)
# 轻微均值回归,避免价格长期漂移
current = self.state.market.get(key, base)
blended = current * 0.35 + base * 0.65
self.state.market[key] = round(clamp(blended * factor, base * 0.55, base * 1.55), 2)
for key, info in config.ANIMALS.items():
base = float(info["base_price"])
factor = random.uniform(1 - vol, 1 + vol)
current = self.state.market.get(f"animal:{key}", base)
blended = current * 0.35 + base * 0.65
self.state.market[f"animal:{key}"] = round(
clamp(blended * factor, base * 0.55, base * 1.55), 2
)
def tick_decision(
paused: bool, next_tick_at: float | None, tick_seconds: float, now: float
) -> tuple[bool, float]:
"""根据当前时刻决定"是否该推进一天",并给出新的调度时间点。
这是 :class:`Scheduler`(单机)与 :class:`farm.server.session.FarmClock`
(服务器)共用的唯一 tick 决策,两处不再各写一遍:
* 暂停中 → 顺延,保证恢复后仍有完整的一个 tick;
* 没有调度点(从未启动/被清空)→ 从现在起重新计时;
* 到点 → 推进,并把下一次调度设为 now + tick_seconds;
* 未到点 → 维持原调度时间。
返回 ``(should_advance, next_tick_at)``。
"""
if paused or next_tick_at is None:
return False, now + tick_seconds
if now >= next_tick_at:
return True, now + tick_seconds
return False, next_tick_at
class Scheduler:
"""自动推进游戏时间的后台线程(单机模式;服务器版见 farm.server.session.FarmClock)。"""
def __init__(self, engine: FarmEngine, enabled: bool = True) -> None:
self.engine = engine
self.enabled = enabled
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
if not self.enabled or self._thread is not None:
return
self.engine.next_tick_at = time.time() + self.engine.tick_seconds
self._thread = threading.Thread(target=self._run, name="farm-clock", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
self.engine.next_tick_at = None
def _run(self) -> None:
while not self._stop.wait(0.2):
engine = self.engine
advance, engine.next_tick_at = tick_decision(
engine.paused, engine.next_tick_at, engine.tick_seconds, time.time()
)
if advance:
with contextlib.suppress(Exception):
engine.advance_day(reason="时间流逝")