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

+177 -14
View File
@@ -48,6 +48,9 @@ CELL_INNER = CELL_WIDTH - 2
SIDE_WIDTH = 36 # 右侧栏宽度
MIN_TERM_WIDTH = 76
MAX_VISIBLE_ANIMALS = 5 # 侧栏同时最多展示的牲畜数量
MAX_VISIBLE_GOODS = 4 # 侧栏谷仓同时展示的货物种类
# 有菜单界面的模式;这几处判断原本各写了一遍模式列表
MENU_MODES = ("select_crop", "select_animal", "select_goods", "select_build")
def strip_ansi(text: str) -> str:
@@ -276,7 +279,7 @@ class TuiApp:
self._flash("已取消重置", C_GRAY)
self.mode = "main"
return
if self.mode in ("select_crop", "select_animal"):
if self.mode in MENU_MODES:
self._handle_menu_key(key)
return
@@ -299,6 +302,8 @@ class TuiApp:
"c": self._collect,
"l": lambda: self._act(self.engine.buy_plot),
"k": lambda: self._act(self.engine.buy_feed, config.FEED_BULK),
"m": self._open_goods_menu,
"g": self._open_build_menu,
"s": self._save,
"r": self._ask_reset,
keys.SPACE: self._toggle_pause,
@@ -338,16 +343,19 @@ class TuiApp:
if not targets:
self._flash("暂时没有成熟的作物", C_GRAY)
return
total = 0
total = 0.0
count = 0
for number in targets:
try:
result = engine.harvest(number)
total += result["earned"]
count += result["quantity"]
total += float(result.get("value", 0))
count += int(result.get("quantity", 0))
except GameError:
continue
self._flash(f"收获 {count} 份作物,共 +{total} 金币", C_GREEN)
if not count:
self._flash("一块都没收进来——多半是谷仓满了,按 M 卖点存货", C_RED)
return
self._flash(f"收获 {count} 份作物入谷仓(约值 {total:.0f} 金币,按 M 卖出)", C_GREEN)
# ---------------------------------------------------------- 菜单交互
def _open_crop_menu(self) -> None:
@@ -408,6 +416,85 @@ class TuiApp:
self.menu_title = "选择要购买的动物"
self.mode = "select_animal"
def _open_goods_menu(self) -> None:
"""卖货菜单:每种存货一行,按仓量从多到少排。
"全部卖光"也做成一个正常选项而不是单独的快捷键——它和逐个卖是
同一件事,只是数量取满,放进同一个菜单里玩家更容易发现。
"""
inventory = {k: v for k, v in self.engine.state.inventory.items() if v > 0}
if not inventory:
self._flash("谷仓是空的:收获与收取的产出会自动入库", C_GRAY)
return
market = self.engine.state.market
items: list[dict[str, Any]] = []
for key, count in sorted(inventory.items(), key=lambda kv: -kv[1]):
price = float(market.get(key, 0.0))
base = config.base_price(key)
ratio = (price / base - 1) * 100 if base else 0.0
items.append(
{
"key": key,
"label": config.goods_name(key),
"cost": 0,
"detail": (
f"存{count:>3} 现价{price:>6.0f} 基准{base:>5.0f} "
f"{ratio:+.0f}% 共值{price * count:>7.0f}"
),
"locked": False,
"lock_hint": "",
"affordable": True,
}
)
items.append(
{
"key": None,
"label": "全部卖光",
"cost": 0,
"detail": "一键清仓(价格不好时慎用)",
"locked": False,
"lock_hint": "",
"affordable": True,
}
)
self.menu = items
self.menu_title = "卖出谷仓里的货物"
self.mode = "select_goods"
def _open_build_menu(self) -> None:
"""建造菜单。报价走服务器给的 catalog,拿不到时退回本地配置表。"""
catalog = {b.get("key"): b for b in (self.engine.catalog().get("buildings") or [])}
state = self.engine.state
items: list[dict[str, Any]] = []
for key, info in config.BUILDINGS.items():
entry = catalog.get(key, {})
name = info["name"]
effect = info["effect"]
level_req = info["level_req"]
max_level = info["max_level"]
owned = entry.get("owned", state.buildings.get(key, 0))
next_cost = entry.get("next_cost", config.build_price(key, owned))
maxed = entry.get("maxed", owned >= max_level)
locked = entry.get("locked", state.level < level_req)
if maxed:
detail = f"已建满 {max_level} 座 {effect}"
else:
detail = f"{next_cost:>5}金币 {owned}/{max_level} 座 {effect}"
items.append(
{
"key": key,
"label": name,
"cost": next_cost,
"detail": detail,
"locked": locked,
"lock_hint": f"需 Lv.{level_req}",
"affordable": not maxed and state.gold >= next_cost,
}
)
self.menu = items
self.menu_title = "建造(同类建筑越造越贵)"
self.mode = "select_build"
def _handle_menu_key(self, key: str) -> None:
if key in (keys.ESC, "q"):
self.mode = "main"
@@ -427,8 +514,12 @@ class TuiApp:
if self.mode == "select_crop":
self._act(self.engine.plant, self.plot_cursor + 1, item["key"])
else:
elif self.mode == "select_animal":
self._act(self.engine.buy_animal, item["key"])
elif self.mode == "select_goods":
self._act(self.engine.sell, item["key"])
else:
self._act(self.engine.build, item["key"])
self.mode = "main"
def _feed(self) -> None:
@@ -510,6 +601,13 @@ class TuiApp:
tag = color(f"👥{online} ", C_ORANGE, self.use_color)
else:
tag = ""
# 天气放在最显眼的位置:它是每天唯一会变的"环境参数",
# 而且"今→明"里的第二个字就是预报,浇水决策要靠它。
weather_name = snap.get("weather_name")
if weather_name:
tag += color(
f"{weather_name}→{snap.get('forecast_name') or '?'} ", C_PURPLE, self.use_color
)
if snap["paused"]:
right = tag + color("⏸ 已暂停 ", C_ORANGE, self.use_color)
@@ -720,17 +818,52 @@ class TuiApp:
market_rows.append(
color(f" 饲料 {config.FEED_UNIT_PRICE} 金币/份(按 K 购买)", C_DIM, self.use_color)
)
market = draw_box(
market_box = draw_box(
"市场行情", market_rows, side_width, accent=C_CYAN, use_color=self.use_color
)
return pasture + market
# ---- 谷仓:有存货才占地方,空仓只留一行说明
inventory = {k: v for k, v in (snap.get("inventory") or {}).items() if v > 0}
granary_rows: list[str] = []
market = snap.get("market") or {}
for key, count in sorted(inventory.items(), key=lambda kv: -kv[1])[:MAX_VISIBLE_GOODS]:
price = float(market.get(key, 0.0))
base = config.base_price(key)
ratio = price / base if base else 1.0
if ratio > 1.06:
tone = C_RED
elif ratio < 0.94:
tone = C_GREEN
else:
tone = C_GRAY
label = color(f" {config.goods_name(key)}", C_WHITE, self.use_color)
value = color(f"{count:>3} ×{price:>5.0f}", tone, self.use_color)
spacer = max(1, side_width - 4 - display_width(label) - display_width(value))
granary_rows.append(label + " " * spacer + value)
if not granary_rows:
granary_rows.append(color(" 空仓:收获会自动入库", C_DIM, self.use_color))
elif len(inventory) > MAX_VISIBLE_GOODS:
granary_rows.append(
color(f" …还有 {len(inventory) - MAX_VISIBLE_GOODS} 种存货", C_DIM, self.use_color)
)
used = int(snap.get("storage_used") or 0)
capacity = int(snap.get("granary_capacity") or 0)
granary_rows.append(
color(f" 仓容 {used}/{capacity} ", C_GRAY, self.use_color)
+ color(bar(used, max(1, capacity), 8), C_RED if used >= capacity else C_GOLD)
)
granary_rows.append(color(" 按 M 卖出存货", C_DIM, self.use_color))
granary = draw_box(
"谷仓", granary_rows, side_width, accent=C_GOLD, use_color=self.use_color
)
return pasture + market_box + granary
# ---------------------------------------------------------- 底部
def _render_bottom(self, snap: dict[str, Any], width: int) -> list[str]:
if self.mode == "help":
return self._render_help(width)
if self.mode in ("select_crop", "select_animal"):
if self.mode in MENU_MODES:
return self._render_menu(width)
rows: list[str] = []
@@ -757,11 +890,15 @@ class TuiApp:
hint = [
color(" [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获", C_GRAY, self.use_color),
color(
" [F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料",
" [F]喂食 [C]收取 [Tab]换牲畜 [M]卖货 [G]建造 [B]买动物",
C_GRAY,
self.use_color,
),
color(
" [L]买地 [K]买饲料 [空格]暂停 [S]存档 [R]重置 [Q]退出",
C_GRAY,
self.use_color,
),
color(" [空格]暂停 [S]存档 [R]重置 [Q]退出", C_GRAY, self.use_color),
]
if self.show_keyboard_notice and not self.reader.available:
# 读不到键盘时把原因摆在最显眼处,别让玩家对着界面干等
@@ -804,9 +941,17 @@ class TuiApp:
C_GRAY,
self.use_color,
),
color(" 天气", C_BOLD + C_GOLD, self.use_color)
+ color(
" 每天换一种:晴/多云/雨/干旱/暴风雨。作物按天气蒸发 0~2 点水分,"
"雨天自动补满,暴风雨还可能毁苗。",
C_GRAY,
self.use_color,
),
color(" 水分", C_BOLD + C_GOLD, self.use_color)
+ color(
" 作物每天消耗 1 点水分,水为 0 就停止生长。按 W 浇水恢复到 3 点。",
f" 水为 0 就停止生长,按 W 浇满({config.WATER_CAPACITY} 点)。"
"顶部「今→明」的第二个字就是明天的天气预报。",
C_GRAY,
self.use_color,
),
@@ -818,8 +963,24 @@ class TuiApp:
+ color(
" 必须每天喂食才会产出;连续 3 天没吃东西会离开农场。", C_GRAY, self.use_color
),
color(" 谷仓", C_BOLD + C_GOLD, self.use_color)
+ color(
" 收获与收取的产出先进谷仓、不直接换钱;按 M 卖出,装满了就收不进来。",
C_GRAY,
self.use_color,
),
color(" 建筑", C_BOLD + C_GOLD, self.use_color)
+ color(
" 按 G 建造:谷仓加容量、水井自动浇水、喂食器自动喂食、温室免疫天气。",
C_GRAY,
self.use_color,
),
color(" 市场", C_BOLD + C_GOLD, self.use_color)
+ color(" 作物和畜产品价格每天波动 ±18%,高点卖出更赚。", C_GRAY, self.use_color),
+ color(
" 货价每天在基准价 ±18% 内波动。囤到高位再卖才赚——这就是谷仓存在的意义。",
C_GRAY,
self.use_color,
),
"",
color(" 快捷键", C_BOLD + C_GOLD, self.use_color)
+ color(
@@ -829,10 +990,12 @@ class TuiApp:
),
color(" ", C_GRAY, self.use_color)
+ color(
" B 买动物 / L 开垦土地 / K 买饲料 / 空格 暂停 / S 存档 / R 重置",
" M 卖货 / G 建造 / B 买动物 / L 开垦土地 / K 买饲料",
C_GRAY,
self.use_color,
),
color(" ", C_GRAY, self.use_color)
+ color(" 空格 暂停 / S 存档 / R 重置 / Q 退出", C_GRAY, self.use_color),
"",
color(" AI 接入", C_BOLD + C_GOLD, self.use_color)
+ color(