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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user