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"""