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

+2 -1
View File
@@ -26,6 +26,7 @@ from .daemon import (
DEFAULT_PID_FILE,
PROJECT_ROOT,
DaemonManager,
ensure_parent_dir,
port_busy,
)
@@ -264,7 +265,7 @@ def _run_foreground(args, save_dir: str) -> int:
if not args.pid_file:
return
try:
os.makedirs(os.path.dirname(args.pid_file) or ".", exist_ok=True)
ensure_parent_dir(args.pid_file)
with open(args.pid_file, "w", encoding="utf-8") as file_handle:
file_handle.write(str(os.getpid()))
except OSError as exc:
+31 -5
View File
@@ -99,8 +99,8 @@ class Action:
# ---------------------------------------------------------------------- 处理器
def _harvest_all(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
"""收获所有成熟作物。逐块调用引擎,个别地块失败不影响其它地块。"""
total_gold = 0
"""收获所有成熟作物,全部进谷仓。逐块调用引擎,个别地块失败不影响其它地块。"""
total_value = 0.0
total_units = 0
plots: list[int] = []
errors: list[str] = []
@@ -113,17 +113,20 @@ def _harvest_all(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
except GameError as exc:
errors.append(f"{plot.index + 1}号地:{exc.message}")
continue
total_gold += int(result.get("earned", 0))
total_value += float(result.get("value", 0))
total_units += int(result.get("quantity", 0))
plots.append(plot.index + 1)
if not plots and not errors:
raise GameError("nothing_to_harvest", "当前没有成熟的作物可以收获")
return {
"message": f"收获 {len(plots)} 块地共 {total_units} 份作物,+{total_gold} 金币",
"message": (
f"收获 {len(plots)} 块地共 {total_units} 份作物入谷仓,"
f"按现价约值 {round(total_value)} 金币(用 sell 才换成钱)"
),
"plots": plots,
"quantity": total_units,
"earned": total_gold,
"value": round(total_value, 2),
"errors": errors,
}
@@ -235,6 +238,29 @@ _register(
)
)
_register(
Action(
"sell",
"把谷仓里的货物按当前市价卖出(省略 key 即全部卖光)",
[
Param("key", "str", False, None, "货物标识,如 wheat / animal:chicken;省略=全卖"),
Param("quantity", "int", False, None, "卖出数量,省略=该货物全卖"),
],
lambda e, p: e.sell(p.get("key"), p.get("quantity")),
)
)
_register(
Action(
"build",
"建造谷仓 / 水井 / 自动喂食器 / 温室",
[
Param("key", "str", True, None, "建筑标识:granary / well / feeder / greenhouse"),
],
lambda e, p: e.build(p["key"]),
)
)
# 注意:save / reset **刻意不进**这张表。它们是"存档类动作",多牧场环境下
# 必须由牧场会话(FarmSession)处理——engine.save() 只会写默认路径,
+27 -7
View File
@@ -104,6 +104,10 @@ class ServerHandle:
# 数据源是 FarmEngine.brief_source()(**不是**完整快照),加字段时两处一起改。
BRIEF_KEYS = (
"day",
"weather",
"weather_name",
"forecast",
"forecast_name",
"gold",
"level",
"exp",
@@ -116,6 +120,10 @@ BRIEF_KEYS = (
"next_plot_price",
"animal_count",
"max_animals",
"storage_used",
"granary_capacity",
"granary_free",
"building_count",
"net_worth",
"goal_index",
"seconds_to_tick",
@@ -150,16 +158,26 @@ def rules_text() -> str:
f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n"
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天——"
"时间只能自然流动,没有任何「手动推进一天」的接口,别白费请求。\n"
f"3. 水分:作物每天消耗 1 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长。"
f"播种时自带 {config.PLANT_INITIAL_MOISTURE} 点水分。\n"
f"4. 收获:成熟后 harvest 获得金币与经验,有 {config.LUCKY_CHANCE:.0%} 概率丰收翻倍。\n"
f"3. 天气与水分:每天随机一种天气(晴/多云/雨/干旱/暴风雨)。作物每天按天气蒸发"
f" 0~2 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长;"
f"播种自带 {config.PLANT_INITIAL_MOISTURE} 点。雨天与暴风雨会自动补满水分,"
"暴风雨还可能毁掉未成熟的作物(温室可免疫)。state.forecast 是明天的预报——"
"提前一天浇水就能避免断水停长。\n"
"4. 收获与谷仓:harvest / collect 的产出**先进谷仓,不直接换钱**,再用 sell "
"按当时的市价卖出。仓容 = 基础 "
f"{config.GRANARY_BASE} + 等级成长 + 谷仓建筑,装不下时收获会被拒绝"
"(granary_full),先卖货或者建谷仓。\n"
"5. 牲畜:每天必须喂食才会累计产出周期;连续 3 天未进食会永久离开农场。\n"
f"6. 市场:作物与畜产品价格每天在基础价 ±{config.PRICE_VOLATILITY:.0%} 内波动,"
"高价卖出收益更高。\n"
f"6. 市场:作物与畜产品价格每天在基础价 ±{config.PRICE_VOLATILITY:.0%} 内波动。"
'有了谷仓之后,"什么时候卖"才真正影响收益:囤到高位再 sell。\n'
f"7. 土地:初始 {config.BASE_PLOTS} 块,每升 1 级多 {config.PLOTS_PER_LEVEL} 块,"
f"上限 {config.MAX_PLOTS} 块;购地价格每次递增。\n"
"8. 目标:把净资产(金币 + 土地 + 牲畜 + 库存 + 地里作物)做到 10000 金币。\n"
"9. 多人:每个 farm 是一个独立牧场,用 ?farm=名字 区分,互不影响;"
"8. 建筑:build 建造 granary / well / feeder / greenhouse。谷仓加仓容、"
"水井每天自动浇 1 块干透的地、喂食器每天自动喂 1 只牲畜(只吃库存饲料,"
"不会替你花钱)、温室免疫暴风雨且水分蒸发减半。同类建筑越造越贵。\n"
f"9. 目标:把净资产(金币 + 土地 + 牲畜 + 饲料 + 地里作物 + **谷仓存货**)"
f"做到 {config.GOALS[-1][0]} 金币。\n"
"10. 多人:每个 farm 是一个独立牧场,用 ?farm=名字 区分,互不影响;"
"排行榜见 GET /api/world。\n"
"\n推荐决策循环(AI 最短路径):\n"
" GET /api/actions?farm=me → 拿到当前所有**合法**动作(含开销与理由)\n"
@@ -379,6 +397,8 @@ def _register_meta_routes(app: FastAPI, svc: _Services) -> None:
"actions": actions_mod.describe_actions(),
"crops": config.CROPS,
"animals": config.ANIMALS,
"buildings": config.BUILDINGS,
"weather": config.WEATHER,
"endpoints": {
"state": "GET /api/state?farm=NAME",
"catalog": "GET /api/catalog?farm=NAME",
+16 -3
View File
@@ -88,6 +88,19 @@ def port_busy(host: str, port: int, timeout: float = 0.6) -> bool:
return False
def ensure_parent_dir(path: str) -> None:
"""确保 ``path`` 所在的目录存在之后再动手写文件。
路径可能只给一个文件名(``--pid-file pid.txt``),此时
``os.path.dirname`` 返回空串,直接 ``makedirs`` 会以 WinError 3 /
FileNotFoundError 收场——启动阶段就崩,报错信息还只说"找不到路径"。
所以空目录部分要当成"当前目录",什么都不用建。
"""
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
class DaemonManager:
"""负责在后台拉起/停止 ``farm.server`` 进程。"""
@@ -121,7 +134,7 @@ class DaemonManager:
return None
def write_pid(self, pid: int) -> None:
os.makedirs(os.path.dirname(self.pid_file), exist_ok=True)
ensure_parent_dir(self.pid_file)
with open(self.pid_file, "w", encoding="utf-8") as handle:
handle.write(str(pid))
@@ -203,8 +216,8 @@ class DaemonManager:
if self.port_busy():
return False, f"端口 {self.port} 被其它程序占用,请改用 --port 8899"
os.makedirs(os.path.dirname(self.log_file), exist_ok=True)
os.makedirs(os.path.dirname(self.pid_file), exist_ok=True)
ensure_parent_dir(self.log_file)
ensure_parent_dir(self.pid_file)
self.clear_pid() # 清掉上一次留下的陈旧 pid
env = dict(os.environ)
env["PYTHONUNBUFFERED"] = "1"
+65 -11
View File
@@ -142,6 +142,17 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
<h2>市场行情 <em>红涨绿跌</em></h2>
<table id="market"></table>
</div>
<div class="card">
<h2>谷仓 <em id="granaryhint"></em></h2>
<table id="granary"></table>
<div class="row" style="margin-top:8px">
<button class="primary" data-act="sell">全部卖出</button>
</div>
</div>
<div class="card">
<h2>建筑 <em>花金币换自动化</em></h2>
<div class="row" id="buildings"></div>
</div>
<div class="card">
<h2>服务器排行榜 <em id="worldinfo"></em></h2>
<table id="board"></table>
@@ -165,6 +176,13 @@ const cropNames = {
corn: "玉米", pumpkin: "南瓜", strawberry: "草莓"
};
function goodsName(key) {
const g = ((catalog && catalog.goods) || []).find((x) => x.key === key);
if (g) return g.name;
if (key.indexOf("animal:") === 0) return key.slice(7);
return cropNames[key] || key;
}
function toast(text) {
const el = $("toast");
el.textContent = text;
@@ -277,8 +295,12 @@ function render() {
if (!state) return;
const s = state;
$("stats").innerHTML = [
["第几天", s.day], ["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed], ["净资产", Math.round(s.net_worth)],
["第几天", s.day],
["天气", (s.weather_name || "—") + " → " + (s.forecast_name || "?")],
["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed],
["仓储", (s.storage_used || 0) + "/" + (s.granary_capacity || 0)],
["净资产", Math.round(s.net_worth)],
["在线", s.online !== undefined ? s.online : "—"],
["下一天", fmtLeft(s.seconds_to_tick)]
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join("");
@@ -314,18 +336,48 @@ function render() {
(a.pending > 0 ? '<button data-c="' + a.id + '">收取</button>' : '') + '</li>'
).join("") : '<li style="color:#6b7a66">牧场空空的,先买一只吧</li>';
/* 市场 */
const rows = Object.entries(s.market).filter((kv) => kv[0].indexOf("animal:") !== 0);
const base = { wheat: 14, carrot: 26, tomato: 52, corn: 95, pumpkin: 180, strawberry: 320 };
$("market").innerHTML = '<tr><th>作物</th><th class="num">现价</th><th class="num">基准</th></tr>' +
rows.sort((a, b) => b[1] - a[1]).map((kv) => {
const b = base[kv[0]] || kv[1];
/* 市场:基准价直接用 catalog.goods,别再在 JS 里抄一份常量表 */
const gmap = {};
((catalog && catalog.goods) || []).forEach((g) => { gmap[g.key] = g; });
$("market").innerHTML = '<tr><th>货物</th><th class="num">现价</th><th class="num">基准</th></tr>' +
Object.entries(s.market).sort((a, b) => b[1] - a[1]).map((kv) => {
const g = gmap[kv[0]] || {};
const b = g.base_price || kv[1];
const cls = kv[1] > b * 1.03 ? "up" : (kv[1] < b * 0.97 ? "down" : "flat");
const arrow = cls === "up" ? "↑" : (cls === "down" ? "↓" : "→");
return '<tr><td>' + (cropNames[kv[0]] || kv[0]) + '</td><td class="num ' + cls + '">' +
kv[1].toFixed(0) + " " + arrow + '</td><td class="num">' + b + '</td></tr>';
return '<tr><td>' + goodsName(kv[0]) + '</td><td class="num ' + cls + '">' +
kv[1].toFixed(0) + " " + arrow + '</td><td class="num">' + b.toFixed(0) + '</td></tr>';
}).join("");
/* 谷仓:卖货是唯一把存货变成金币的出口,所以每个存项都直接给一个卖出按钮 */
const inv = Object.entries(s.inventory || {}).filter((kv) => kv[1] > 0);
$("granaryhint").textContent =
"仓容 " + (s.storage_used || 0) + "/" + (s.granary_capacity || 0);
$("granary").innerHTML =
'<tr><th>货物</th><th class="num">存量</th><th class="num">现价</th>' +
'<th class="num">基准</th><th></th></tr>' +
(inv.length ? inv.sort((a, b) => b[1] - a[1]).map((kv) => {
const g = gmap[kv[0]] || {};
const price = g.price !== undefined ? g.price : ((s.market || {})[kv[0]] || 0);
const b = g.base_price || 0;
const cls = b && price > b * 1.03 ? "up" : (b && price < b * 0.97 ? "down" : "flat");
return '<tr><td>' + goodsName(kv[0]) + '</td><td class="num">' + kv[1] +
'</td><td class="num ' + cls + '">' + price.toFixed(0) + '</td>' +
'<td class="num">' + (b ? b.toFixed(0) : "—") + '</td>' +
'<td class="num"><button data-s="' + kv[0] + '">卖</button></td></tr>';
}).join("")
: '<tr><td colspan="5" style="color:#6b7a66">空仓:收获与收取的产出会自动入库</td></tr>');
/* 建筑 */
const builds = (catalog && catalog.buildings) || [];
$("buildings").innerHTML = builds.length ? builds.map((b) => {
const disabled = b.locked || b.maxed || s.gold < b.next_cost;
const label = b.name + " " + (b.maxed ? "已满" : b.next_cost + " 金") +
"(" + b.owned + "/" + b.max_level + ")";
return '<button data-b="' + b.key + '"' + (disabled ? " disabled" : "") +
' title="' + b.effect + '">' + label + '</button>';
}).join("") : '<span style="color:#6b7a66">目录加载中…</span>';
renderLog(null);
renderBoard();
}
@@ -383,6 +435,8 @@ document.addEventListener("click", (ev) => {
}
else if (t.dataset.f) act("feed", { animal_id: +t.dataset.f });
else if (t.dataset.c) act("collect", { animal_id: +t.dataset.c });
else if (t.dataset.s) act("sell", { key: t.dataset.s });
else if (t.dataset.b) act("build", { key: t.dataset.b });
else if (t.dataset.act === "buy_animal") act("buy_animal", { kind: $("buyanimal").value });
else if (t.dataset.act === "buy_feed") act("buy_feed", { quantity: 10 });
else if (t.dataset.act === "reset") { if (confirm("确定重置牧场?所有进度都会清空")) act("reset"); }
@@ -443,7 +497,7 @@ def dashboard_text(host: str, port: int) -> str:
'POST /api/actions {"action":"...","params":{...}} 通用入口\n'
'POST /api/batch {"actions":[{...},{...}]} 批量\n'
"POST /api/plant|water|harvest|harvest_all|buy_animal|feed|\n"
" collect|buy_feed|buy_plot|pause|save|reset 便捷入口\n"
" collect|buy_feed|buy_plot|sell|build|pause|save|reset 便捷入口\n"
'POST /api/farms {"farm":"名字"} 创建/进入牧场\n'
"POST /api/admin/save 全部存档\n"
"POST /api/admin/shutdown 优雅停机\n"