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
+177
-14
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user