feat: 加天气 / 谷仓 / 建筑三套系统,收获改为入库 + 择时卖出
动手前先修掉了两处「死机制」,新增的三个系统各自激活一个: * 天气:小麦 2 天成熟、初始水分 2、每天只掉 1 点,等于永远不需要浇水。 现在水分消耗由天气决定(晴 1 / 多云 0 / 干旱 2,雨天反补 3),而 `state.forecast` 把明天的天气提前告诉你——「今晚浇不浇」成了能算的决策。 5 种天气、权重合计 100、同种不连出;暴风雨会毁未熟作物,温室免疫。 * 谷仓:市场波动原本纯装饰(收获当场按市价变现,没有择时空间)。现在收获与 收取只进 `state.inventory`,靠新增的 `sell` 换金币,价格波动才真正参与决策。 仓满时 `_store()` 抛 `granary_full` 且不改状态,作物原样留在地里。 * 建筑:新增 `build`,买的是「以后不用天天手动做」——水井自动浇干地、 喂食器自动喂食(只吃库存饲料,绝不自动花钱)、温室、谷仓扩容。 每天在 `advance_day` 里先于生长执行,同类越造越贵(×1.6)。 存档升到 `SAVE_VERSION=2`,配 `MIN_SAVE_VERSION=1` 向后兼容,旧档缺的新字段 一律走默认值,不作废玩家手里的档。`advance_day(weather=...)` 是留给测试的 确定性入口。 顺带修两个真 bug: * `daemon.py` 三处 `makedirs(os.path.dirname(path))`:`--pid-file pid.txt` 这种只给文件名的用法会让 dirname 变成空串,启动阶段直接崩。抽出 `ensure_parent_dir()` 兜底。 * `RemoteFarm.snapshot()` 是本地缓存,非实时客户端轮询会一直读到旧数据, 看起来像服务器卡死。补 docstring 指明要自己调 `refresh()`。 质量门禁:ruff format/check 全绿、pyright 0 错误、503 项测试 OK(skip 35)、 bench 无回归(brief 19.5µs / snapshot 48.5µs / POST full=1 400µs)。 另起真实服务器(--tick 3 + 预置存档)跑了 38 项端到端验收,全过。
This commit is contained in:
1 parent
a92c29ead2
commit
cf24cb43f1
21 files changed
+1349
-112
No files matched your search
+495
-26
@@ -57,6 +57,11 @@ class FarmEngine:
|
||||
# 状态变化回调(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:
|
||||
@@ -137,6 +142,16 @@ class FarmEngine:
|
||||
"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],
|
||||
@@ -188,14 +203,98 @@ class FarmEngine:
|
||||
"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,
|
||||
"ap_cost": config.AP_COST,
|
||||
}
|
||||
|
||||
# ---- 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 直接决策使用。
|
||||
|
||||
@@ -207,7 +306,9 @@ class FarmEngine:
|
||||
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 的三个分区;拆开是为了各自能独立阅读/测试
|
||||
@@ -245,13 +346,18 @@ class FarmEngine:
|
||||
"reason": f"{position} 号地的{config.CROPS[plot.crop]['name']}已成熟",
|
||||
}
|
||||
)
|
||||
elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]:
|
||||
elif self._needs_water(plot) and state.ap >= config.AP_COST["water"]:
|
||||
reason = (
|
||||
f"{position} 号地缺水,作物已停止生长"
|
||||
if plot.moisture <= 0
|
||||
else f"{position} 号地水分只够撑到今天,不浇明天就会停长"
|
||||
)
|
||||
actions.append(
|
||||
{
|
||||
"action": "water",
|
||||
"params": {"plot": position},
|
||||
"cost": {"gold": 0, "ap": config.AP_COST["water"]},
|
||||
"reason": f"{position} 号地缺水,作物已停止生长",
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
return actions
|
||||
@@ -321,6 +427,59 @@ class FarmEngine:
|
||||
)
|
||||
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, "ap": config.AP_COST["sell"]},
|
||||
"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"], "ap": config.AP_COST["build"]},
|
||||
"reason": f"可造第 {entry['owned'] + 1} 座{entry['name']}:{entry['effect']}",
|
||||
}
|
||||
)
|
||||
return actions
|
||||
|
||||
# ================================================================ 内部工具
|
||||
def _is_locked(self, info: dict[str, Any]) -> bool:
|
||||
"""这件作物/牲畜在当前等级是否还没解锁。
|
||||
@@ -343,7 +502,7 @@ class FarmEngine:
|
||||
"""正好在 level 这一级解锁的作物与牲畜名字(先作物后牲畜)。"""
|
||||
return [
|
||||
info["name"]
|
||||
for table in (config.CROPS, config.ANIMALS)
|
||||
for table in (config.CROPS, config.ANIMALS, config.BUILDINGS)
|
||||
for info in table.values()
|
||||
if info["level_req"] == level
|
||||
]
|
||||
@@ -424,6 +583,45 @@ class FarmEngine:
|
||||
"""丰收回合判定:常规产出 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:
|
||||
@@ -489,8 +687,11 @@ class FarmEngine:
|
||||
self._spend_ap("harvest")
|
||||
|
||||
quantity = self._roll_yield()
|
||||
# 先入库再清地:谷仓满了会在这里抛 granary_full,作物原地不动,
|
||||
# 玩家卖掉一点就能重来,不会"收了个寂寞"。
|
||||
self._store(crop_key, quantity)
|
||||
price = self.market_price(crop_key)
|
||||
earned = self._earn(price * quantity, info["name"])
|
||||
value = round(price * quantity, 2)
|
||||
|
||||
plot.crop = None
|
||||
plot.growth = 0.0
|
||||
@@ -498,16 +699,21 @@ class FarmEngine:
|
||||
self.state.total_harvested += quantity
|
||||
|
||||
lucky = "(丰收!)" if quantity > 1 else ""
|
||||
self.add_log(f"收获 {quantity} 份{info['name']}{lucky},+{earned} 金币", "good")
|
||||
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']},获得 {earned} 金币",
|
||||
"message": (
|
||||
f"收获 {quantity} 份{info['name']}{lucky}入谷仓,现价 {price}"
|
||||
f"(约 {value} 金币,需 sell 才能换成钱)"
|
||||
),
|
||||
"quantity": quantity,
|
||||
"earned": earned,
|
||||
"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]:
|
||||
@@ -584,24 +790,31 @@ class FarmEngine:
|
||||
)
|
||||
self._spend_ap("collect")
|
||||
quantity = animal.pending
|
||||
key = f"animal:{animal.kind}"
|
||||
# 与收获同理:先确认装得下,再清空待收,免得产出凭空消失
|
||||
self._store(key, quantity)
|
||||
animal.pending = 0
|
||||
price = self.market_price(f"animal:{animal.kind}")
|
||||
earned = self._earn(price * quantity, animal.product)
|
||||
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"+{earned} 金币",
|
||||
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},获得 {earned} 金币",
|
||||
"message": (
|
||||
f"收取 {quantity} 份{animal.product}入谷仓,现价 {price}(约 {value} 金币)"
|
||||
),
|
||||
"quantity": quantity,
|
||||
"earned": earned,
|
||||
"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]:
|
||||
@@ -650,6 +863,132 @@ class FarmEngine:
|
||||
"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._spend_ap("build")
|
||||
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
|
||||
@@ -665,6 +1004,8 @@ class FarmEngine:
|
||||
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": "农场已重置"}
|
||||
@@ -681,28 +1022,155 @@ class FarmEngine:
|
||||
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._feeders(state)
|
||||
|
||||
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 = "自动") -> list[str]:
|
||||
"""推进游戏内一天,返回本日发生的事件文本。"""
|
||||
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
|
||||
state.ap = state.max_ap
|
||||
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 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:
|
||||
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] = []
|
||||
@@ -732,10 +1200,11 @@ class FarmEngine:
|
||||
# ---- 写入日志(最多 4 条,避免刷屏)
|
||||
for text in events[:4]:
|
||||
self.add_log(text, "warn")
|
||||
today = f"第 {state.day} 天开始({reason}),今日{info['name']}"
|
||||
if events:
|
||||
self.add_log(f"第 {state.day} 天开始({reason}),行动点已恢复", "info")
|
||||
self.add_log(f"{today},行动点已恢复", "info")
|
||||
else:
|
||||
self.add_log(f"第 {state.day} 天开始({reason}),一切安好", "info")
|
||||
self.add_log(f"{today},一切安好", "info")
|
||||
|
||||
self._notify()
|
||||
return events
|
||||
|
||||
Reference in new issue
Block a user