Files
SimpleFarm/farm/config.py
T
snowy-yang cf24cb43f1 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 项端到端验收,全过。
2026-09-19 17:02:33 +08:00

381 lines
12 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.
"""游戏平衡参数与静态定义。
本模块只描述"世界是什么样的"(作物表、动物表、经济参数),
不包含任何状态或逻辑,方便单独调参。
"""
from __future__ import annotations
from typing import Any
# --------------------------------------------------------------------------
# 基础经济参数
# --------------------------------------------------------------------------
START_GOLD = 120 # 初始金币
START_LEVEL = 1 # 初始等级
START_AP = 10 # 每天行动点上限
START_FEED = 5 # 初始饲料库存
BASE_PLOTS = 6 # 初始土地数量
MAX_PLOTS = 16 # 土地数量硬上限
PLOT_BASE_PRICE = 200 # 第一块新地的价格
PLOT_PRICE_GROWTH = 1.75 # 每多买一块,价格乘以该系数
FEED_UNIT_PRICE = 6 # 主动购买饲料的单价
FEED_EMERGENCY_PRICE = 9 # 自动补货(缺饲料时喂食)的惩罚单价
FEED_BULK = 10 # 一次购买饲料的默认批量
WATER_CAPACITY = 3 # 一次浇水可支撑的天数
PLANT_INITIAL_MOISTURE = 2 # 播种后地块自带的水分
PRICE_VOLATILITY = 0.18 # 每天价格波动幅度(±18%)
LUCKY_CHANCE = 0.12 # 丰收暴击概率(产出翻倍)
MAX_LOG_ENTRIES = 300 # 内存中保留的事件条数上限
MAX_FEED_PURCHASE = 200 # 单次购买饲料的数量上限
# 每级提供的土地上限(基础 6 + 2/级,封顶 MAX_PLOTS)
PLOTS_PER_LEVEL = 2
# 行动点消耗表
AP_COST = {
"plant": 1,
"water": 1,
"harvest": 0,
"feed": 1,
"collect": 0,
"buy_animal": 0,
"buy_plot": 0,
"buy_feed": 0,
"build": 0,
"sell": 0,
}
# 经验曲线:升到 level+1 所需经验
def exp_to_next(level: int) -> int:
"""返回从 level 升到 level+1 所需的经验值。"""
return int(80 * (max(1, level) ** 1.6))
# 升级奖励金币
def level_up_bonus(level: int) -> int:
"""升到 level 时发放的金币奖励。"""
return 80 + 40 * level
# --------------------------------------------------------------------------
# 作物表
# --------------------------------------------------------------------------
# key : 内部标识(API 与存档使用)
# name : 中文名
# seed_cost : 种子价格
# grow_days : 成熟所需生长天数(需要水分支撑)
# base_price : 收获物的基础售价
# exp : 每次收获获得的经验
# level_req : 解锁所需等级
CROPS: dict[str, dict[str, Any]] = {
"wheat": {
"name": "小麦",
"seed_cost": 5,
"grow_days": 2,
"base_price": 14,
"exp": 3,
"level_req": 1,
},
"carrot": {
"name": "胡萝卜",
"seed_cost": 8,
"grow_days": 3,
"base_price": 26,
"exp": 5,
"level_req": 1,
},
"tomato": {
"name": "番茄",
"seed_cost": 16,
"grow_days": 4,
"base_price": 52,
"exp": 9,
"level_req": 2,
},
"corn": {
"name": "玉米",
"seed_cost": 28,
"grow_days": 6,
"base_price": 95,
"exp": 14,
"level_req": 3,
},
"pumpkin": {
"name": "南瓜",
"seed_cost": 45,
"grow_days": 8,
"base_price": 180,
"exp": 22,
"level_req": 4,
},
"strawberry": {
"name": "草莓",
"seed_cost": 70,
"grow_days": 10,
"base_price": 320,
"exp": 35,
"level_req": 5,
},
}
# 生长阶段阈值(按生长进度 0.0~1.0 划分)
GROWTH_STAGES = [
(0.00, "已播种"),
(0.30, "幼苗"),
(0.65, "生长中"),
(1.00, "已成熟"),
]
# --------------------------------------------------------------------------
# 动物表
# --------------------------------------------------------------------------
# key : 内部标识
# name : 中文名
# cost : 购买价格
# product : 产出物标识(中文名)
# base_price : 产出物基础售价
# cycle : 产出周期(天)
# feed_per_day: 每天消耗饲料数
# exp : 每次收集产出获得的经验
# level_req : 解锁所需等级
ANIMALS: dict[str, dict[str, Any]] = {
"chicken": {
"name": "鸡",
"cost": 90,
"product": "鸡蛋",
"base_price": 15,
"cycle": 1,
"feed_per_day": 1,
"exp": 4,
"level_req": 1,
},
"sheep": {
"name": "羊",
"cost": 220,
"product": "羊毛",
"base_price": 55,
"cycle": 2,
"feed_per_day": 1,
"exp": 9,
"level_req": 2,
},
"cow": {
"name": "牛",
"cost": 480,
"product": "牛奶",
"base_price": 72,
"cycle": 1,
"feed_per_day": 2,
"exp": 15,
"level_req": 3,
},
}
MAX_ANIMALS = 8 # 牧场总容量
# --------------------------------------------------------------------------
# 时间
# --------------------------------------------------------------------------
# 一天有多长(真实秒数)。慢是刻意的:这是“放置农场”——
# 一天 40 分钟,作物 2~10 天成熟(小麦 80 分钟、草莓约 6.7 小时)。
# 没有任何“手动推进一天”的接口,时间只能自然流动。
DEFAULT_TICK_SECONDS = 2400.0
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
# --------------------------------------------------------------------------
# 进度目标
# --------------------------------------------------------------------------
GOALS = [
(200, "起步:攒够 200 金币"),
(600, "小农:攒够 600 金币"),
(1500, "农场主:攒够 1500 金币"),
(4000, "庄园主:攒够 4000 金币"),
(10000, "农业大亨:攒够 10000 金币"),
]
def goal_index(net_worth: float) -> int:
"""返回已达成的最低目标索引(-1 表示一个都没达成)。"""
reached = -1
for i, (amount, _) in enumerate(GOALS):
if net_worth >= amount:
reached = i
return reached