From cf24cb43f123f4cd9172a7579a10e085f7788daf Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:02:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=A0=E5=A4=A9=E6=B0=94=20/=20?= =?UTF-8?q?=E8=B0=B7=E4=BB=93=20/=20=E5=BB=BA=E7=AD=91=E4=B8=89=E5=A5=97?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=EF=BC=8C=E6=94=B6=E8=8E=B7=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=85=A5=E5=BA=93=20+=20=E6=8B=A9=E6=97=B6=E5=8D=96=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 动手前先修掉了两处「死机制」,新增的三个系统各自激活一个: * 天气:小麦 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 项端到端验收,全过。 --- .workbuddy/memory/2026-09-19.md | 44 +++ .workbuddy/memory/MEMORY.md | 24 ++ README.md | 28 +- farm/bot.py | 26 +- farm/client.py | 55 ++++ farm/config.py | 165 ++++++++++ farm/engine.py | 521 ++++++++++++++++++++++++++++++-- farm/models.py | 82 ++++- farm/server/__main__.py | 3 +- farm/server/actions.py | 36 ++- farm/server/app.py | 34 ++- farm/server/daemon.py | 19 +- farm/server/webui.py | 76 ++++- farm/storage.py | 15 +- farm/tui.py | 191 +++++++++++- main.py | 33 +- tests/test_actions.py | 6 +- tests/test_bot.py | 20 +- tests/test_engine.py | 33 +- tests/test_server.py | 40 ++- tests/test_tui_keys.py | 10 +- 21 files changed, 1349 insertions(+), 112 deletions(-) diff --git a/.workbuddy/memory/2026-09-19.md b/.workbuddy/memory/2026-09-19.md index 1d01a59..aa5ff46 100644 --- a/.workbuddy/memory/2026-09-19.md +++ b/.workbuddy/memory/2026-09-19.md @@ -236,3 +236,47 @@ 最后一次核对:`git status` → "up to date with 'origin/main',working tree clean"。 - 远端内容已用 API 核对:3 个提交齐全(450655b / f8e093c / 47dd818)、根目录 13 个条目 (.gitattributes / .githubignore / README.md 25KB / farm/ / tests/ / tools/ / uv.lock / pyproject.toml …)。 + +## 2026-09-19 三大新系统:天气+水分重做 / 谷仓+择时卖出 / 建筑设施 + +- 用户答复「继续优化 添加功能等」;多选确认范围,选中三个(第四个「离线补偿」没选)。 +- 动机:动手前发现两处**死机制**——①小麦 2 天成熟、初始水分 2、每天只掉 1 点, + 等于**永远不需要浇水**;②市场波动纯装饰,收获当场按市价变现,玩家没有择时决策。 + 加「天气」和「谷仓」正好各激活一个,建筑则给攒下来的钱一个出口。 +- **天气**(`config.WEATHER`):5 种、权重合计 100、同种不连出(`WEATHER_REROLLS=3` 重掷)。 + 晴天蒸发 1 / 多云 0 / 雨补 3 / 干旱 2 / 暴风雨补 3 + 毁未熟作物 12%。 + `state.weather`(今天)+ `state.forecast`(明天,已知)→「今晚浇不浇」成了可计算的决策。 + 温室建成后蒸发减半。`advance_day(weather=None)` 按 forecast 结算; + **传 `weather="sunny"` 可钉死确定性**(测试/调试用,正常玩法不传)。 +- **谷仓**:收获/收取不再给钱,进 `state.inventory`(键与 `market` 同构:`wheat` / `animal:chicken`), + 再由 `sell` 按当前市价换金币;`granary_capacity = 40 + 10*(level-1) + 25*谷仓数`,上限 400。 + 仓满时 `_store()` 抛 `granary_full` **且不改状态**(作物原地不动),不是「收了个寂寞」; + 卖空报 `nothing_to_sell`。 +- **建筑**(`config.BUILDINGS`):granary(300,Lv1,max5) / well(400,Lv2,max3) / + feeder(650,Lv3,max3) / greenhouse(1500,Lv5,max1);第 n 座价 `int(cost*1.6**owned)`。 + 每天在 `advance_day` 里**先于生长**跑(水井救的正是今天要断水那块地)。 + 喂食器只吃库存饲料、**绝不自动花钱**。等级不够抛 `locked`、建满抛 `build_limit`。 +- **存档**:`SAVE_VERSION=2` + `MIN_SAVE_VERSION=1`(旧档缺的新字段一律走默认值,不作废)。 +- 动作表新增 `sell`(key?/quantity?) 与 `build`(key),两者都 0 AP;`bot` 在 AP=0 时 + 仍会走一次 `step()`,因为卖货/建造/收获/收取都是免费的。 +- **回归测试改了 11 处**,全部是预期内的行为变化而非 bug:harvest/collect 回执从 + `earned` 改 `value`(钱在 sell 才进来);成长用例改成 `advance_day(weather="sunny")` + 才可确定;BRIEF_KEYS 那只测试原来断言 `session.snapshot()`,改为断言 + `brief(session, hub)`(BRIEF_KEYS 的数据源本来就是 brief_source,不是完整快照)。 + 门禁:ruff/pyright 全绿、**503 项测试 OK(skip 35)**、bench 无回归 + (brief 19.5µs / snapshot 48.5µs / POST full=1 400µs)。 + +### 顺手修的两个真 bug +- `daemon.py` 三处 `os.makedirs(os.path.dirname(path), exist_ok=True)`:路径只给文件名时 + (`--pid-file pid.txt`)`dirname` 是空串 → FileNotFoundError,**启动阶段直接崩**。 + 抽出 `ensure_parent_dir()`(没有目录部分就什么都不建),`__main__.py` 里那份手写的 + `os.path.dirname(...) or "."` 兜底也统一过去。 +- `RemoteFarm.snapshot()` 是**本地缓存**,不发请求;非实时客户端轮询时会一直读到同一份 + 旧数据——服务器明明在推进,看起来却像卡死。已加 docstring 说明「要新鲜数据自己调 + `refresh()`,或开 SSE」。这个坑是我自己写端到端脚本时踩的。 + +### 真机端到端验收(临时脚本,跑完即删) +起 `--tick 3` 的服务器 + 预置存档(boss=5000 金币 Lv.5、noob=5000 金币 Lv.1), +走真实 HTTP 验了 **38 项全过**:catalog 四张新表、天气/预报换班、种→缺水→浇水→成熟、 +收获进仓不给钱、`sell` 变现与空仓报错、`build` 扣钱/涨容量/涨价/未解锁 locked/未知建筑。 +其中「等待期间手动浇水 1 次」是被干旱实打实逼出来的,说明天气→浇水这条链真在起作用。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 8c0600f..45775f5 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -35,6 +35,23 @@ TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务 - 新建牧场时 `FarmSession` 会立刻设好 `next_tick_at`,否则到时钟线程下一轮(≤0.2s) 之间倒计时是 None,客户端会误显示成“已暂停/手动模式”。 +## 三大系统:天气 / 谷仓 / 建筑(2026-09-19 起) +- **天气**:`config.WEATHER` 5 种(晴 30 / 多云 27 / 雨 24 / 干旱 12 / 暴风雨 7,合计 100), + 同种不连出(`WEATHER_REROLLS` 重掷)。`state.weather`=今天、`state.forecast`=明天且 + **提前可见**,于是「今晚浇不浇」成了可计算的决策。水分消耗改由天气决定 + (晴 1 / 多云 0 / 干旱 2),雨天反补 3;建成温室后蒸发减半。 +- **谷仓**:收获 / 收取**不再直接给钱**,进 `state.inventory`(键与 `market` 同构: + `wheat` / `animal:chicken`),靠 `sell` 换金币——市场波动因此从纯装饰变成真决策。 + 仓满时 `_store()` 抛 `granary_full` **且不改状态**(作物原地不动,卖了再来)。 +- **建筑**:`config.BUILDINGS`,第 n 座价 `int(cost * 1.6**owned)`;每天在 `advance_day` + 里**先于生长**执行(水井救的正是今天要断水那块地)。喂食器只吃库存饲料、绝不自动花钱。 +- **存档**:`SAVE_VERSION=2` 配 `MIN_SAVE_VERSION=1`——旧档缺的新字段一律走默认值, + **不作废玩家手里的档**。加字段时两处版本号都要看一遍。 +- **测试钩子**:`advance_day(weather="sunny")` 可钉死当天天气(不传就随机,测试会飘); + 这条链的入口是 `_resolve_weather` / `_evaporation` / `_run_buildings` / `_storm_damage`。 +- 动作表新增 `sell` 与 `build`,都是 **0 AP**;`bot` 在 AP=0 时仍会走一次 `step()`, + 正是为了执行这些免费动作。 + ## 架构约定(改动时必须遵守) - **分层**:`farm.engine` 只管规则,不认识网络/终端;`farm.tui` 不认识 socket; `farm/server/*` 不认识终端。新增玩法动作只改 `farm/server/actions.py` 一张表。 @@ -101,6 +118,13 @@ TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务 不要在 `catalog` / `available_actions` / `plant` / `buy_animal` / `_gain_exp` 各写一份。 ## 环境禁忌(本机特有) +- `os.makedirs(os.path.dirname(path), exist_ok=True)` 在 path **只有文件名**时 + (`--pid-file pid.txt`)`dirname` 是空串,会以 FileNotFoundError 崩掉。 + 已抽 `farm/server/daemon.py` 的 `ensure_parent_dir()` 兜底,新代码用它,别再手写。 +- `RemoteFarm.snapshot()` 是**本地缓存、不发请求**。关掉实时通道(`realtime=False`)的 + 脚本必须自己调 `refresh()`,否则轮询永远读到同一份旧数据——服务器在推进却看起来像卡死。 + TUI / 网页走 SSE 所以没这个问题;`bot` 走 `available_actions()`(会顺手带回最新状态)。 + - Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32); 若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。 一律"先读完 → 关掉句柄 → 再删"。 diff --git a/README.md b/README.md index 6ef2b15..3bf8208 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,14 @@ uv run main.py solo # 本地引擎 + 终端界面,不开 | 系统 | 规则 | | --- | --- | | **时间** | 每个牧场每 **40 分钟**自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 | -| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获和收取产出免费。用完只能等第二天——时间只能自然流动,没有手动快进 | -| **水分** | 作物每天消耗 1 点水分(上限 3),水分为 0 停止生长;播种自带 2 点 | +| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获、收取、卖货、建造都免费。用完只能等第二天——时间只能自然流动,没有手动快进 | +| **天气** | 每天从晴 / 多云 / 雨 / 干旱 / 暴风雨里抽一种(同种不连着出),而且**明天的天气提前可见**——「今晚浇不浇」因此是个能算的决策 | +| **水分** | 每天按天气消耗:晴 1、多云 0、干旱 2,雨天反补 3(上限 3);水分为 0 就停止生长。播种自带 2 点,只够晴天撑两天,所以下种前得先看预报 | | **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 | | **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** | -| **市场** | 作物与畜产品价格每天在基础价 ±18% 内波动,高位卖出更赚 | +| **谷仓** | 收获和收取**只入库、不当场给钱**(容量 40 起,随等级与谷仓建筑增长);仓满时收不进去,作物原样留在地里,卖了再来 | +| **市场** | 价格每天在基础价 ±18% 内波动;**靠 `sell` 择时变现**,高位卖才真的赚 | +| **建筑** | 谷仓(+25 仓容,最多 5 座)/ 水井(每天自动浇一块干透的地)/ 自动喂食器(每天自动喂一只,只吃库存饲料、绝不自动花钱)/ 温室(免疫暴风雨、水分蒸发减半);每多一座涨价 60% | | **土地** | 初始 6 块,每升 1 级 +2 块,上限 16 块,买地价格指数递增 | | **目标** | 净资产(金币+土地+牲畜+库存+地里作物)做到 10000 金币,共 5 个里程碑 | | **丰收** | 收获有 12% 概率触发双倍产出 | @@ -69,7 +72,8 @@ uv run main.py solo # 本地引擎 + 终端界面,不开 > **关于节奏**:这是一款“放置型”农场,一天 40 分钟是刻意设计的,不是笔误。 > 每天 10 点行动点,用完就是等时间流动——所以一天的玩法是“上来干十件事,然后离开”。 -> 小麦 2 天(80 分钟)成熟,草莓 10 天(约 6.7 小时)。想调试就把节奏调快: +> 小麦 2 天(80 分钟)成熟,草莓 10 天(约 6.7 小时);断水会停长,所以实际天数看天吃饭—— +> 好在预报提前一天就给你,来得及浇。想调试就把节奏调快: > `uv run main.py play --tick 3`(一天 3 秒)。 --- @@ -81,7 +85,7 @@ uv run main.py solo # 本地引擎 + 终端界面,不开 ``` [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获 [F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料 - [空格]暂停 [S]存档 [R]重置 [Q]退出 + [M]卖货 [G]建造 [空格]暂停 [S]存档 [R]重置 [Q]退出 ``` 界面用双宽字符感知的对齐算法(`display_width`),在 Windows Terminal / iTerm / VSCode 里边框都不会歪。 @@ -156,18 +160,18 @@ POST /api/actions → {"action":"<上一步的 action>","params":{. | 方法 | 路径 | 说明 | | --- | --- | --- | | GET | `/api/health` | 健康检查(唯一不需要令牌的接口) | -| GET | `/api/help` | 规则 + 动作清单 + 参数类型 | +| GET | `/api/help` | 规则 + 动作清单 + 参数类型 + 建筑 / 天气表 | | GET | `/api/world` | 世界概览:排行榜、在线连接、时钟 | | GET | `/api/farms` | 牧场列表(按净资产排名) | | POST | `/api/farms` | `{"farm":"名字","owner":"昵称","tick":10}` 创建/进入 | | GET | `/api/state` | 完整状态快照 | -| GET | `/api/catalog` | 作物 / 牲畜 / 实时价格 / 每单位收益 | +| GET | `/api/catalog` | 作物 / 牲畜 / 商品与实时价格 / 建筑 / 天气表 / 仓容 | | GET | `/api/actions` | 当前合法动作(AI 推荐入口) | | GET | `/api/log?limit=20&since=0` | 事件流(`since` 做增量拉取) | | GET | `/api/stream` | **SSE 实时推送**(服务器 → 客户端单向长连接) | | POST | `/api/actions` | **通用动作入口** | | POST | `/api/batch` | 批量动作(一次最多 50 个) | -| POST | `/api/{动作名}` | 便捷入口:`/api/plant` `/api/harvest` … 等 10 个 | +| POST | `/api/{动作名}` | 便捷入口:`/api/plant` `/api/harvest` … 等 12 个 | | POST | `/api/admin/save` | 全部存档 | | POST | `/api/admin/shutdown` | 优雅停机 | | GET | `/docs` | 自动生成的 Swagger 文档 | @@ -178,6 +182,7 @@ POST /api/actions → {"action":"<上一步的 action>","params":{. plant(plot,crop) water(plot) harvest(plot) harvest_all() buy_animal(kind) feed(animal_id,quantity?) collect(animal_id) buy_feed(quantity?) buy_plot() pause(paused?) +sell(key?,quantity?) build(key) ← 谷仓变现与建造,0 行动点 ``` > `save` / `reset` 是**存档类动作**,不进通用动作表:它们必须由牧场会话处理, @@ -203,6 +208,13 @@ curl -X POST 'http://127.0.0.1:8765/api/batch?farm=alice' \ {"action":"buy_feed","params":{"quantity":10}}, {"action":"plant","params":{"plot":1,"crop":"carrot"}}]}' +# 收成变现 / 造建筑:都是 0 行动点,行动点用完了照样能做 +curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \ + -H 'Content-Type: application/json' -d '{"action":"sell"}' +curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \ + -H 'Content-Type: application/json' \ + -d '{"action":"build","params":{"key":"granary"}}' + # 要完整快照(默认只回精简状态) curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice&full=1' ... ``` diff --git a/farm/bot.py b/farm/bot.py index 405586c..4c399a6 100644 --- a/farm/bot.py +++ b/farm/bot.py @@ -104,6 +104,26 @@ class RuleBot: if cost and gold >= cost * 3 and ap >= 1: return 30.0 + (info.get("profit_per_day", 0) if info else 0) return 3.0 + if name == "sell": + # 卖货是唯一的现金流入口(收获不再直接给钱),所以优先级跟着"缺钱" + # 和"仓快满"走:仓满了不腾地方,下一茬根本收不进来。 + used = state.get("storage_used", 0) or 0 + capacity = state.get("granary_capacity", 1) or 1 + if gold < 150: + return 90.0 + if used >= capacity * 0.8: + return 88.0 + return 30.0 + if name == "build": + key = params.get("key") + used = state.get("storage_used", 0) or 0 + capacity = state.get("granary_capacity", 1) or 1 + if key == "granary" and used >= capacity * 0.7: + return 46.0 + if key in ("well", "feeder"): + # 这两个买的是"以后不用天天手动做",放置玩法里稳赚 + return 38.0 + return 22.0 return 1.0 def choose(self) -> dict[str, Any] | None: @@ -195,8 +215,10 @@ class RuleBot: state = self.client.snapshot() if state.get("ap", 0) <= 0: - # 行动点用完:只能等这一天自己走完,没有手动快进 - if not self.wait_for_next_day(wait_seconds): + # 行动点用完 ≠ 没事可做:sell / build / harvest / collect / buy_* + # 都是 0 消耗,而 available_actions 本身就按行动点筛过一遍了, + # 所以照常挑一次最优的;真的没得选才等下一天。 + if self.step() is None and not self.wait_for_next_day(wait_seconds): break idle = 0 continue diff --git a/farm/client.py b/farm/client.py index 307295c..3895850 100644 --- a/farm/client.py +++ b/farm/client.py @@ -230,6 +230,38 @@ class StateView: def market(self) -> dict[str, float]: return self._d.get("market", {}) + @property + def weather(self) -> str: + return self._d.get("weather", config.DEFAULT_WEATHER) + + @property + def weather_name(self) -> str: + return self._d.get("weather_name") or config.weather_info(self.weather)["name"] + + @property + def forecast(self) -> str: + return self._d.get("forecast", config.DEFAULT_WEATHER) + + @property + def forecast_name(self) -> str: + return self._d.get("forecast_name") or config.weather_info(self.forecast)["name"] + + @property + def inventory(self) -> dict[str, int]: + return self._d.get("inventory", {}) + + @property + def storage_used(self) -> int: + return int(self._d.get("storage_used", 0)) + + @property + def granary_capacity(self) -> int: + return int(self._d.get("granary_capacity", config.GRANARY_BASE)) + + @property + def buildings(self) -> dict[str, int]: + return self._d.get("buildings", {}) + def get(self, key: str, default: Any = None) -> Any: return self._d.get(key, default) @@ -277,6 +309,10 @@ class RemoteFarm: self._catalog: dict[str, Any] = { "crops": [], "animals": [], + "goods": [], + "buildings": [], + "weather": {}, + "granary": {}, "feed_price": config.FEED_UNIT_PRICE, "water_capacity": config.WATER_CAPACITY, "ap_cost": config.AP_COST, @@ -389,6 +425,13 @@ class RemoteFarm: self._apply(state) def snapshot(self) -> dict[str, Any]: + """本地缓存的状态视图,**不会发请求**。 + + TUI 每帧都要读它,所以刻意做成纯本地读取;代价是它只反映"上一次同步" + 的结果。开实时通道(``realtime=True``,TUI / 网页走这条)时 SSE 会持续 + 刷新它;关掉实时通道的脚本要自己调 :meth:`refresh`,否则轮询到的 + 永远是同一份旧数据——一天天推进的服务器看起来就像卡住了。 + """ return self.state.to_dict() def catalog(self) -> dict[str, Any]: @@ -480,6 +523,18 @@ class RemoteFarm: def buy_plot(self) -> dict[str, Any]: return self._action("buy_plot") + def sell(self, key: Any = None, quantity: Any = None) -> dict[str, Any]: + """卖出谷仓存货;两个参数都省略就是清仓。""" + params: dict[str, Any] = {} + if key is not None: + params["key"] = key + if quantity is not None: + params["quantity"] = quantity + return self._action("sell", params) + + def build(self, key: Any) -> dict[str, Any]: + return self._action("build", {"key": key}) + def pause(self, paused: bool | None = None) -> dict[str, Any]: # 动作回执里的 state(brief)已带 paused 与 seconds_to_tick, # _action 内部会合并进本地视图,这里只需转发参数 diff --git a/farm/config.py b/farm/config.py index e059f55..af5133e 100644 --- a/farm/config.py +++ b/farm/config.py @@ -46,6 +46,8 @@ AP_COST = { "buy_animal": 0, "buy_plot": 0, "buy_feed": 0, + "build": 0, + "sell": 0, } @@ -193,6 +195,169 @@ 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 + + # -------------------------------------------------------------------------- # 进度目标 # -------------------------------------------------------------------------- diff --git a/farm/engine.py b/farm/engine.py index f6d3c2c..4423b4f 100644 --- a/farm/engine.py +++ b/farm/engine.py @@ -57,6 +57,11 @@ class FarmEngine: # 状态变化回调(TUI 用它触发重绘) self._listeners: list[Callable[[], None]] = [] + # 开局先把今天与明天的天气掷好:否则第一天会顶着 DEFAULT_WEATHER 的 + # 占位值开局,而"预报"字段又和它对不上。 + self.state.weather = self._roll_weather() + self.state.forecast = self._roll_weather(previous=self.state.weather) + # ================================================================ 订阅 def subscribe(self, callback: Callable[[], None]) -> None: with self._lock: @@ -137,6 +142,16 @@ class FarmEngine: "seconds_to_tick": self.seconds_to_tick, "paused": self.paused, "tick_seconds": self.tick_seconds, + "weather": state.weather, + "weather_name": config.weather_info(state.weather)["name"], + "forecast": state.forecast, + "forecast_name": config.weather_info(state.forecast)["name"], + "storage_used": state.storage_used, + "granary_capacity": state.granary_capacity, + "granary_free": state.granary_free, + # 存货是给 AI 的"卖什么"清单;只带非空项,通常只有几行 + "inventory": dict(state.inventory), + "building_count": sum(state.buildings.values()), # 待办清单是特意为 AI 准备的:直接读模型对象,不再过一遍序列化 "ripe_plots": [p.index + 1 for p in state.plots if p.is_ripe], "hungry_animals": [a.aid for a in state.animals if not a.fed], @@ -188,14 +203,98 @@ class FarmEngine: "profit_per_day": round((price - feed_cost) / max(1, info["cycle"]), 2), } ) + goods = self._goods_catalog() return { "crops": crops, "animals": animals, + "goods": goods, + "buildings": self._building_catalog(), + "weather": self._weather_catalog(), + "granary": { + "used": self.state.storage_used, + "capacity": self.state.granary_capacity, + }, "feed_price": config.FEED_UNIT_PRICE, "water_capacity": config.WATER_CAPACITY, "ap_cost": config.AP_COST, } + # ---- catalog 的三个子表。拆开是因为它们的"变化频率"各不相同: + # goods 随市价与存货变,buildings 随等级与已建数量变,weather 每天都在变。 + def _goods_catalog(self) -> list[dict[str, Any]]: + """所有可卖货物(作物 + 畜产品)的现价、基准价与仓库存量。 + + 合成一张表是刻意的:``sell`` 的 key 空间就是这张表的 key 空间, + 客户端不必自己拼 "animal:chicken" 这种前缀。 + """ + state = self.state + goods: list[dict[str, Any]] = [] + keys = list(config.CROPS) + [f"animal:{kind}" for kind in config.ANIMALS] + for key in keys: + price = self.market_price(key) + base = config.base_price(key) + stock = state.inventory.get(key, 0) + goods.append( + { + "key": key, + "name": config.goods_name(key), + "kind": "animal" if key.startswith("animal:") else "crop", + "price": price, + "base_price": base, + "ratio": round(price / base, 3) if base else 1.0, + "stock": stock, + "value": round(price * stock, 2), + } + ) + return goods + + def _building_catalog(self) -> list[dict[str, Any]]: + return [self._building_entry(self.state, key) for key in config.BUILDINGS] + + def _building_entry(self, state: GameState, key: str) -> dict[str, Any]: + """一座建筑的完整状态:报价、已建、上限、解锁。 + + catalog 与 available_actions 都读它——否则"报价"和"建议"两处 + 一旦各写一遍,迟早就出现"建议建、真去建却说太贵"这种矛盾。 + """ + info = config.BUILDINGS[key] + owned = state.building_count(key) + maxed = owned >= info["max_level"] + return { + "key": key, + "name": info["name"], + "effect": info["effect"], + "cost": info["cost"], + "next_cost": -1 if maxed else config.build_price(key, owned), + "level_req": info["level_req"], + "locked": state.level < info["level_req"], + "owned": owned, + "max_level": info["max_level"], + "maxed": maxed, + } + + def _weather_catalog(self) -> dict[str, Any]: + """今天的天气、明天的预报,以及这张天气表本身(给 AI 讲清规则用)。""" + state = self.state + return { + "today": state.weather, + "today_name": config.weather_info(state.weather)["name"], + "forecast": state.forecast, + "forecast_name": config.weather_info(state.forecast)["name"], + "evaporation": self._evaporation(state, config.weather_info(state.weather)), + "table": [ + { + "key": key, + "name": info["name"], + "desc": info["desc"], + "evaporation": info["evaporation"], + "water_gain": info["water_gain"], + "damage": info["damage"], + } + for key, info in config.WEATHER.items() + ], + } + def available_actions(self) -> list[dict[str, Any]]: """列出当前**合法**的动作,供 AI 直接决策使用。 @@ -207,7 +306,9 @@ class FarmEngine: actions: list[dict[str, Any]] = [] actions.extend(self._plot_actions(state)) actions.extend(self._animal_actions(state)) + actions.extend(self._market_actions(state)) actions.extend(self._investment_actions(state)) + actions.extend(self._build_actions(state)) return actions # ---- available_actions 的三个分区;拆开是为了各自能独立阅读/测试 @@ -245,13 +346,18 @@ class FarmEngine: "reason": f"{position} 号地的{config.CROPS[plot.crop]['name']}已成熟", } ) - elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]: + elif self._needs_water(plot) and state.ap >= config.AP_COST["water"]: + reason = ( + f"{position} 号地缺水,作物已停止生长" + if plot.moisture <= 0 + else f"{position} 号地水分只够撑到今天,不浇明天就会停长" + ) actions.append( { "action": "water", "params": {"plot": position}, "cost": {"gold": 0, "ap": config.AP_COST["water"]}, - "reason": f"{position} 号地缺水,作物已停止生长", + "reason": reason, } ) return actions @@ -321,6 +427,59 @@ class FarmEngine: ) return actions + def _market_actions(self, state: GameState) -> list[dict[str, Any]]: + """该不该卖货:谷仓快满了要腾地方,价格明显高于基准价则值得出手。 + + 这是"择时卖出"在 AI 侧的落点:把"现价 vs 基准价"直接算好写进 reason, + 机器人/LLM 不需要自己记住每种货的基准价,也就能真的做出囤或抛的判断。 + """ + actions: list[dict[str, Any]] = [] + if not state.inventory: + return actions + used, capacity = state.storage_used, state.granary_capacity + tight = used >= capacity * 0.8 + for key, count in sorted(state.inventory.items()): + if count <= 0: + continue + price = self.market_price(key) + base = config.base_price(key) + ratio = price / base if base else 1.0 + reasons = [f"谷仓有 {count} 份{config.goods_name(key)},现价 {price}(基准 {base})"] + if tight: + reasons.append(f"谷仓已用 {used}/{capacity},该腾地方了") + if ratio >= 1.1: + reasons.append(f"比基准价高 {(ratio - 1) * 100:.0f}%,适合出手") + if len(reasons) == 1: + # 价格没优势、仓也不挤 → 建议继续囤着。不推荐就别出声, + # 否则候选动作里全是"卖了吧",AI 反而挑不出重点。 + continue + actions.append( + { + "action": "sell", + "params": {"key": key, "quantity": count}, + "cost": {"gold": 0, "ap": config.AP_COST["sell"]}, + "reason": ";".join(reasons), + } + ) + return actions + + def _build_actions(self, state: GameState) -> list[dict[str, Any]]: + """能造什么:等级够、没建满、且钱够。""" + actions: list[dict[str, Any]] = [] + for key in config.BUILDINGS: + entry = self._building_entry(state, key) + if entry["locked"] or entry["maxed"] or state.gold < entry["next_cost"]: + continue + actions.append( + { + "action": "build", + "params": {"key": key}, + "cost": {"gold": entry["next_cost"], "ap": config.AP_COST["build"]}, + "reason": f"可造第 {entry['owned'] + 1} 座{entry['name']}:{entry['effect']}", + } + ) + return actions + # ================================================================ 内部工具 def _is_locked(self, info: dict[str, Any]) -> bool: """这件作物/牲畜在当前等级是否还没解锁。 @@ -343,7 +502,7 @@ class FarmEngine: """正好在 level 这一级解锁的作物与牲畜名字(先作物后牲畜)。""" return [ info["name"] - for table in (config.CROPS, config.ANIMALS) + for table in (config.CROPS, config.ANIMALS, config.BUILDINGS) for info in table.values() if info["level_req"] == level ] @@ -424,6 +583,45 @@ class FarmEngine: """丰收回合判定:常规产出 1,暴击产出 2。""" return 2 if random.random() < config.LUCKY_CHANCE else 1 + def _store(self, key: str, amount: int) -> None: + """把货物放进谷仓;装不下就抛 granary_full,且**不改动任何状态**。 + + 先判容量再落袋,所以收获撞上满仓时作物还好好地留在地里, + 玩家卖掉一点东西就能重来,不会"收了个寂寞"。 + """ + amount = int(amount) + if amount <= 0: + return + state = self.state + free = state.granary_free + if amount > free: + raise GameError( + "granary_full", + f"谷仓装不下:还差 {amount - free} 格" + f"(已用 {state.storage_used}/{state.granary_capacity})。" + "先 sell 卖掉点存货,或者 build 一座谷仓。", + ) + state.inventory[key] = state.inventory.get(key, 0) + amount + + def _needs_water(self, plot: Plot) -> bool: + """这块地现在该不该浇水。 + + * 已经干透 → 今天就长不了,必须浇; + * 水分刚好够今天蒸发、而明天又不下雨 → 明天会停长,提前浇能少浪费一天。 + + 第二条用到了明天的预报——这就是"预报"这个字段存在的意义: + 让"今天浇不浇"变成能提前算清的决策,而不是撞运气。 + """ + if plot.crop is None: + return False + if plot.moisture <= 0: + return True + forecast = config.weather_info(self.state.forecast) + if forecast["water_gain"] > 0: + # 明天有雨,今天浇水等于把水倒进雨里 + return False + return plot.moisture <= self._evaporation(self.state, forecast) + # ================================================================ 动作 def plant(self, plot_no: Any, crop: Any) -> dict[str, Any]: with self._lock: @@ -489,8 +687,11 @@ class FarmEngine: self._spend_ap("harvest") quantity = self._roll_yield() + # 先入库再清地:谷仓满了会在这里抛 granary_full,作物原地不动, + # 玩家卖掉一点就能重来,不会"收了个寂寞"。 + self._store(crop_key, quantity) price = self.market_price(crop_key) - earned = self._earn(price * quantity, info["name"]) + value = round(price * quantity, 2) plot.crop = None plot.growth = 0.0 @@ -498,16 +699,21 @@ class FarmEngine: self.state.total_harvested += quantity lucky = "(丰收!)" if quantity > 1 else "" - self.add_log(f"收获 {quantity} 份{info['name']}{lucky},+{earned} 金币", "good") + self.add_log(f"收获 {quantity} 份{info['name']}{lucky}入库,现价 {price}", "good") levelups = self._gain_exp(info["exp"]) self._notify() return { "ok": True, - "message": f"收获 {quantity} 份{info['name']},获得 {earned} 金币", + "message": ( + f"收获 {quantity} 份{info['name']}{lucky}入谷仓,现价 {price}" + f"(约 {value} 金币,需 sell 才能换成钱)" + ), "quantity": quantity, - "earned": earned, + "price": price, + "value": value, "exp": info["exp"], "level_ups": levelups, + "storage_used": self.state.storage_used, } def buy_animal(self, kind: Any) -> dict[str, Any]: @@ -584,24 +790,31 @@ class FarmEngine: ) self._spend_ap("collect") quantity = animal.pending + key = f"animal:{animal.kind}" + # 与收获同理:先确认装得下,再清空待收,免得产出凭空消失 + self._store(key, quantity) animal.pending = 0 - price = self.market_price(f"animal:{animal.kind}") - earned = self._earn(price * quantity, animal.product) + price = self.market_price(key) + value = round(price * quantity, 2) self.state.total_collected += quantity self.add_log( - f"收取 {quantity} 份{animal.product}({animal.name} #{animal.aid})," - f"+{earned} 金币", + f"收取 {quantity} 份{animal.product}({animal.name} #{animal.aid})入库," + f"现价 {price}", "good", ) levelups = self._gain_exp(animal.info["exp"] * quantity) self._notify() return { "ok": True, - "message": f"收取 {quantity} 份{animal.product},获得 {earned} 金币", + "message": ( + f"收取 {quantity} 份{animal.product}入谷仓,现价 {price}(约 {value} 金币)" + ), "quantity": quantity, - "earned": earned, + "price": price, + "value": value, "level_ups": levelups, + "storage_used": self.state.storage_used, } def buy_feed(self, quantity: Any = config.FEED_BULK) -> dict[str, Any]: @@ -650,6 +863,132 @@ class FarmEngine: "next_plot_price": self.state.next_plot_price, } + def sell(self, key: Any = None, quantity: Any = None) -> dict[str, Any]: + """把谷仓里的货物按**当前市价**卖掉。 + + ``key`` 省略 = 能卖的都卖;``quantity`` 省略 = 该货物全卖。 + 这是存货变回金币的唯一出口,所以 ``granary_full`` 时它是唯一的解法—— + ``available_actions`` 会在谷仓快满或价格见好时主动推荐它。 + """ + with self._lock: + state = self.state + if key is None: + if quantity is not None: + raise GameError("bad_param", "不指定 key 时不能只卖指定数量") + if not state.inventory: + raise GameError("nothing_to_sell", "谷仓还是空的,没东西可卖") + return self._sell_everything(state) + + if not isinstance(key, str) or config.goods_info(key) is None: + raise GameError( + "unknown_goods", + f"未知货物 {key!r},可选:{'、'.join(sorted(state.market))}", + ) + name = config.goods_name(key) + count = state.inventory.get(key, 0) + if count <= 0: + raise GameError("nothing_to_sell", f"谷仓里没有{name}") + if quantity is None: + quantity = count + try: + quantity = int(quantity) + except (TypeError, ValueError): + raise GameError("bad_param", "卖出数量必须是整数") from None + if quantity <= 0: + raise GameError("bad_param", "卖出数量必须大于 0") + quantity = min(quantity, count) + + price = self.market_price(key) + earned = self._earn(price * quantity, name) + left = count - quantity + if left > 0: + state.inventory[key] = left + else: + del state.inventory[key] + + text = f"卖出 {quantity} 份{name}(单价 {price}),+{earned} 金币" + self.add_log(text, "good") + self._notify() + return { + "ok": True, + "message": text, + "key": key, + "quantity": quantity, + "price": price, + "earned": earned, + "gold": state.gold, + "storage_used": state.storage_used, + } + + def _sell_everything(self, state: GameState) -> dict[str, Any]: + """一键清仓。带上明细,回执才能说清"到底卖了什么、各卖了多少"。""" + sold: list[dict[str, Any]] = [] + total = 0 + for key in sorted(state.inventory): + count = state.inventory[key] + price = self.market_price(key) + earned = round(price * count) + total += earned + sold.append( + { + "key": key, + "name": config.goods_name(key), + "quantity": count, + "price": price, + "earned": earned, + } + ) + state.inventory.clear() + self._earn(total, "卖出存货") + names = "、".join(f"{item['name']}×{item['quantity']}" for item in sold) + text = f"卖出 {names},共 +{total} 金币" + self.add_log(text, "good") + self._notify() + return { + "ok": True, + "message": text, + "earned": total, + "sold": sold, + "gold": state.gold, + "storage_used": state.storage_used, + } + + def build(self, key: Any) -> dict[str, Any]: + """造一座建筑。价格与解锁等级见 ``config.BUILDINGS``。""" + with self._lock: + if not isinstance(key, str) or key not in config.BUILDINGS: + raise GameError( + "unknown_building", + f"未知建筑 {key!r},可选:{'、'.join(config.BUILDINGS)}", + ) + entry = self._building_entry(self.state, key) + if entry["locked"]: + raise GameError( + "locked", + f"{entry['name']}需要 Lv.{entry['level_req']},当前 Lv.{self.state.level}", + ) + if entry["maxed"]: + raise GameError("build_limit", f"{entry['name']}已建满({entry['max_level']} 座)") + + self._spend_gold(entry["next_cost"], f"建造{entry['name']}") + self._spend_ap("build") + self.state.buildings[key] = entry["owned"] + 1 + built = self._building_entry(self.state, key) + self.add_log( + f"建成第 {built['owned']} 座{built['name']}(-{entry['next_cost']} 金币)", + "good", + ) + self._notify() + return { + "ok": True, + "message": ( + f"{built['name']} 已建成({built['owned']}/{built['max_level']}):" + f"{built['effect']}" + ), + "building": built, + "gold": self.state.gold, + } + def pause(self, paused: bool | None = None) -> dict[str, Any]: with self._lock: self.paused = bool(paused) if paused is not None else not self.paused @@ -665,6 +1004,8 @@ class FarmEngine: self.paused = False if keep_tick: self.tick_seconds = tick + self.state.weather = self._roll_weather() + self.state.forecast = self._roll_weather(previous=self.state.weather) self.add_log("新的一天,新的开始。欢迎来到 SimpleFarm!", "info") self._notify() return {"ok": True, "message": "农场已重置"} @@ -681,28 +1022,155 @@ class FarmEngine: self.add_log(f"已存档到 {target}", "info") return {"ok": True, "message": f"已存档到 {target}", "path": target} + # ================================================================ 天气与建筑 + def _roll_weather(self, previous: str | None = None) -> str: + """按权重掷一种天气;同一种不连着出现(最多重掷 WEATHER_REROLLS 次)。 + + 权重已经表达了稀有度,连续性约束是额外补的一层手感——防的是 + "一连五天干旱"这种既难玩、又不像真天气的序列。 + """ + keys = list(config.WEATHER) + weights = [float(config.WEATHER[key]["weight"]) for key in keys] + pick = keys[0] + for _ in range(config.WEATHER_REROLLS): + pick = random.choices(keys, weights=weights, k=1)[0] + if pick != previous: + return pick + return pick + + def _resolve_weather(self, state: GameState, forced: str | None) -> dict[str, Any]: + """定下今天的天气,并把预报翻到明天,返回今天天气的定义。 + + "预报"是刻意做成**提前一天可知**的:这样"明天要干旱,今晚先浇一遍" + 才是一个算得清的决策,而不是撞运气。正常玩法走预报;``forced`` + 只给测试与调试用(钉住"今天下雨"这类场景,否则断言会随掷骰抖动)。 + """ + if forced is not None and forced in config.WEATHER: + today = forced + elif state.forecast in config.WEATHER: + today = state.forecast + else: + today = self._roll_weather() + state.weather = today + state.forecast = self._roll_weather(previous=today) + return config.WEATHER[today] + + def _evaporation(self, state: GameState, info: dict[str, Any]) -> int: + """当天实际蒸发量;温室让蒸发减半(向下取整)。 + + 这是温室"省水"的那一半效果,另一半是免疫暴风雨(见 _storm_damage)。 + """ + evaporate = int(info["evaporation"]) + if state.building_count("greenhouse"): + evaporate //= 2 + return evaporate + + def _run_buildings(self, state: GameState) -> list[str]: + """结算建筑的每日自动动作:水井浇水、喂食器喂食。 + + 两件事都只替代"玩家本来要花行动点做的事",且都**不替玩家花钱**: + 喂食器只在库存饲料够时动作,缺了就停摆并记一条日志,而不是像手动 + 喂食那样以应急价临时采购——自动系统悄悄花光金币是最容易被骂的设计。 + """ + return self._wells(state) + self._feeders(state) + + def _wells(self, state: GameState) -> list[str]: + quota = state.building_count("well") + if quota <= 0: + return [] + events: list[str] = [] + for plot in state.plots: + if quota <= 0: + break + if plot.crop is None or plot.moisture > 0: + continue + plot.moisture = config.WATER_CAPACITY + quota -= 1 + events.append(f"水井给 {plot.index + 1} 号地浇了水") + return events + + def _feeders(self, state: GameState) -> list[str]: + quota = state.building_count("feeder") + if quota <= 0: + return [] + events: list[str] = [] + for animal in state.animals: + if quota <= 0: + break + if animal.fed: + continue + need = int(animal.info["feed_per_day"]) + if state.feed < need: + # 只报一次就停:饲料见底时每只都报一条会刷爆日志 + events.append("饲料不够,自动喂食器停摆") + break + state.feed -= need + animal.fed = True + animal.starve = 0 + quota -= 1 + events.append(f"自动喂食器喂了{animal.name} #{animal.aid}") + return events + + def _storm_damage(self, state: GameState, info: dict[str, Any]) -> list[str]: + """暴风雨:每块**未成熟**的作物独立判定,被毁则地块直接清空。 + + 成熟的作物不参与判定——"等你来收的东西被天灾吃掉"只会让人关掉游戏。 + 温室建成后整段失效。 + """ + damage = float(info["damage"]) + if damage <= 0 or state.building_count("greenhouse"): + return [] + events: list[str] = [] + for plot in state.plots: + if plot.crop is None or plot.is_ripe or random.random() >= damage: + continue + name = config.CROPS[plot.crop]["name"] + plot.crop = None + plot.growth = 0.0 + plot.moisture = 0 + plot.planted_day = 0 + text = f"暴风雨毁了 {plot.index + 1} 号地的{name}" + events.append(text) + self.add_log(text, "bad") + return events + # ================================================================ 时间推进 - def advance_day(self, reason: str = "自动") -> list[str]: - """推进游戏内一天,返回本日发生的事件文本。""" + def advance_day(self, reason: str = "自动", weather: str | None = None) -> list[str]: + """推进游戏内一天,返回本日发生的事件文本。 + + ``weather`` 是留给测试与调试的显式入口:不传就按"昨天的预报"结算 + (正常玩法走这条),传了就强制使用该天气。 + """ with self._lock: state = self.state + info = self._resolve_weather(state, weather) state.day += 1 state.ap = state.max_ap events: list[str] = [] - # ---- 作物生长 + # ---- 建筑自动化。必须排在生长结算之前:水井救的正是"今天就断水"的 + # 那块地,晚一步它今天就白等了。 + events.extend(self._run_buildings(state)) + + # ---- 作物生长:水分消耗改由天气决定(0~2),浇水于是变成看天吃饭 + evaporate = self._evaporation(state, info) + gain = int(info["water_gain"]) for plot in state.plots: if plot.crop is None: continue - if plot.moisture > 0: - plot.moisture -= 1 - plot.growth = min(plot.grow_days, plot.growth + 1) - if plot.is_ripe: - events.append( - f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟" - ) - else: + if gain: + # 雨天/暴风雨先补水再蒸发,等于免费替你浇了一遍 + plot.moisture = min(config.WATER_CAPACITY, plot.moisture + gain) + if plot.moisture <= 0: events.append(f"{plot.index + 1} 号地缺水,作物停止生长") + continue + plot.moisture = max(0, plot.moisture - evaporate) + plot.growth = min(plot.grow_days, plot.growth + 1) + if plot.is_ripe: + events.append(f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟") + + # ---- 天灾:暴风雨只毁**还没成熟**的作物 + events.extend(self._storm_damage(state, info)) # ---- 牲畜 dead: list[Animal] = [] @@ -732,10 +1200,11 @@ class FarmEngine: # ---- 写入日志(最多 4 条,避免刷屏) for text in events[:4]: self.add_log(text, "warn") + today = f"第 {state.day} 天开始({reason}),今日{info['name']}" if events: - self.add_log(f"第 {state.day} 天开始({reason}),行动点已恢复", "info") + self.add_log(f"{today},行动点已恢复", "info") else: - self.add_log(f"第 {state.day} 天开始({reason}),一切安好", "info") + self.add_log(f"{today},一切安好", "info") self._notify() return events diff --git a/farm/models.py b/farm/models.py index 5f67848..8d554ff 100644 --- a/farm/models.py +++ b/farm/models.py @@ -219,6 +219,17 @@ class GameState: self.total_spent: int = 0 self.plots_bought: int = 0 + # 谷仓:收获先进这里,卖不卖、什么时候卖由玩家决定。 + # 键与 self.market 完全同构(作物用 "wheat",畜产品用 "animal:chicken"), + # 这样定价、序列化、存档校验都只有一套规则。 + self.inventory: dict[str, int] = {} + # 建筑:key -> 已建座数(0 表示还没造) + self.buildings: dict[str, int] = dict.fromkeys(config.BUILDINGS, 0) + # 天气:今天与明天。掷骰在 engine 里做,模型层只持有结果, + # 这样"存/读档"不需要重放随机序列就能复原当天的天气。 + self.weather: str = config.DEFAULT_WEATHER + self.forecast: str = config.DEFAULT_WEATHER + # ---------------------------------------------------------------- 派生属性 @property def max_ap(self) -> int: @@ -254,7 +265,7 @@ class GameState: @property def net_worth(self) -> float: - """净资产:金币 + 已购地价 + 牲畜折价 + 库存 + 地里未收的作物。""" + """净资产:金币 + 已购地价 + 牲畜折价 + 饲料 + 地里未收的作物 + 谷仓存货。""" land = sum( config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH**i) for i in range(self.plots_bought) ) @@ -262,7 +273,8 @@ class GameState: for plot in self.plots: if plot.crop: crops += self.market.get(plot.crop, 0) * (plot.growth / max(1, plot.grow_days)) - return self.gold + land + self.livestock_value + self.inventory_value + crops + stored = self.goods_value + return self.gold + land + self.livestock_value + self.inventory_value + crops + stored @property def goal_index(self) -> int: @@ -272,6 +284,32 @@ class GameState: def next_goal(self) -> dict[str, Any] | None: return next_goal_after(self.goal_index) + @property + def granary_capacity(self) -> int: + """仓储上限:基础值 + 等级成长 + 谷仓建筑。""" + cap = ( + config.GRANARY_BASE + + config.GRANARY_PER_LEVEL * (self.level - 1) + + config.GRANARY_PER_UPGRADE * self.building_count("granary") + ) + return int(clamp(cap, config.GRANARY_BASE, config.MAX_GRANARY)) + + @property + def storage_used(self) -> int: + return sum(self.inventory.values()) + + @property + def granary_free(self) -> int: + return max(0, self.granary_capacity - self.storage_used) + + @property + def goods_value(self) -> float: + """谷仓存货按**当前市价**的估值(会随市场波动,这正是囤货的风险与收益)。""" + return sum(count * self.market.get(key, 0.0) for key, count in self.inventory.items()) + + def building_count(self, key: str) -> int: + return int(self.buildings.get(key, 0)) + # ---------------------------------------------------------------- 序列化 def to_dict(self) -> dict[str, Any]: # net_worth 要遍历地块与牲畜,是这里最贵的一项;下面三处都要用它, @@ -280,6 +318,11 @@ class GameState: goal = config.goal_index(worth) return { "day": self.day, + "weather": self.weather, + "weather_name": config.weather_info(self.weather)["name"], + "weather_desc": config.weather_info(self.weather)["desc"], + "forecast": self.forecast, + "forecast_name": config.weather_info(self.forecast)["name"], "gold": self.gold, "level": self.level, "exp": self.exp, @@ -287,6 +330,11 @@ class GameState: "ap": self.ap, "max_ap": self.max_ap, "feed": self.feed, + "inventory": dict(sorted(self.inventory.items())), + "storage_used": self.storage_used, + "granary_capacity": self.granary_capacity, + "granary_free": self.granary_free, + "buildings": dict(self.buildings), "plots": [p.to_dict() for p in self.plots], "plot_count": len(self.plots), "max_plots": self.max_plots, @@ -333,6 +381,22 @@ class GameState: animals.append(animal) state.animals = animals + raw_buildings = data.get("buildings", {}) + if isinstance(raw_buildings, dict): + for key, info in config.BUILDINGS.items(): + try: + owned = int(raw_buildings.get(key, 0)) + except (TypeError, ValueError): + continue + state.buildings[key] = int(clamp(owned, 0, info["max_level"])) + + weather = data.get("weather") + if isinstance(weather, str) and weather in config.WEATHER: + state.weather = weather + forecast = data.get("forecast") + if isinstance(forecast, str) and forecast in config.WEATHER: + state.forecast = forecast + market = data.get("market", {}) for key in list(state.market.keys()): try: @@ -341,6 +405,20 @@ class GameState: continue state.market[key] = clamp(value, 1.0, 100000.0) + # 存货键必须存在于 market:顺手挡住"手改存档塞进来的野键", + # 否则它会一直躺在谷仓里、既卖不掉也算不进净资产。 + raw_inventory = data.get("inventory", {}) + if isinstance(raw_inventory, dict): + for key, count in raw_inventory.items(): + if key not in state.market: + continue + try: + amount = int(count) + except (TypeError, ValueError): + continue + if amount > 0: + state.inventory[key] = amount + stats = data.get("stats", {}) state.total_harvested = max(0, int(stats.get("harvested", 0))) state.total_collected = max(0, int(stats.get("collected", 0))) diff --git a/farm/server/__main__.py b/farm/server/__main__.py index 0719570..8a4658f 100644 --- a/farm/server/__main__.py +++ b/farm/server/__main__.py @@ -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: diff --git a/farm/server/actions.py b/farm/server/actions.py index 26228f6..726c315 100644 --- a/farm/server/actions.py +++ b/farm/server/actions.py @@ -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() 只会写默认路径, diff --git a/farm/server/app.py b/farm/server/app.py index 9ab2e43..67cf56f 100644 --- a/farm/server/app.py +++ b/farm/server/app.py @@ -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", diff --git a/farm/server/daemon.py b/farm/server/daemon.py index 2bf215f..6778992 100644 --- a/farm/server/daemon.py +++ b/farm/server/daemon.py @@ -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" diff --git a/farm/server/webui.py b/farm/server/webui.py index ff91557..96fe259 100644 --- a/farm/server/webui.py +++ b/farm/server/webui.py @@ -142,6 +142,17 @@ DASHBOARD_HTML = r"""

市场行情 红涨绿跌

+
+

谷仓

+
+
+ +
+
+
+

建筑 花金币换自动化

+
+

服务器排行榜

@@ -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) => '
' + p[0] + '' + p[1] + '
').join(""); @@ -314,18 +336,48 @@ function render() { (a.pending > 0 ? '' : '') + '' ).join("") : '
  • 牧场空空的,先买一只吧
  • '; - /* 市场 */ - 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 = '作物现价基准' + - 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 = '货物现价基准' + + 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 '' + (cropNames[kv[0]] || kv[0]) + '' + - kv[1].toFixed(0) + " " + arrow + '' + b + ''; + return '' + goodsName(kv[0]) + '' + + kv[1].toFixed(0) + " " + arrow + '' + b.toFixed(0) + ''; }).join(""); + /* 谷仓:卖货是唯一把存货变成金币的出口,所以每个存项都直接给一个卖出按钮 */ + const inv = Object.entries(s.inventory || {}).filter((kv) => kv[1] > 0); + $("granaryhint").textContent = + "仓容 " + (s.storage_used || 0) + "/" + (s.granary_capacity || 0); + $("granary").innerHTML = + '货物存量现价' + + '基准' + + (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 '' + goodsName(kv[0]) + '' + kv[1] + + '' + price.toFixed(0) + '' + + '' + (b ? b.toFixed(0) : "—") + '' + + ''; + }).join("") + : '空仓:收获与收取的产出会自动入库'); + + /* 建筑 */ + 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 ''; + }).join("") : '目录加载中…'; + 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" diff --git a/farm/storage.py b/farm/storage.py index 261bc93..b6b04e0 100644 --- a/farm/storage.py +++ b/farm/storage.py @@ -13,7 +13,10 @@ from typing import Any from .models import GameState -SAVE_VERSION = 1 +SAVE_VERSION = 2 # v2 起:谷仓存货 / 建筑 / 天气 +# 还能读回来的最老版本。存档读取是「宽容向后」的:老档缺的新字段 +# 一律走默认值,所以没必要因为升了一版就把玩家手里的档作废。 +MIN_SAVE_VERSION = 1 def default_save_path() -> str: @@ -49,11 +52,15 @@ def _raw_state(state: GameState) -> dict[str, Any]: """导出可完整还原的原始字段(与 to_dict 的展示字段不同)。""" return { "day": state.day, + "weather": state.weather, + "forecast": state.forecast, "gold": state.gold, "level": state.level, "exp": state.exp, "ap": state.ap, "feed": state.feed, + "inventory": dict(state.inventory), + "buildings": dict(state.buildings), "plots": [ { "index": p.index, @@ -105,8 +112,10 @@ def load_state(path: str | None = None) -> tuple[GameState | None, str]: return None, "存档格式错误:根节点不是对象" version = payload.get("version", 0) - if version != SAVE_VERSION: - return None, f"存档版本不兼容(文件 v{version},当前 v{SAVE_VERSION})" + if not isinstance(version, int) or not (MIN_SAVE_VERSION <= version <= SAVE_VERSION): + return None, ( + f"存档版本不兼容(文件 v{version},当前支持 v{MIN_SAVE_VERSION}~v{SAVE_VERSION})" + ) raw = payload.get("state") if not isinstance(raw, dict): diff --git a/farm/tui.py b/farm/tui.py index e478680..714a25b 100644 --- a/farm/tui.py +++ b/farm/tui.py @@ -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( diff --git a/main.py b/main.py index c1dd8f8..5c70bce 100644 --- a/main.py +++ b/main.py @@ -515,9 +515,32 @@ def cmd_state(args) -> int: f"金币 {snap.get('gold')} 等级 Lv.{snap.get('level')} " f"({snap.get('exp')}/{snap.get('exp_needed')})" ) + day_weather = snap.get("weather_name") + if day_weather: + desc = snap.get("weather_desc") + tomorrow = snap.get("forecast_name") + print(f"天气 {day_weather}({desc})· 明日 {tomorrow}") print(f"行动点 {snap.get('ap')}/{snap.get('max_ap')} 饲料 {snap.get('feed')}") print(f"净资产 {snap.get('net_worth')}") print(f"土地 {snap.get('plot_count')}/{snap.get('max_plots')} 块") + + used = snap.get("storage_used") or 0 + capacity = snap.get("granary_capacity") or 0 + tail = " ← 已满,先 sell 卖点存货" if (snap.get("granary_free") or 0) <= 0 else "" + print(f"谷仓 {used}/{capacity} 格{tail}") + market = snap.get("market") or {} + for key, count in sorted((snap.get("inventory") or {}).items()): + price = float(market.get(key, 0.0)) + base = config.base_price(key) + ratio = (price / base - 1) * 100 if base else 0.0 + print( + f" {config.goods_name(key):<6} x{count:<4} 现价 {price:>7.1f}" + f"(基准 {base:>6.1f},{ratio:+.0f}%)" + ) + built = {k: v for k, v in (snap.get("buildings") or {}).items() if v} + if built: + names = {k: v["name"] for k, v in config.BUILDINGS.items()} + print("建筑 " + " ".join(f"{names[k]}×{v}" for k, v in built.items())) for plot in snap.get("plots", []): if plot["is_empty"]: continue @@ -558,10 +581,14 @@ def cmd_watch(args) -> int: last_seq = max(last_seq, int(entry.get("seq", 0))) print(f"[D{entry['day']:>3}] {entry['text']}") online = snap.get("online") + day_no = snap.get("day") + weather = snap.get("weather_name") or "—" + used = snap.get("storage_used") or 0 + capacity = snap.get("granary_capacity") or 0 head = ( - f"第 {snap.get('day')} 天 | 金币 {snap.get('gold')} | " + f"第 {day_no} 天 {weather} | 金币 {snap.get('gold')} | " f"Lv.{snap.get('level')} | 行动点 {snap.get('ap')}/{snap.get('max_ap')} | " - f"净资产 {snap.get('net_worth')}" + f"仓储 {used}/{capacity} | 净资产 {snap.get('net_worth')}" ) if online is not None: head += f" | 在线 {online}" @@ -635,6 +662,8 @@ QUICKSTART = """ uv run main.py actions # 我当前能做什么 uv run main.py do harvest '{"plot":1}' + uv run main.py do sell '{"key":"wheat"}' # 卖货才换得到金币 + uv run main.py do build '{"key":"well"}' # 造建筑换自动化 uv run main.py state --json # 机器可读的完整状态 uv run main.py watch # 实时看别人/机器人玩 diff --git a/tests/test_actions.py b/tests/test_actions.py index e21f27d..fb8ee45 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -30,6 +30,8 @@ EXPECTED_ACTIONS = { "collect", "buy_feed", "buy_plot", + "sell", + "build", "pause", } @@ -292,9 +294,7 @@ class HarvestAllTestCase(unittest.TestCase): result = run_action(self.engine, "harvest_all", {}) self.assertEqual(result["plots"], [1, 2]) - self.assertEqual( - result["quantity"], result["earned"] // config.CROPS["wheat"]["base_price"] - ) + self.assertEqual(result["quantity"], result["value"] // config.CROPS["wheat"]["base_price"]) self.assertEqual(result["errors"], []) self.assertFalse(self.engine.state.plots[2].is_empty, "没熟的 3 号地要保持原样") diff --git a/tests/test_bot.py b/tests/test_bot.py index d811254..4af1443 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -291,15 +291,31 @@ class RunTestCase(unittest.TestCase): self.assertEqual(set(summary) >= {"name", "days", "actions", "failures", "net_worth"}, True) def test_no_action_points_means_clocking_out(self): + # AP 用完时,服务端的 available_actions 只会剩下 0 消耗的动作 + # (卖货/建造/收获/收取),这里模拟"连这些都没有"的极端情况: + # 机器人应当直接收工,而不是空转或乱发请求。 client = FakeClient( - actions=[{"action": "harvest", "params": {"plot": 1}}], + actions=[], state={**BASE_STATE, "ap": 0}, catalog=CATALOG, ) bot = make_bot(client) summary = bot.run(rounds=5) self.assertEqual(summary["actions"], 0) - self.assertEqual(client.calls, [], "没行动点就不该发动作请求") + self.assertEqual(client.calls, [], "没得做就不该发动作请求") + + def test_free_actions_still_run_without_action_points(self): + # 新设计:收获不再直接给钱,得靠 sell 把谷仓里的货变现;而 sell/build + # 是 0 消耗动作,AP=0 时也必须能执行,否则一整天只能干等着。 + client = FakeClient( + actions=[{"action": "sell", "params": {}}], + state={**BASE_STATE, "ap": 0}, + catalog=CATALOG, + ) + bot = make_bot(client) + summary = bot.run(rounds=1) + self.assertEqual(summary["actions"], 1) + self.assertEqual(client.calls, [("sell", {})]) def test_idle_limit_triggers_the_waiting_path(self): client = FakeClient(actions=[], state=BASE_STATE) diff --git a/tests/test_engine.py b/tests/test_engine.py index 6948af7..74dd34d 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -370,15 +370,17 @@ class ActionTestCase(unittest.TestCase): self.assert_code("already_watered", self.engine.water, 2) # ------------------------------------------------------------------ 收获 - def test_harvest_pays_the_market_price(self): + def test_harvest_lands_in_the_granary_at_market_price(self): self.engine.plant(1, "wheat") self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"] gold = self.engine.state.gold with mock.patch("farm.engine.random.random", return_value=0.99): result = self.engine.harvest(1) self.assertEqual(result["quantity"], 1) - self.assertEqual(result["earned"], config.CROPS["wheat"]["base_price"]) - self.assertEqual(self.engine.state.gold, gold + result["earned"]) + self.assertEqual(result["price"], self.engine.market_price("wheat")) + self.assertEqual(result["value"], result["price"] * result["quantity"]) + self.assertEqual(self.engine.state.inventory["wheat"], 1, "收成先进谷仓") + self.assertEqual(self.engine.state.gold, gold, "收获本身不给钱,卖了才给") self.assertTrue(self.engine.state.plots[0].is_empty) def test_lucky_harvest_doubles_the_yield(self): @@ -387,7 +389,7 @@ class ActionTestCase(unittest.TestCase): with mock.patch("farm.engine.random.random", return_value=0.0): result = self.engine.harvest(1) self.assertEqual(result["quantity"], 2) - self.assertEqual(result["earned"], config.CROPS["wheat"]["base_price"] * 2) + self.assertEqual(self.engine.state.inventory["wheat"], 2) def test_harvest_before_ripe_is_refused(self): self.engine.plant(1, "carrot") @@ -458,15 +460,17 @@ class ActionTestCase(unittest.TestCase): self.assert_code("bad_param", self.engine.feed, aid, 0) self.assert_code("bad_param", self.engine.feed, aid, "many") - def test_collect_pays_for_every_pending_product(self): + def test_collect_lands_every_pending_product_in_the_granary(self): self.engine.buy_animal("chicken") animal = self.engine.state.animals[0] animal.pending = 3 gold = self.engine.state.gold result = self.engine.collect(animal.aid) self.assertEqual(result["quantity"], 3) - self.assertEqual(result["earned"], config.ANIMALS["chicken"]["base_price"] * 3) - self.assertEqual(self.engine.state.gold, gold + result["earned"]) + self.assertEqual(result["price"], self.engine.market_price("animal:chicken")) + self.assertEqual(result["value"], result["price"] * 3) + self.assertEqual(self.engine.state.inventory["animal:chicken"], 3, "产物先入库") + self.assertEqual(self.engine.state.gold, gold, "收取本身不给钱,卖了才给") self.assertEqual(animal.pending, 0) def test_collect_without_output_is_refused(self): @@ -563,25 +567,26 @@ class GrowthTestCase(unittest.TestCase): self.engine.plant(1, "carrot") # 3 天,刚好够观察缺水停摆 self.assertEqual(self.engine.state.plots[0].moisture, config.PLANT_INITIAL_MOISTURE) - self.engine.advance_day() + # 天气按天随机,这里钉死成晴天(每天蒸发 1 点),才观察得到"断水停摆"。 + self.engine.advance_day(weather="sunny") self.assertEqual(self.engine.state.plots[0].growth, 1) - self.engine.advance_day() + self.engine.advance_day(weather="sunny") self.assertEqual(self.engine.state.plots[0].moisture, 0) self.assertEqual(self.engine.state.plots[0].growth, 2) - events = self.engine.advance_day() + events = self.engine.advance_day(weather="sunny") self.assertEqual(self.engine.state.plots[0].growth, 2, "没水就不该再长") self.assertTrue(any("缺水" in text for text in events)) def test_watering_resumes_growth(self): self.engine.plant(1, "carrot") - self.engine.advance_day() - self.engine.advance_day() - self.engine.advance_day() # 此刻缺水停摆 + self.engine.advance_day(weather="sunny") + self.engine.advance_day(weather="sunny") + self.engine.advance_day(weather="sunny") # 此刻缺水停摆 self.assertEqual(self.engine.state.plots[0].growth, 2, "断水期间不该生长") self.engine.water(1) # 浇到 3 点水 - self.engine.advance_day() + self.engine.advance_day(weather="sunny") self.assertEqual(self.engine.state.plots[0].growth, 3, "浇水后又开始长了") self.assertTrue(self.engine.state.plots[0].is_ripe) diff --git a/tests/test_server.py b/tests/test_server.py index bb9853f..9e59776 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -24,7 +24,7 @@ import urllib.request from farm import config, net from farm.engine import GameError -from farm.server.daemon import DaemonManager, pid_alive, port_busy +from farm.server.daemon import DaemonManager, ensure_parent_dir, pid_alive, port_busy from farm.server.hub import ( CLOSE_MESSAGE, Client, @@ -657,11 +657,16 @@ class BriefAndRulesTestCase(unittest.TestCase): self.hub = ConnectionHub() self.session = self.manager.get("alice") - def test_brief_keys_exist_in_the_snapshot(self): - """回归:snapshot 改字段名时,brief 必须跟着改,否则动作回执直接 500。""" - snap = self.session.snapshot() + def test_brief_keys_exist_in_the_brief_payload(self): + """回归:brief_source 改字段名时,BRIEF_KEYS 必须跟着改,否则动作回执直接 500。 + + BRIEF_KEYS 的数据源是 ``FarmEngine.brief_source()``(精简状态), + **不是**完整快照 ``session.snapshot()``——后者包含 market/plots 等大块数据, + 拿它来断言会把"brief 只读精简字段"这个设计意图测丢。 + """ + data = brief(self.session, self.hub) for key in BRIEF_KEYS: - self.assertIn(key, snap, f"快照里没有 {key}") + self.assertIn(key, data, f"回执里没有 {key}") def test_brief_exposes_the_to_do_lists(self): self.session.engine.plant(1, "wheat") @@ -805,6 +810,31 @@ class DaemonTestCase(unittest.TestCase): self.assertEqual(len(lines), 5) self.assertEqual(lines[-1], "第49行") + def test_ensure_parent_dir_tolerates_a_bare_file_name(self): + """回归:路径没有目录部分时 dirname 返回空串,makedirs("") 直接崩。""" + ensure_parent_dir("server.pid") # 不该抛 + ensure_parent_dir(os.path.join(self.dir, "a", "b", "server.pid")) + self.assertTrue(os.path.isdir(os.path.join(self.dir, "a", "b"))) + + def test_pid_file_without_a_directory_still_works(self): + """``--pid-file server.pid`` 这种只给文件名的用法必须能起来。""" + manager = DaemonManager( + host="127.0.0.1", + port=free_port(), + save_dir=self.dir, + pid_file="server.pid", + log_file="server.log", + ) + cwd = os.getcwd() + os.chdir(self.dir) + try: + manager.write_pid(4321) + self.assertEqual(manager.read_pid(), 4321) + manager.clear_pid() + self.assertIsNone(manager.read_pid()) + finally: + os.chdir(cwd) + @unittest.skipUnless(HAS_TESTCLIENT, "未安装 httpx(TestClient 依赖它)") class RestApiTestCase(unittest.TestCase): diff --git a/tests/test_tui_keys.py b/tests/test_tui_keys.py index 1955089..b51fb78 100644 --- a/tests/test_tui_keys.py +++ b/tests/test_tui_keys.py @@ -455,9 +455,9 @@ class TuiLetterKeyBehaviourTestCase(unittest.TestCase): self.assertEqual(self.engine.state.plots[0].moisture, config.WATER_CAPACITY) self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"] - gold = self.engine.state.gold + before = self.engine.state.inventory.get("wheat", 0) self.app._handle_key("h") - self.assertGreater(self.engine.state.gold, gold) + self.assertGreater(self.engine.state.inventory.get("wheat", 0), before, "收获进谷仓") def test_e_harvests_everything_ripe(self): for number in (1, 2, 3): @@ -481,9 +481,11 @@ class TuiLetterKeyBehaviourTestCase(unittest.TestCase): def test_c_collects_the_focused_animal(self): self.engine.buy_animal("chicken") self.engine.state.animals[0].pending = 2 - gold = self.engine.state.gold + before = self.engine.state.inventory.get("animal:chicken", 0) self.app._handle_key("c") - self.assertGreater(self.engine.state.gold, gold) + self.assertGreater( + self.engine.state.inventory.get("animal:chicken", 0), before, "产物进谷仓" + ) def test_l_buys_land_and_k_buys_feed(self): self.engine.state.level = 2