Files
SimpleFarm/farm/engine.py
T
snowy-yang f8e093c98f feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径
三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。

实时通道:WebSocket -> SSE
- hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye,
  250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。
- 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 +
  客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。
- 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。

服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2
- 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式
  http="zttp",否则 Config.load() 直接 raise。
- 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖),
  不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。
- 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例
  「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。
- 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被
  except OSError 吞掉,导致每次正常退出都留 stale pid 文件。

节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」
- DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。
- engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉,
  时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。
- 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。

键盘:修复方向键/空格/Tab/ESC 全部静默失效
- keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串
  (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。
- 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。

测试:重建 500 项
- test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 /
  test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑,
  所以是全绿而不是靠跳过伪装。
- 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」
  (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要
  预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。

优化:先量后改,依据与前后数字都在 tools/bench.py
- 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化
  整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、
  GET /api/world -31%、POST /api/actions?full=1 -16%。
- 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、
  build_parser 与 available_actions 分组、__main__.main 160->7 行。
- 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的
  broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json;
  main.py 的 _add_endpoint_args/_leaderboard_lines。
- tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。

门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。
2026-09-19 12:55:45 +08:00

814 lines
34 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]] = []
# ================================================================ 订阅
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,
"ap": state.ap,
"max_ap": state.max_ap,
"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,
# 待办清单是特意为 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),
}
)
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:
state = self.state
actions: list[dict[str, Any]] = []
actions.extend(self._plot_actions(state))
actions.extend(self._animal_actions(state))
actions.extend(self._investment_actions(state))
return actions
# ---- available_actions 的三个分区;拆开是为了各自能独立阅读/测试
def _plot_actions(self, state: GameState) -> list[dict[str, Any]]:
"""每块地当前能做的事:播种 / 收获 / 浇水。"""
# 能种什么只取决于等级、金币和行动点,与**具体是哪块地**无关,
# 所以整趟只筛一次,不要放进地块循环里做 O(地块 × 作物) 次判断。
plant_ap = config.AP_COST["plant"]
plantable = [
(key, info)
for key, info in config.CROPS.items()
if not self._is_locked(info)
and state.gold >= info["seed_cost"]
and state.ap >= plant_ap
]
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"], "ap": plant_ap},
"reason": f"{position} 号地空闲,可种{info['name']}",
}
)
elif plot.is_ripe:
actions.append(
{
"action": "harvest",
"params": {"plot": position},
"cost": {"gold": 0, "ap": config.AP_COST["harvest"]},
"reason": f"{position} 号地的{config.CROPS[plot.crop]['name']}已成熟",
}
)
elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]:
actions.append(
{
"action": "water",
"params": {"plot": position},
"cost": {"gold": 0, "ap": config.AP_COST["water"]},
"reason": f"{position} 号地缺水,作物已停止生长",
}
)
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, "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})今天还没喂食",
}
)
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, "ap": config.AP_COST["buy_plot"]},
"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, "ap": config.AP_COST["buy_feed"]},
"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"], "ap": config.AP_COST["buy_animal"]},
"reason": f"可购买{info['name']}({info['cost']} 金币)",
}
)
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)
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_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"等一天的时间自然走完就会恢复。",
)
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 = 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 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']}种子")
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]
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']}")
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 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.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
)
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="时间流逝")