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:
snowy-yang committed 2026-09-19 17:02:33 +08:00
1 parent a92c29ead2
commit cf24cb43f1
21 files changed
+1349 -112

No files matched your search

+165
View File
@@ -46,6 +46,8 @@ AP_COST = {
"buy_animal": 0,
"buy_plot": 0,
"buy_feed": 0,
"build": 0,
"sell": 0,
}
@@ -193,6 +195,169 @@ MIN_TICK_SECONDS = 3.0 # 调试用:把一天缩到几秒
MAX_TICK_SECONDS = 86400.0 # 上限给到一整天,想多慢都不会被夹住
# --------------------------------------------------------------------------
# 天气
# --------------------------------------------------------------------------
# 每天随机一种天气,直接改写"水分"这条曲线:
# * evaporation 决定作物每天掉多少水分(0~2),浇水于是从"常量消耗"变成看天吃饭;
# * water_gain 是自动补水,雨天/暴风雨相当于免费替你浇了一遍;
# * damage 只作用于**还没成熟**的地块——已经熟了、正等你收的东西不该被天灾吃掉。
# 权重合计 100;同一种天气不连着出现(重掷,见 WEATHER_REROLLS),免得一连旱一周。
#
# key : 内部标识(存档与 API 使用)
# name : 中文名
# weight : 出现权重
# evaporation : 当天水分蒸发量
# water_gain : 当天自动补水(封顶 WATER_CAPACITY)
# damage : 每块未成熟地块被毁的独立概率
# desc : 给玩家与 AI 看的一句话
WEATHER: dict[str, dict[str, Any]] = {
"sunny": {
"name": "晴",
"weight": 30,
"evaporation": 1,
"water_gain": 0,
"damage": 0.0,
"desc": "阳光充足,水分正常蒸发",
},
"cloudy": {
"name": "多云",
"weight": 27,
"evaporation": 0,
"water_gain": 0,
"damage": 0.0,
"desc": "日头不毒,作物几乎不失水",
},
"rain": {
"name": "雨",
"weight": 24,
"evaporation": 0,
"water_gain": WATER_CAPACITY,
"damage": 0.0,
"desc": "雨水充足,所有作物自动补满水分",
},
"drought": {
"name": "干旱",
"weight": 12,
"evaporation": 2,
"water_gain": 0,
"damage": 0.0,
"desc": "水分蒸发加倍,一天不浇就可能停长",
},
"storm": {
"name": "暴风雨",
"weight": 7,
"evaporation": 0,
"water_gain": WATER_CAPACITY,
"damage": 0.12,
"desc": "暴雨伴狂风,未成熟的作物可能被毁",
},
}
DEFAULT_WEATHER = "sunny" # 新牧场开局天气
WEATHER_REROLLS = 3 # 同一天气最多重掷几次
def weather_info(key: str | None) -> dict[str, Any]:
"""取某种天气的完整定义;未知 key 退回默认天气。"""
return WEATHER.get(key or "", WEATHER[DEFAULT_WEATHER])
# --------------------------------------------------------------------------
# 谷仓(仓储 + 自由买卖)
# --------------------------------------------------------------------------
# 收获不再当场按市价卖掉,而是先进谷仓;卖不卖、什么时候卖由玩家决定。
# 这样"市场每天波动"才第一次变成真决策:囤到高位再出,或者急着用钱低价走。
GRANARY_BASE = 40 # 初始仓容
GRANARY_PER_LEVEL = 10 # 每升 1 级 +10
GRANARY_PER_UPGRADE = 25 # 每座谷仓建筑 +25
MAX_GRANARY = 400 # 仓容硬上限
# --------------------------------------------------------------------------
# 建筑
# --------------------------------------------------------------------------
# 给金币一个"买地 / 买牲畜"之外的长期出口,而且买的是**自动化**:
# 花一笔钱,换掉一件每天都要手动做的事。
#
# key : 内部标识
# name : 中文名
# cost : 第一座的价格(第 owned+1 座 = cost * BUILD_PRICE_GROWTH ** owned)
# level_req : 解锁等级
# max_level : 最多能造几座
# effect : 一句话说明(原样出现在 catalog 与界面上)
BUILDINGS: dict[str, dict[str, Any]] = {
"granary": {
"name": "谷仓",
"cost": 300,
"level_req": 1,
"max_level": 5,
"effect": f"仓储上限 +{GRANARY_PER_UPGRADE}",
},
"well": {
"name": "水井",
"cost": 400,
"level_req": 2,
"max_level": 3,
"effect": "每天自动给 1 块干透的地浇满",
},
"feeder": {
"name": "自动喂食器",
"cost": 650,
"level_req": 3,
"max_level": 3,
"effect": "每天自动喂 1 只牲畜(只吃库存饲料,不自动花钱)",
},
"greenhouse": {
"name": "温室",
"cost": 1500,
"level_req": 5,
"max_level": 1,
"effect": "作物不再被暴风雨损毁,水分蒸发减半",
},
}
BUILD_PRICE_GROWTH = 1.6 # 同类建筑第 n 座的价格系数
def build_price(key: str, owned: int) -> int:
"""第 owned+1 座建筑的价格(owned 是这类建筑已建的数量)。"""
return int(BUILDINGS[key]["cost"] * (BUILD_PRICE_GROWTH**owned))
# --------------------------------------------------------------------------
# 货物(可买卖的东西)
# --------------------------------------------------------------------------
# 谷仓、市场、sell 动作共用同一套 key:作物直接用作物 key(wheat),
# 畜产品用 animal:<牲畜 key>(animal:chicken)。命名收敛在一处,
# 客户端就不必自己拼前缀,也不会出现「两个地方各拼一种」的漂移。
def goods_info(key: str) -> dict[str, Any] | None:
"""货物 key 对应的静态定义;未知 key 返回 None。"""
if key.startswith("animal:"):
return ANIMALS.get(key.split(":", 1)[1])
return CROPS.get(key)
def goods_name(key: str) -> str:
"""货物 key -> 中文名。畜产品给的是产出名(鸡蛋)而不是牲畜名(鸡)。"""
if key.startswith("animal:"):
info = ANIMALS.get(key.split(":", 1)[1])
return str(info["product"]) if info else key
info = CROPS.get(key)
return str(info["name"]) if info else key
def base_price(key: str) -> float:
"""货物 key 的基准价,也就是市场每天围绕它波动的那个中心。"""
info = goods_info(key)
return float(info["base_price"]) if info else 0.0
# --------------------------------------------------------------------------
# 进度目标
# --------------------------------------------------------------------------