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

748 lines
31 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._last_sleep = 0.0
self.paused = False
# 由 Scheduler 维护:下一次自动推进的绝对时间戳(无调度器时为 None)
self.next_tick_at: float | None = None
# 状态变化回调(TUI 用它触发重绘)
self._listeners: list[Callable[[], None]] = []
# ================================================================ 订阅
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 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.state.level < info["level_req"],
"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.state.level < info["level_req"],
"profit_per_day": round((price - feed_cost) / max(1, info["cycle"]), 2),
}
)
return {
"crops": crops,
"animals": animals,
"feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY,
"ap_cost": config.AP_COST,
}
def available_actions(self) -> list[dict[str, Any]]:
"""列出当前**合法**的动作,供 AI 直接决策使用。
每个动作包含:``action``(接口名)、``params``(参数模板)、
``cost``(金币/行动点开销)、``reason``(为什么值得做)。
"""
with self._lock:
actions: list[dict[str, Any]] = []
state = self.state
# ---- 收获 / 浇水 / 播种
for plot in state.plots:
if plot.crop is None:
for key, info in config.CROPS.items():
if state.level < info["level_req"]:
continue
if state.gold < info["seed_cost"] or state.ap < config.AP_COST["plant"]:
continue
actions.append(
{
"action": "plant",
"params": {"plot": plot.index + 1, "crop": key},
"cost": {"gold": info["seed_cost"], "ap": config.AP_COST["plant"]},
"reason": f"{plot.index + 1} 号地空闲,可种{info['name']}",
}
)
elif plot.is_ripe:
actions.append(
{
"action": "harvest",
"params": {"plot": plot.index + 1},
"cost": {"gold": 0, "ap": config.AP_COST["harvest"]},
"reason": (
f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟"
),
}
)
elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]:
actions.append(
{
"action": "water",
"params": {"plot": plot.index + 1},
"cost": {"gold": 0, "ap": config.AP_COST["water"]},
"reason": f"{plot.index + 1} 号地缺水,作物已停止生长",
}
)
# ---- 动物
for animal in state.animals:
if animal.pending > 0:
actions.append(
{
"action": "collect",
"params": {"animal_id": animal.aid},
"cost": {"gold": 0, "ap": config.AP_COST["collect"]},
"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 and state.ap >= config.AP_COST["feed"]:
actions.append(
{
"action": "feed",
"params": {"animal_id": animal.aid},
"cost": {"gold": cost, "ap": config.AP_COST["feed"]},
"reason": f"{animal.name}(#{animal.aid})今天还没喂食",
}
)
# ---- 投资类
price = state.next_plot_price
if price > 0 and state.gold >= price:
actions.append(
{
"action": "buy_plot",
"params": {},
"cost": {"gold": price, "ap": config.AP_COST["buy_plot"]},
"reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地",
}
)
if state.feed < 5 and state.gold >= config.FEED_UNIT_PRICE * config.FEED_BULK:
actions.append(
{
"action": "buy_feed",
"params": {"quantity": config.FEED_BULK},
"cost": {
"gold": config.FEED_UNIT_PRICE * config.FEED_BULK,
"ap": config.AP_COST["buy_feed"],
},
"reason": "饲料库存偏低,建议补货",
}
)
for key, info in config.ANIMALS.items():
if state.level < info["level_req"]:
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"], "ap": config.AP_COST["buy_animal"]},
"reason": f"可购买{info['name']}({info['cost']} 金币)",
}
)
return actions
# ================================================================ 内部工具
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_ap(self, action: str) -> None:
cost = config.AP_COST.get(action, 0)
if cost and self.state.ap < cost:
raise GameError(
"no_ap",
f"行动点不足:{action} 需要 {cost} 点,当前只剩 {self.state.ap} 点。"
f"按 N 休息或等待下一天。",
)
if cost:
self.state.ap -= cost
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 = [
info["name"]
for info in config.CROPS.values()
if info["level_req"] == self.state.level
] + [
info["name"]
for info in config.ANIMALS.values()
if info["level_req"] == 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 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]
if self.state.level < info["level_req"]:
raise GameError(
"locked",
f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}",
)
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']}种子")
self._spend_ap("plant")
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})",
)
self._spend_ap("water")
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]
self._spend_ap("harvest")
quantity = self._roll_yield()
price = self.market_price(crop_key)
earned = self._earn(price * quantity, info["name"])
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},+{earned} 金币", "good")
levelups = self._gain_exp(info["exp"])
self._notify()
return {
"ok": True,
"message": f"收获 {quantity} 份{info['name']},获得 {earned} 金币",
"quantity": quantity,
"earned": earned,
"exp": info["exp"],
"level_ups": levelups,
}
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]
if self.state.level < info["level_req"]:
raise GameError(
"locked",
f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}",
)
if len(self.state.animals) >= config.MAX_ANIMALS:
raise GameError("pasture_full", f"牧场已满(上限 {config.MAX_ANIMALS} 只)")
self._spend_gold(info["cost"], f"购买{info['name']}")
self._spend_ap("buy_animal")
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._spend_ap("feed")
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} 天)",
)
self._spend_ap("collect")
quantity = animal.pending
animal.pending = 0
price = self.market_price(f"animal:{animal.kind}")
earned = self._earn(price * quantity, animal.product)
self.state.total_collected += quantity
self.add_log(
f"收取 {quantity} 份{animal.product}({animal.name} #{animal.aid}),"
f"+{earned} 金币",
"good",
)
levelups = self._gain_exp(animal.info["exp"] * quantity)
self._notify()
return {
"ok": True,
"message": f"收取 {quantity} 份{animal.product},获得 {earned} 金币",
"quantity": quantity,
"earned": earned,
"level_ups": levelups,
}
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, "开垦新土地")
self._spend_ap("buy_plot")
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 sleep(self) -> dict[str, Any]:
"""手动推进一天,带冷却避免刷时间。"""
with self._lock:
elapsed = time.time() - self._last_sleep
if elapsed < config.SLEEP_COOLDOWN:
wait = config.SLEEP_COOLDOWN - elapsed
raise GameError("cooldown", f"刚休息过,请等待 {wait:.1f} 秒", status=429)
self._last_sleep = time.time()
events = self.advance_day(reason="休息")
return {"ok": True, "message": f"进入第 {self.state.day} 天", "events": events}
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._last_sleep = 0.0
self.paused = False
if keep_tick:
self.tick_seconds = tick
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 advance_day(self, reason: str = "自动") -> list[str]:
"""推进游戏内一天,返回本日发生的事件文本。"""
with self._lock:
state = self.state
state.day += 1
state.ap = state.max_ap
events: list[str] = []
# ---- 作物生长
for plot in state.plots:
if plot.crop is None:
continue
if plot.moisture > 0:
plot.moisture -= 1
plot.growth = min(plot.grow_days, plot.growth + 1)
if plot.is_ripe:
events.append(
f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟"
)
else:
events.append(f"{plot.index + 1} 号地缺水,作物停止生长")
# ---- 牲畜
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")
if events:
self.add_log(f"第 {state.day} 天开始({reason}),行动点已恢复", "info")
else:
self.add_log(f"第 {state.day} 天开始({reason}),一切安好", "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
)
class Scheduler:
"""自动推进游戏时间的后台线程。"""
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
if engine.paused:
# 暂停时顺延,保证恢复后仍有完整的一个 tick
engine.next_tick_at = time.time() + engine.tick_seconds
continue
deadline = engine.next_tick_at
if deadline is None:
deadline = time.time() + engine.tick_seconds
if time.time() >= deadline:
with contextlib.suppress(Exception):
engine.advance_day(reason="时间流逝")
engine.next_tick_at = time.time() + engine.tick_seconds
else:
engine.next_tick_at = deadline