feat: 加天气 / 谷仓 / 建筑三套系统,收获改为入库 + 择时卖出

动手前先修掉了两处「死机制」,新增的三个系统各自激活一个:

* 天气:小麦 2 天成熟、初始水分 2、每天只掉 1 点,等于永远不需要浇水。
  现在水分消耗由天气决定(晴 1 / 多云 0 / 干旱 2,雨天反补 3),而
  `state.forecast` 把明天的天气提前告诉你——「今晚浇不浇」成了能算的决策。
  5 种天气、权重合计 100、同种不连出;暴风雨会毁未熟作物,温室免疫。
* 谷仓:市场波动原本纯装饰(收获当场按市价变现,没有择时空间)。现在收获与
  收取只进 `state.inventory`,靠新增的 `sell` 换金币,价格波动才真正参与决策。
  仓满时 `_store()` 抛 `granary_full` 且不改状态,作物原样留在地里。
* 建筑:新增 `build`,买的是「以后不用天天手动做」——水井自动浇干地、
  喂食器自动喂食(只吃库存饲料,绝不自动花钱)、温室、谷仓扩容。
  每天在 `advance_day` 里先于生长执行,同类越造越贵(×1.6)。

存档升到 `SAVE_VERSION=2`,配 `MIN_SAVE_VERSION=1` 向后兼容,旧档缺的新字段
一律走默认值,不作废玩家手里的档。`advance_day(weather=...)` 是留给测试的
确定性入口。

顺带修两个真 bug:
* `daemon.py` 三处 `makedirs(os.path.dirname(path))`:`--pid-file pid.txt`
  这种只给文件名的用法会让 dirname 变成空串,启动阶段直接崩。抽出
  `ensure_parent_dir()` 兜底。
* `RemoteFarm.snapshot()` 是本地缓存,非实时客户端轮询会一直读到旧数据,
  看起来像服务器卡死。补 docstring 指明要自己调 `refresh()`。

质量门禁:ruff format/check 全绿、pyright 0 错误、503 项测试 OK(skip 35)、
bench 无回归(brief 19.5µs / snapshot 48.5µs / POST full=1 400µs)。
另起真实服务器(--tick 3 + 预置存档)跑了 38 项端到端验收,全过。
This commit is contained in:
snowy-yang committed 2026-09-19 17:02:33 +08:00
1 parent a92c29ead2
commit cf24cb43f1
21 files changed
+1348 -111

No files matched your search

+44
View File
@@ -236,3 +236,47 @@
最后一次核对:`git status` → "up to date with 'origin/main',working tree clean"。 最后一次核对:`git status` → "up to date with 'origin/main',working tree clean"。
- 远端内容已用 API 核对:3 个提交齐全(450655b / f8e093c / 47dd818)、根目录 13 个条目 - 远端内容已用 API 核对:3 个提交齐全(450655b / f8e093c / 47dd818)、根目录 13 个条目
(.gitattributes / .githubignore / README.md 25KB / farm/ / tests/ / tools/ / uv.lock / pyproject.toml …)。 (.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 次」是被干旱实打实逼出来的,说明天气→浇水这条链真在起作用。
+24
View File
@@ -35,6 +35,23 @@ TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务
- 新建牧场时 `FarmSession` 会立刻设好 `next_tick_at`,否则到时钟线程下一轮(≤0.2s) - 新建牧场时 `FarmSession` 会立刻设好 `next_tick_at`,否则到时钟线程下一轮(≤0.2s)
之间倒计时是 None,客户端会误显示成“已暂停/手动模式”。 之间倒计时是 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.engine` 只管规则,不认识网络/终端;`farm.tui` 不认识 socket;
`farm/server/*` 不认识终端。新增玩法动作只改 `farm/server/actions.py` 一张表。 `farm/server/*` 不认识终端。新增玩法动作只改 `farm/server/actions.py` 一张表。
@@ -101,6 +118,13 @@ TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务
不要在 `catalog` / `available_actions` / `plant` / `buy_animal` / `_gain_exp` 各写一份。 不要在 `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); - Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32);
若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。 若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。
一律"先读完 → 关掉句柄 → 再删"。 一律"先读完 → 关掉句柄 → 再删"。
+20 -8
View File
@@ -56,11 +56,14 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
| 系统 | 规则 | | 系统 | 规则 |
| --- | --- | | --- | --- |
| **时间** | 每个牧场每 **40 分钟**自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 | | **时间** | 每个牧场每 **40 分钟**自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 |
| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获和收取产出免费。用完只能等第二天——时间只能自然流动,没有手动快进 | | **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获、收取、卖货、建造都免费。用完只能等第二天——时间只能自然流动,没有手动快进 |
| **水分** | 作物每天消耗 1 点水分(上限 3),水分为 0 停止生长;播种自带 2 点 | | **天气** | 每天从晴 / 多云 / 雨 / 干旱 / 暴风雨里抽一种(同种不连着出),而且**明天的天气提前可见**——「今晚浇不浇」因此是个能算的决策 |
| **水分** | 每天按天气消耗:晴 1、多云 0、干旱 2,雨天反补 3(上限 3);水分为 0 就停止生长。播种自带 2 点,只够晴天撑两天,所以下种前得先看预报 |
| **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 | | **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 |
| **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** | | **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** |
| **市场** | 作物与畜产品价格每天在基础价 ±18% 内波动,高位卖出更赚 | | **谷仓** | 收获和收取**只入库、不当场给钱**(容量 40 起,随等级与谷仓建筑增长);仓满时收不进去,作物原样留在地里,卖了再来 |
| **市场** | 价格每天在基础价 ±18% 内波动;**靠 `sell` 择时变现**,高位卖才真的赚 |
| **建筑** | 谷仓(+25 仓容,最多 5 座)/ 水井(每天自动浇一块干透的地)/ 自动喂食器(每天自动喂一只,只吃库存饲料、绝不自动花钱)/ 温室(免疫暴风雨、水分蒸发减半);每多一座涨价 60% |
| **土地** | 初始 6 块,每升 1 级 +2 块,上限 16 块,买地价格指数递增 | | **土地** | 初始 6 块,每升 1 级 +2 块,上限 16 块,买地价格指数递增 |
| **目标** | 净资产(金币+土地+牲畜+库存+地里作物)做到 10000 金币,共 5 个里程碑 | | **目标** | 净资产(金币+土地+牲畜+库存+地里作物)做到 10000 金币,共 5 个里程碑 |
| **丰收** | 收获有 12% 概率触发双倍产出 | | **丰收** | 收获有 12% 概率触发双倍产出 |
@@ -69,7 +72,8 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
> **关于节奏**:这是一款“放置型”农场,一天 40 分钟是刻意设计的,不是笔误。 > **关于节奏**:这是一款“放置型”农场,一天 40 分钟是刻意设计的,不是笔误。
> 每天 10 点行动点,用完就是等时间流动——所以一天的玩法是“上来干十件事,然后离开”。 > 每天 10 点行动点,用完就是等时间流动——所以一天的玩法是“上来干十件事,然后离开”。
> 小麦 2 天(80 分钟)成熟,草莓 10 天(约 6.7 小时)。想调试就把节奏调快: > 小麦 2 天(80 分钟)成熟,草莓 10 天(约 6.7 小时);断水会停长,所以实际天数看天吃饭——
> 好在预报提前一天就给你,来得及浇。想调试就把节奏调快:
> `uv run main.py play --tick 3`(一天 3 秒)。 > `uv run main.py play --tick 3`(一天 3 秒)。
--- ---
@@ -81,7 +85,7 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
``` ```
[↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获 [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获
[F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料 [F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料
[空格]暂停 [S]存档 [R]重置 [Q]退出 [M]卖货 [G]建造 [空格]暂停 [S]存档 [R]重置 [Q]退出
``` ```
界面用双宽字符感知的对齐算法(`display_width`),在 Windows Terminal / iTerm / VSCode 里边框都不会歪。 界面用双宽字符感知的对齐算法(`display_width`),在 Windows Terminal / iTerm / VSCode 里边框都不会歪。
@@ -156,18 +160,18 @@ POST /api/actions → {"action":"<上一步的 action>","params":{.
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/api/health` | 健康检查(唯一不需要令牌的接口) | | GET | `/api/health` | 健康检查(唯一不需要令牌的接口) |
| GET | `/api/help` | 规则 + 动作清单 + 参数类型 | | GET | `/api/help` | 规则 + 动作清单 + 参数类型 + 建筑 / 天气表 |
| GET | `/api/world` | 世界概览:排行榜、在线连接、时钟 | | GET | `/api/world` | 世界概览:排行榜、在线连接、时钟 |
| GET | `/api/farms` | 牧场列表(按净资产排名) | | GET | `/api/farms` | 牧场列表(按净资产排名) |
| POST | `/api/farms` | `{"farm":"名字","owner":"昵称","tick":10}` 创建/进入 | | POST | `/api/farms` | `{"farm":"名字","owner":"昵称","tick":10}` 创建/进入 |
| GET | `/api/state` | 完整状态快照 | | GET | `/api/state` | 完整状态快照 |
| GET | `/api/catalog` | 作物 / 牲畜 / 实时价格 / 每单位收益 | | GET | `/api/catalog` | 作物 / 牲畜 / 商品与实时价格 / 建筑 / 天气表 / 仓容 |
| GET | `/api/actions` | 当前合法动作(AI 推荐入口) | | GET | `/api/actions` | 当前合法动作(AI 推荐入口) |
| GET | `/api/log?limit=20&since=0` | 事件流(`since` 做增量拉取) | | GET | `/api/log?limit=20&since=0` | 事件流(`since` 做增量拉取) |
| GET | `/api/stream` | **SSE 实时推送**(服务器 → 客户端单向长连接) | | GET | `/api/stream` | **SSE 实时推送**(服务器 → 客户端单向长连接) |
| POST | `/api/actions` | **通用动作入口** | | POST | `/api/actions` | **通用动作入口** |
| POST | `/api/batch` | 批量动作(一次最多 50 个) | | 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/save` | 全部存档 |
| POST | `/api/admin/shutdown` | 优雅停机 | | POST | `/api/admin/shutdown` | 优雅停机 |
| GET | `/docs` | 自动生成的 Swagger 文档 | | GET | `/docs` | 自动生成的 Swagger 文档 |
@@ -178,6 +182,7 @@ POST /api/actions → {"action":"<上一步的 action>","params":{.
plant(plot,crop) water(plot) harvest(plot) harvest_all() plant(plot,crop) water(plot) harvest(plot) harvest_all()
buy_animal(kind) feed(animal_id,quantity?) collect(animal_id) buy_animal(kind) feed(animal_id,quantity?) collect(animal_id)
buy_feed(quantity?) buy_plot() pause(paused?) buy_feed(quantity?) buy_plot() pause(paused?)
sell(key?,quantity?) build(key) ← 谷仓变现与建造,0 行动点
``` ```
> `save` / `reset` 是**存档类动作**,不进通用动作表:它们必须由牧场会话处理, > `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":"buy_feed","params":{"quantity":10}},
{"action":"plant","params":{"plot":1,"crop":"carrot"}}]}' {"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' ... curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice&full=1' ...
``` ```
+24 -2
View File
@@ -104,6 +104,26 @@ class RuleBot:
if cost and gold >= cost * 3 and ap >= 1: if cost and gold >= cost * 3 and ap >= 1:
return 30.0 + (info.get("profit_per_day", 0) if info else 0) return 30.0 + (info.get("profit_per_day", 0) if info else 0)
return 3.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 return 1.0
def choose(self) -> dict[str, Any] | None: def choose(self) -> dict[str, Any] | None:
@@ -195,8 +215,10 @@ class RuleBot:
state = self.client.snapshot() state = self.client.snapshot()
if state.get("ap", 0) <= 0: if state.get("ap", 0) <= 0:
# 行动点用完:只能等这一天自己走完,没有手动快进 # 行动点用完 ≠ 没事可做:sell / build / harvest / collect / buy_*
if not self.wait_for_next_day(wait_seconds): # 都是 0 消耗,而 available_actions 本身就按行动点筛过一遍了,
# 所以照常挑一次最优的;真的没得选才等下一天。
if self.step() is None and not self.wait_for_next_day(wait_seconds):
break break
idle = 0 idle = 0
continue continue
+55
View File
@@ -230,6 +230,38 @@ class StateView:
def market(self) -> dict[str, float]: def market(self) -> dict[str, float]:
return self._d.get("market", {}) 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: def get(self, key: str, default: Any = None) -> Any:
return self._d.get(key, default) return self._d.get(key, default)
@@ -277,6 +309,10 @@ class RemoteFarm:
self._catalog: dict[str, Any] = { self._catalog: dict[str, Any] = {
"crops": [], "crops": [],
"animals": [], "animals": [],
"goods": [],
"buildings": [],
"weather": {},
"granary": {},
"feed_price": config.FEED_UNIT_PRICE, "feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY, "water_capacity": config.WATER_CAPACITY,
"ap_cost": config.AP_COST, "ap_cost": config.AP_COST,
@@ -389,6 +425,13 @@ class RemoteFarm:
self._apply(state) self._apply(state)
def snapshot(self) -> dict[str, Any]: def snapshot(self) -> dict[str, Any]:
"""本地缓存的状态视图,**不会发请求**。
TUI 每帧都要读它,所以刻意做成纯本地读取;代价是它只反映"上一次同步"
的结果。开实时通道(``realtime=True``,TUI / 网页走这条)时 SSE 会持续
刷新它;关掉实时通道的脚本要自己调 :meth:`refresh`,否则轮询到的
永远是同一份旧数据——一天天推进的服务器看起来就像卡住了。
"""
return self.state.to_dict() return self.state.to_dict()
def catalog(self) -> dict[str, Any]: def catalog(self) -> dict[str, Any]:
@@ -480,6 +523,18 @@ class RemoteFarm:
def buy_plot(self) -> dict[str, Any]: def buy_plot(self) -> dict[str, Any]:
return self._action("buy_plot") 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]: def pause(self, paused: bool | None = None) -> dict[str, Any]:
# 动作回执里的 state(brief)已带 paused 与 seconds_to_tick, # 动作回执里的 state(brief)已带 paused 与 seconds_to_tick,
# _action 内部会合并进本地视图,这里只需转发参数 # _action 内部会合并进本地视图,这里只需转发参数
+165
View File
@@ -46,6 +46,8 @@ AP_COST = {
"buy_animal": 0, "buy_animal": 0,
"buy_plot": 0, "buy_plot": 0,
"buy_feed": 0, "buy_feed": 0,
"build": 0,
"sell": 0,
} }
@@ -193,6 +195,169 @@ MIN_TICK_SECONDS = 3.0 # 调试用:把一天缩到几秒
MAX_TICK_SECONDS = 86400.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
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 进度目标 # 进度目标
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
+494 -25
View File
@@ -57,6 +57,11 @@ class FarmEngine:
# 状态变化回调(TUI 用它触发重绘) # 状态变化回调(TUI 用它触发重绘)
self._listeners: list[Callable[[], None]] = [] 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: def subscribe(self, callback: Callable[[], None]) -> None:
with self._lock: with self._lock:
@@ -137,6 +142,16 @@ class FarmEngine:
"seconds_to_tick": self.seconds_to_tick, "seconds_to_tick": self.seconds_to_tick,
"paused": self.paused, "paused": self.paused,
"tick_seconds": self.tick_seconds, "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 准备的:直接读模型对象,不再过一遍序列化 # 待办清单是特意为 AI 准备的:直接读模型对象,不再过一遍序列化
"ripe_plots": [p.index + 1 for p in state.plots if p.is_ripe], "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], "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), "profit_per_day": round((price - feed_cost) / max(1, info["cycle"]), 2),
} }
) )
goods = self._goods_catalog()
return { return {
"crops": crops, "crops": crops,
"animals": animals, "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, "feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY, "water_capacity": config.WATER_CAPACITY,
"ap_cost": config.AP_COST, "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]]: def available_actions(self) -> list[dict[str, Any]]:
"""列出当前**合法**的动作,供 AI 直接决策使用。 """列出当前**合法**的动作,供 AI 直接决策使用。
@@ -207,7 +306,9 @@ class FarmEngine:
actions: list[dict[str, Any]] = [] actions: list[dict[str, Any]] = []
actions.extend(self._plot_actions(state)) actions.extend(self._plot_actions(state))
actions.extend(self._animal_actions(state)) actions.extend(self._animal_actions(state))
actions.extend(self._market_actions(state))
actions.extend(self._investment_actions(state)) actions.extend(self._investment_actions(state))
actions.extend(self._build_actions(state))
return actions return actions
# ---- available_actions 的三个分区;拆开是为了各自能独立阅读/测试 # ---- available_actions 的三个分区;拆开是为了各自能独立阅读/测试
@@ -245,13 +346,18 @@ class FarmEngine:
"reason": f"{position} 号地的{config.CROPS[plot.crop]['name']}已成熟", "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( actions.append(
{ {
"action": "water", "action": "water",
"params": {"plot": position}, "params": {"plot": position},
"cost": {"gold": 0, "ap": config.AP_COST["water"]}, "cost": {"gold": 0, "ap": config.AP_COST["water"]},
"reason": f"{position} 号地缺水,作物已停止生长", "reason": reason,
} }
) )
return actions return actions
@@ -321,6 +427,59 @@ class FarmEngine:
) )
return actions 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: def _is_locked(self, info: dict[str, Any]) -> bool:
"""这件作物/牲畜在当前等级是否还没解锁。 """这件作物/牲畜在当前等级是否还没解锁。
@@ -343,7 +502,7 @@ class FarmEngine:
"""正好在 level 这一级解锁的作物与牲畜名字(先作物后牲畜)。""" """正好在 level 这一级解锁的作物与牲畜名字(先作物后牲畜)。"""
return [ return [
info["name"] info["name"]
for table in (config.CROPS, config.ANIMALS) for table in (config.CROPS, config.ANIMALS, config.BUILDINGS)
for info in table.values() for info in table.values()
if info["level_req"] == level if info["level_req"] == level
] ]
@@ -424,6 +583,45 @@ class FarmEngine:
"""丰收回合判定:常规产出 1,暴击产出 2。""" """丰收回合判定:常规产出 1,暴击产出 2。"""
return 2 if random.random() < config.LUCKY_CHANCE else 1 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]: def plant(self, plot_no: Any, crop: Any) -> dict[str, Any]:
with self._lock: with self._lock:
@@ -489,8 +687,11 @@ class FarmEngine:
self._spend_ap("harvest") self._spend_ap("harvest")
quantity = self._roll_yield() quantity = self._roll_yield()
# 先入库再清地:谷仓满了会在这里抛 granary_full,作物原地不动,
# 玩家卖掉一点就能重来,不会"收了个寂寞"。
self._store(crop_key, quantity)
price = self.market_price(crop_key) price = self.market_price(crop_key)
earned = self._earn(price * quantity, info["name"]) value = round(price * quantity, 2)
plot.crop = None plot.crop = None
plot.growth = 0.0 plot.growth = 0.0
@@ -498,16 +699,21 @@ class FarmEngine:
self.state.total_harvested += quantity self.state.total_harvested += quantity
lucky = "(丰收!)" if quantity > 1 else "" 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"]) levelups = self._gain_exp(info["exp"])
self._notify() self._notify()
return { return {
"ok": True, "ok": True,
"message": f"收获 {quantity} 份{info['name']},获得 {earned} 金币", "message": (
f"收获 {quantity} 份{info['name']}{lucky}入谷仓,现价 {price}"
f"(约 {value} 金币,需 sell 才能换成钱)"
),
"quantity": quantity, "quantity": quantity,
"earned": earned, "price": price,
"value": value,
"exp": info["exp"], "exp": info["exp"],
"level_ups": levelups, "level_ups": levelups,
"storage_used": self.state.storage_used,
} }
def buy_animal(self, kind: Any) -> dict[str, Any]: def buy_animal(self, kind: Any) -> dict[str, Any]:
@@ -584,24 +790,31 @@ class FarmEngine:
) )
self._spend_ap("collect") self._spend_ap("collect")
quantity = animal.pending quantity = animal.pending
key = f"animal:{animal.kind}"
# 与收获同理:先确认装得下,再清空待收,免得产出凭空消失
self._store(key, quantity)
animal.pending = 0 animal.pending = 0
price = self.market_price(f"animal:{animal.kind}") price = self.market_price(key)
earned = self._earn(price * quantity, animal.product) value = round(price * quantity, 2)
self.state.total_collected += quantity self.state.total_collected += quantity
self.add_log( self.add_log(
f"收取 {quantity} 份{animal.product}({animal.name} #{animal.aid})," f"收取 {quantity} 份{animal.product}({animal.name} #{animal.aid})入库,"
f"+{earned} 金币", f"现价 {price}",
"good", "good",
) )
levelups = self._gain_exp(animal.info["exp"] * quantity) levelups = self._gain_exp(animal.info["exp"] * quantity)
self._notify() self._notify()
return { return {
"ok": True, "ok": True,
"message": f"收取 {quantity} 份{animal.product},获得 {earned} 金币", "message": (
f"收取 {quantity} 份{animal.product}入谷仓,现价 {price}(约 {value} 金币)"
),
"quantity": quantity, "quantity": quantity,
"earned": earned, "price": price,
"value": value,
"level_ups": levelups, "level_ups": levelups,
"storage_used": self.state.storage_used,
} }
def buy_feed(self, quantity: Any = config.FEED_BULK) -> dict[str, Any]: 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, "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]: def pause(self, paused: bool | None = None) -> dict[str, Any]:
with self._lock: with self._lock:
self.paused = bool(paused) if paused is not None else not self.paused self.paused = bool(paused) if paused is not None else not self.paused
@@ -665,6 +1004,8 @@ class FarmEngine:
self.paused = False self.paused = False
if keep_tick: if keep_tick:
self.tick_seconds = 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.add_log("新的一天,新的开始。欢迎来到 SimpleFarm!", "info")
self._notify() self._notify()
return {"ok": True, "message": "农场已重置"} return {"ok": True, "message": "农场已重置"}
@@ -681,28 +1022,155 @@ class FarmEngine:
self.add_log(f"已存档到 {target}", "info") self.add_log(f"已存档到 {target}", "info")
return {"ok": True, "message": f"已存档到 {target}", "path": target} 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: with self._lock:
state = self.state state = self.state
info = self._resolve_weather(state, weather)
state.day += 1 state.day += 1
state.ap = state.max_ap state.ap = state.max_ap
events: list[str] = [] 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: for plot in state.plots:
if plot.crop is None: if plot.crop is None:
continue continue
if plot.moisture > 0: if gain:
plot.moisture -= 1 # 雨天/暴风雨先补水再蒸发,等于免费替你浇了一遍
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) plot.growth = min(plot.grow_days, plot.growth + 1)
if plot.is_ripe: if plot.is_ripe:
events.append( events.append(f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟")
f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟"
) # ---- 天灾:暴风雨只毁**还没成熟**的作物
else: events.extend(self._storm_damage(state, info))
events.append(f"{plot.index + 1} 号地缺水,作物停止生长")
# ---- 牲畜 # ---- 牲畜
dead: list[Animal] = [] dead: list[Animal] = []
@@ -732,10 +1200,11 @@ class FarmEngine:
# ---- 写入日志(最多 4 条,避免刷屏) # ---- 写入日志(最多 4 条,避免刷屏)
for text in events[:4]: for text in events[:4]:
self.add_log(text, "warn") self.add_log(text, "warn")
today = f"第 {state.day} 天开始({reason}),今日{info['name']}"
if events: if events:
self.add_log(f"第 {state.day} 天开始({reason}),行动点已恢复", "info") self.add_log(f"{today},行动点已恢复", "info")
else: else:
self.add_log(f"第 {state.day} 天开始({reason}),一切安好", "info") self.add_log(f"{today},一切安好", "info")
self._notify() self._notify()
return events return events
+80 -2
View File
@@ -219,6 +219,17 @@ class GameState:
self.total_spent: int = 0 self.total_spent: int = 0
self.plots_bought: 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 @property
def max_ap(self) -> int: def max_ap(self) -> int:
@@ -254,7 +265,7 @@ class GameState:
@property @property
def net_worth(self) -> float: def net_worth(self) -> float:
"""净资产:金币 + 已购地价 + 牲畜折价 + 库存 + 地里未收的作物。""" """净资产:金币 + 已购地价 + 牲畜折价 + 饲料 + 地里未收的作物 + 谷仓存货。"""
land = sum( land = sum(
config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH**i) for i in range(self.plots_bought) 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: for plot in self.plots:
if plot.crop: if plot.crop:
crops += self.market.get(plot.crop, 0) * (plot.growth / max(1, plot.grow_days)) 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 @property
def goal_index(self) -> int: def goal_index(self) -> int:
@@ -272,6 +284,32 @@ class GameState:
def next_goal(self) -> dict[str, Any] | None: def next_goal(self) -> dict[str, Any] | None:
return next_goal_after(self.goal_index) 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]: def to_dict(self) -> dict[str, Any]:
# net_worth 要遍历地块与牲畜,是这里最贵的一项;下面三处都要用它, # net_worth 要遍历地块与牲畜,是这里最贵的一项;下面三处都要用它,
@@ -280,6 +318,11 @@ class GameState:
goal = config.goal_index(worth) goal = config.goal_index(worth)
return { return {
"day": self.day, "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, "gold": self.gold,
"level": self.level, "level": self.level,
"exp": self.exp, "exp": self.exp,
@@ -287,6 +330,11 @@ class GameState:
"ap": self.ap, "ap": self.ap,
"max_ap": self.max_ap, "max_ap": self.max_ap,
"feed": self.feed, "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], "plots": [p.to_dict() for p in self.plots],
"plot_count": len(self.plots), "plot_count": len(self.plots),
"max_plots": self.max_plots, "max_plots": self.max_plots,
@@ -333,6 +381,22 @@ class GameState:
animals.append(animal) animals.append(animal)
state.animals = animals 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", {}) market = data.get("market", {})
for key in list(state.market.keys()): for key in list(state.market.keys()):
try: try:
@@ -341,6 +405,20 @@ class GameState:
continue continue
state.market[key] = clamp(value, 1.0, 100000.0) 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", {}) stats = data.get("stats", {})
state.total_harvested = max(0, int(stats.get("harvested", 0))) state.total_harvested = max(0, int(stats.get("harvested", 0)))
state.total_collected = max(0, int(stats.get("collected", 0))) state.total_collected = max(0, int(stats.get("collected", 0)))
+2 -1
View File
@@ -26,6 +26,7 @@ from .daemon import (
DEFAULT_PID_FILE, DEFAULT_PID_FILE,
PROJECT_ROOT, PROJECT_ROOT,
DaemonManager, DaemonManager,
ensure_parent_dir,
port_busy, port_busy,
) )
@@ -264,7 +265,7 @@ def _run_foreground(args, save_dir: str) -> int:
if not args.pid_file: if not args.pid_file:
return return
try: 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: with open(args.pid_file, "w", encoding="utf-8") as file_handle:
file_handle.write(str(os.getpid())) file_handle.write(str(os.getpid()))
except OSError as exc: except OSError as exc:
+31 -5
View File
@@ -99,8 +99,8 @@ class Action:
# ---------------------------------------------------------------------- 处理器 # ---------------------------------------------------------------------- 处理器
def _harvest_all(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]: def _harvest_all(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
"""收获所有成熟作物。逐块调用引擎,个别地块失败不影响其它地块。""" """收获所有成熟作物,全部进谷仓。逐块调用引擎,个别地块失败不影响其它地块。"""
total_gold = 0 total_value = 0.0
total_units = 0 total_units = 0
plots: list[int] = [] plots: list[int] = []
errors: list[str] = [] errors: list[str] = []
@@ -113,17 +113,20 @@ def _harvest_all(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
except GameError as exc: except GameError as exc:
errors.append(f"{plot.index + 1}号地:{exc.message}") errors.append(f"{plot.index + 1}号地:{exc.message}")
continue continue
total_gold += int(result.get("earned", 0)) total_value += float(result.get("value", 0))
total_units += int(result.get("quantity", 0)) total_units += int(result.get("quantity", 0))
plots.append(plot.index + 1) plots.append(plot.index + 1)
if not plots and not errors: if not plots and not errors:
raise GameError("nothing_to_harvest", "当前没有成熟的作物可以收获") raise GameError("nothing_to_harvest", "当前没有成熟的作物可以收获")
return { return {
"message": f"收获 {len(plots)} 块地共 {total_units} 份作物,+{total_gold} 金币", "message": (
f"收获 {len(plots)} 块地共 {total_units} 份作物入谷仓,"
f"按现价约值 {round(total_value)} 金币(用 sell 才换成钱)"
),
"plots": plots, "plots": plots,
"quantity": total_units, "quantity": total_units,
"earned": total_gold, "value": round(total_value, 2),
"errors": errors, "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 **刻意不进**这张表。它们是"存档类动作",多牧场环境下 # 注意:save / reset **刻意不进**这张表。它们是"存档类动作",多牧场环境下
# 必须由牧场会话(FarmSession)处理——engine.save() 只会写默认路径, # 必须由牧场会话(FarmSession)处理——engine.save() 只会写默认路径,
+27 -7
View File
@@ -104,6 +104,10 @@ class ServerHandle:
# 数据源是 FarmEngine.brief_source()(**不是**完整快照),加字段时两处一起改。 # 数据源是 FarmEngine.brief_source()(**不是**完整快照),加字段时两处一起改。
BRIEF_KEYS = ( BRIEF_KEYS = (
"day", "day",
"weather",
"weather_name",
"forecast",
"forecast_name",
"gold", "gold",
"level", "level",
"exp", "exp",
@@ -116,6 +120,10 @@ BRIEF_KEYS = (
"next_plot_price", "next_plot_price",
"animal_count", "animal_count",
"max_animals", "max_animals",
"storage_used",
"granary_capacity",
"granary_free",
"building_count",
"net_worth", "net_worth",
"goal_index", "goal_index",
"seconds_to_tick", "seconds_to_tick",
@@ -150,16 +158,26 @@ def rules_text() -> str:
f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n" f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n"
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天——" f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天——"
"时间只能自然流动,没有任何「手动推进一天」的接口,别白费请求。\n" "时间只能自然流动,没有任何「手动推进一天」的接口,别白费请求。\n"
f"3. 水分:作物每天消耗 1 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长。" f"3. 天气与水分:每天随机一种天气(晴/多云/雨/干旱/暴风雨)。作物每天按天气蒸发"
f"播种时自带 {config.PLANT_INITIAL_MOISTURE} 点水分。\n" f" 0~2 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长;"
f"4. 收获:成熟后 harvest 获得金币与经验,有 {config.LUCKY_CHANCE:.0%} 概率丰收翻倍。\n" f"播种自带 {config.PLANT_INITIAL_MOISTURE} 点。雨天与暴风雨会自动补满水分,"
"暴风雨还可能毁掉未成熟的作物(温室可免疫)。state.forecast 是明天的预报——"
"提前一天浇水就能避免断水停长。\n"
"4. 收获与谷仓:harvest / collect 的产出**先进谷仓,不直接换钱**,再用 sell "
"按当时的市价卖出。仓容 = 基础 "
f"{config.GRANARY_BASE} + 等级成长 + 谷仓建筑,装不下时收获会被拒绝"
"(granary_full),先卖货或者建谷仓。\n"
"5. 牲畜:每天必须喂食才会累计产出周期;连续 3 天未进食会永久离开农场。\n" "5. 牲畜:每天必须喂食才会累计产出周期;连续 3 天未进食会永久离开农场。\n"
f"6. 市场:作物与畜产品价格每天在基础价 ±{config.PRICE_VOLATILITY:.0%} 内波动," f"6. 市场:作物与畜产品价格每天在基础价 ±{config.PRICE_VOLATILITY:.0%} 内波动。"
"高价卖出收益更高。\n" '有了谷仓之后,"什么时候卖"才真正影响收益:囤到高位再 sell。\n'
f"7. 土地:初始 {config.BASE_PLOTS} 块,每升 1 级多 {config.PLOTS_PER_LEVEL} 块," f"7. 土地:初始 {config.BASE_PLOTS} 块,每升 1 级多 {config.PLOTS_PER_LEVEL} 块,"
f"上限 {config.MAX_PLOTS} 块;购地价格每次递增。\n" f"上限 {config.MAX_PLOTS} 块;购地价格每次递增。\n"
"8. 目标:把净资产(金币 + 土地 + 牲畜 + 库存 + 地里作物)做到 10000 金币。\n" "8. 建筑:build 建造 granary / well / feeder / greenhouse。谷仓加仓容、"
"9. 多人:每个 farm 是一个独立牧场,用 ?farm=名字 区分,互不影响;" "水井每天自动浇 1 块干透的地、喂食器每天自动喂 1 只牲畜(只吃库存饲料,"
"不会替你花钱)、温室免疫暴风雨且水分蒸发减半。同类建筑越造越贵。\n"
f"9. 目标:把净资产(金币 + 土地 + 牲畜 + 饲料 + 地里作物 + **谷仓存货**)"
f"做到 {config.GOALS[-1][0]} 金币。\n"
"10. 多人:每个 farm 是一个独立牧场,用 ?farm=名字 区分,互不影响;"
"排行榜见 GET /api/world。\n" "排行榜见 GET /api/world。\n"
"\n推荐决策循环(AI 最短路径):\n" "\n推荐决策循环(AI 最短路径):\n"
" GET /api/actions?farm=me → 拿到当前所有**合法**动作(含开销与理由)\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(), "actions": actions_mod.describe_actions(),
"crops": config.CROPS, "crops": config.CROPS,
"animals": config.ANIMALS, "animals": config.ANIMALS,
"buildings": config.BUILDINGS,
"weather": config.WEATHER,
"endpoints": { "endpoints": {
"state": "GET /api/state?farm=NAME", "state": "GET /api/state?farm=NAME",
"catalog": "GET /api/catalog?farm=NAME", "catalog": "GET /api/catalog?farm=NAME",
+16 -3
View File
@@ -88,6 +88,19 @@ def port_busy(host: str, port: int, timeout: float = 0.6) -> bool:
return False 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: class DaemonManager:
"""负责在后台拉起/停止 ``farm.server`` 进程。""" """负责在后台拉起/停止 ``farm.server`` 进程。"""
@@ -121,7 +134,7 @@ class DaemonManager:
return None return None
def write_pid(self, pid: int) -> 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: with open(self.pid_file, "w", encoding="utf-8") as handle:
handle.write(str(pid)) handle.write(str(pid))
@@ -203,8 +216,8 @@ class DaemonManager:
if self.port_busy(): if self.port_busy():
return False, f"端口 {self.port} 被其它程序占用,请改用 --port 8899" return False, f"端口 {self.port} 被其它程序占用,请改用 --port 8899"
os.makedirs(os.path.dirname(self.log_file), exist_ok=True) ensure_parent_dir(self.log_file)
os.makedirs(os.path.dirname(self.pid_file), exist_ok=True) ensure_parent_dir(self.pid_file)
self.clear_pid() # 清掉上一次留下的陈旧 pid self.clear_pid() # 清掉上一次留下的陈旧 pid
env = dict(os.environ) env = dict(os.environ)
env["PYTHONUNBUFFERED"] = "1" env["PYTHONUNBUFFERED"] = "1"
+65 -11
View File
@@ -142,6 +142,17 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
<h2>市场行情 <em>红涨绿跌</em></h2> <h2>市场行情 <em>红涨绿跌</em></h2>
<table id="market"></table> <table id="market"></table>
</div> </div>
<div class="card">
<h2>谷仓 <em id="granaryhint"></em></h2>
<table id="granary"></table>
<div class="row" style="margin-top:8px">
<button class="primary" data-act="sell">全部卖出</button>
</div>
</div>
<div class="card">
<h2>建筑 <em>花金币换自动化</em></h2>
<div class="row" id="buildings"></div>
</div>
<div class="card"> <div class="card">
<h2>服务器排行榜 <em id="worldinfo"></em></h2> <h2>服务器排行榜 <em id="worldinfo"></em></h2>
<table id="board"></table> <table id="board"></table>
@@ -165,6 +176,13 @@ const cropNames = {
corn: "玉米", pumpkin: "南瓜", strawberry: "草莓" 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) { function toast(text) {
const el = $("toast"); const el = $("toast");
el.textContent = text; el.textContent = text;
@@ -277,8 +295,12 @@ function render() {
if (!state) return; if (!state) return;
const s = state; const s = state;
$("stats").innerHTML = [ $("stats").innerHTML = [
["第几天", s.day], ["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"], ["第几天", s.day],
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed], ["净资产", Math.round(s.net_worth)], ["天气", (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 : "—"], ["在线", s.online !== undefined ? s.online : "—"],
["下一天", fmtLeft(s.seconds_to_tick)] ["下一天", fmtLeft(s.seconds_to_tick)]
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join(""); ].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join("");
@@ -314,18 +336,48 @@ function render() {
(a.pending > 0 ? '<button data-c="' + a.id + '">收取</button>' : '') + '</li>' (a.pending > 0 ? '<button data-c="' + a.id + '">收取</button>' : '') + '</li>'
).join("") : '<li style="color:#6b7a66">牧场空空的,先买一只吧</li>'; ).join("") : '<li style="color:#6b7a66">牧场空空的,先买一只吧</li>';
/* 市场 */ /* 市场:基准价直接用 catalog.goods,别再在 JS 里抄一份常量表 */
const rows = Object.entries(s.market).filter((kv) => kv[0].indexOf("animal:") !== 0); const gmap = {};
const base = { wheat: 14, carrot: 26, tomato: 52, corn: 95, pumpkin: 180, strawberry: 320 }; ((catalog && catalog.goods) || []).forEach((g) => { gmap[g.key] = g; });
$("market").innerHTML = '<tr><th>作物</th><th class="num">现价</th><th class="num">基准</th></tr>' + $("market").innerHTML = '<tr><th>货物</th><th class="num">现价</th><th class="num">基准</th></tr>' +
rows.sort((a, b) => b[1] - a[1]).map((kv) => { Object.entries(s.market).sort((a, b) => b[1] - a[1]).map((kv) => {
const b = base[kv[0]] || kv[1]; 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 cls = kv[1] > b * 1.03 ? "up" : (kv[1] < b * 0.97 ? "down" : "flat");
const arrow = cls === "up" ? "↑" : (cls === "down" ? "↓" : "→"); const arrow = cls === "up" ? "↑" : (cls === "down" ? "↓" : "→");
return '<tr><td>' + (cropNames[kv[0]] || kv[0]) + '</td><td class="num ' + cls + '">' + return '<tr><td>' + goodsName(kv[0]) + '</td><td class="num ' + cls + '">' +
kv[1].toFixed(0) + " " + arrow + '</td><td class="num">' + b + '</td></tr>'; kv[1].toFixed(0) + " " + arrow + '</td><td class="num">' + b.toFixed(0) + '</td></tr>';
}).join(""); }).join("");
/* 谷仓:卖货是唯一把存货变成金币的出口,所以每个存项都直接给一个卖出按钮 */
const inv = Object.entries(s.inventory || {}).filter((kv) => kv[1] > 0);
$("granaryhint").textContent =
"仓容 " + (s.storage_used || 0) + "/" + (s.granary_capacity || 0);
$("granary").innerHTML =
'<tr><th>货物</th><th class="num">存量</th><th class="num">现价</th>' +
'<th class="num">基准</th><th></th></tr>' +
(inv.length ? inv.sort((a, b) => b[1] - a[1]).map((kv) => {
const g = gmap[kv[0]] || {};
const price = g.price !== undefined ? g.price : ((s.market || {})[kv[0]] || 0);
const b = g.base_price || 0;
const cls = b && price > b * 1.03 ? "up" : (b && price < b * 0.97 ? "down" : "flat");
return '<tr><td>' + goodsName(kv[0]) + '</td><td class="num">' + kv[1] +
'</td><td class="num ' + cls + '">' + price.toFixed(0) + '</td>' +
'<td class="num">' + (b ? b.toFixed(0) : "—") + '</td>' +
'<td class="num"><button data-s="' + kv[0] + '">卖</button></td></tr>';
}).join("")
: '<tr><td colspan="5" style="color:#6b7a66">空仓:收获与收取的产出会自动入库</td></tr>');
/* 建筑 */
const builds = (catalog && catalog.buildings) || [];
$("buildings").innerHTML = builds.length ? builds.map((b) => {
const disabled = b.locked || b.maxed || s.gold < b.next_cost;
const label = b.name + " " + (b.maxed ? "已满" : b.next_cost + " 金") +
"(" + b.owned + "/" + b.max_level + ")";
return '<button data-b="' + b.key + '"' + (disabled ? " disabled" : "") +
' title="' + b.effect + '">' + label + '</button>';
}).join("") : '<span style="color:#6b7a66">目录加载中…</span>';
renderLog(null); renderLog(null);
renderBoard(); 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.f) act("feed", { animal_id: +t.dataset.f });
else if (t.dataset.c) act("collect", { animal_id: +t.dataset.c }); 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_animal") act("buy_animal", { kind: $("buyanimal").value });
else if (t.dataset.act === "buy_feed") act("buy_feed", { quantity: 10 }); else if (t.dataset.act === "buy_feed") act("buy_feed", { quantity: 10 });
else if (t.dataset.act === "reset") { if (confirm("确定重置牧场?所有进度都会清空")) act("reset"); } 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/actions {"action":"...","params":{...}} 通用入口\n'
'POST /api/batch {"actions":[{...},{...}]} 批量\n' 'POST /api/batch {"actions":[{...},{...}]} 批量\n'
"POST /api/plant|water|harvest|harvest_all|buy_animal|feed|\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/farms {"farm":"名字"} 创建/进入牧场\n'
"POST /api/admin/save 全部存档\n" "POST /api/admin/save 全部存档\n"
"POST /api/admin/shutdown 优雅停机\n" "POST /api/admin/shutdown 优雅停机\n"
+12 -3
View File
@@ -13,7 +13,10 @@ from typing import Any
from .models import GameState from .models import GameState
SAVE_VERSION = 1 SAVE_VERSION = 2 # v2 起:谷仓存货 / 建筑 / 天气
# 还能读回来的最老版本。存档读取是「宽容向后」的:老档缺的新字段
# 一律走默认值,所以没必要因为升了一版就把玩家手里的档作废。
MIN_SAVE_VERSION = 1
def default_save_path() -> str: def default_save_path() -> str:
@@ -49,11 +52,15 @@ def _raw_state(state: GameState) -> dict[str, Any]:
"""导出可完整还原的原始字段(与 to_dict 的展示字段不同)。""" """导出可完整还原的原始字段(与 to_dict 的展示字段不同)。"""
return { return {
"day": state.day, "day": state.day,
"weather": state.weather,
"forecast": state.forecast,
"gold": state.gold, "gold": state.gold,
"level": state.level, "level": state.level,
"exp": state.exp, "exp": state.exp,
"ap": state.ap, "ap": state.ap,
"feed": state.feed, "feed": state.feed,
"inventory": dict(state.inventory),
"buildings": dict(state.buildings),
"plots": [ "plots": [
{ {
"index": p.index, "index": p.index,
@@ -105,8 +112,10 @@ def load_state(path: str | None = None) -> tuple[GameState | None, str]:
return None, "存档格式错误:根节点不是对象" return None, "存档格式错误:根节点不是对象"
version = payload.get("version", 0) version = payload.get("version", 0)
if version != SAVE_VERSION: if not isinstance(version, int) or not (MIN_SAVE_VERSION <= version <= SAVE_VERSION):
return None, f"存档版本不兼容(文件 v{version},当前 v{SAVE_VERSION})" return None, (
f"存档版本不兼容(文件 v{version},当前支持 v{MIN_SAVE_VERSION}~v{SAVE_VERSION})"
)
raw = payload.get("state") raw = payload.get("state")
if not isinstance(raw, dict): if not isinstance(raw, dict):
+177 -14
View File
@@ -48,6 +48,9 @@ CELL_INNER = CELL_WIDTH - 2
SIDE_WIDTH = 36 # 右侧栏宽度 SIDE_WIDTH = 36 # 右侧栏宽度
MIN_TERM_WIDTH = 76 MIN_TERM_WIDTH = 76
MAX_VISIBLE_ANIMALS = 5 # 侧栏同时最多展示的牲畜数量 MAX_VISIBLE_ANIMALS = 5 # 侧栏同时最多展示的牲畜数量
MAX_VISIBLE_GOODS = 4 # 侧栏谷仓同时展示的货物种类
# 有菜单界面的模式;这几处判断原本各写了一遍模式列表
MENU_MODES = ("select_crop", "select_animal", "select_goods", "select_build")
def strip_ansi(text: str) -> str: def strip_ansi(text: str) -> str:
@@ -276,7 +279,7 @@ class TuiApp:
self._flash("已取消重置", C_GRAY) self._flash("已取消重置", C_GRAY)
self.mode = "main" self.mode = "main"
return return
if self.mode in ("select_crop", "select_animal"): if self.mode in MENU_MODES:
self._handle_menu_key(key) self._handle_menu_key(key)
return return
@@ -299,6 +302,8 @@ class TuiApp:
"c": self._collect, "c": self._collect,
"l": lambda: self._act(self.engine.buy_plot), "l": lambda: self._act(self.engine.buy_plot),
"k": lambda: self._act(self.engine.buy_feed, config.FEED_BULK), "k": lambda: self._act(self.engine.buy_feed, config.FEED_BULK),
"m": self._open_goods_menu,
"g": self._open_build_menu,
"s": self._save, "s": self._save,
"r": self._ask_reset, "r": self._ask_reset,
keys.SPACE: self._toggle_pause, keys.SPACE: self._toggle_pause,
@@ -338,16 +343,19 @@ class TuiApp:
if not targets: if not targets:
self._flash("暂时没有成熟的作物", C_GRAY) self._flash("暂时没有成熟的作物", C_GRAY)
return return
total = 0 total = 0.0
count = 0 count = 0
for number in targets: for number in targets:
try: try:
result = engine.harvest(number) result = engine.harvest(number)
total += result["earned"] total += float(result.get("value", 0))
count += result["quantity"] count += int(result.get("quantity", 0))
except GameError: except GameError:
continue 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: def _open_crop_menu(self) -> None:
@@ -408,6 +416,85 @@ class TuiApp:
self.menu_title = "选择要购买的动物" self.menu_title = "选择要购买的动物"
self.mode = "select_animal" 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: def _handle_menu_key(self, key: str) -> None:
if key in (keys.ESC, "q"): if key in (keys.ESC, "q"):
self.mode = "main" self.mode = "main"
@@ -427,8 +514,12 @@ class TuiApp:
if self.mode == "select_crop": if self.mode == "select_crop":
self._act(self.engine.plant, self.plot_cursor + 1, item["key"]) 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"]) 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" self.mode = "main"
def _feed(self) -> None: def _feed(self) -> None:
@@ -510,6 +601,13 @@ class TuiApp:
tag = color(f"👥{online} ", C_ORANGE, self.use_color) tag = color(f"👥{online} ", C_ORANGE, self.use_color)
else: else:
tag = "" 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"]: if snap["paused"]:
right = tag + color("⏸ 已暂停 ", C_ORANGE, self.use_color) right = tag + color("⏸ 已暂停 ", C_ORANGE, self.use_color)
@@ -720,17 +818,52 @@ class TuiApp:
market_rows.append( market_rows.append(
color(f" 饲料 {config.FEED_UNIT_PRICE} 金币/份(按 K 购买)", C_DIM, self.use_color) 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 "市场行情", 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]: def _render_bottom(self, snap: dict[str, Any], width: int) -> list[str]:
if self.mode == "help": if self.mode == "help":
return self._render_help(width) return self._render_help(width)
if self.mode in ("select_crop", "select_animal"): if self.mode in MENU_MODES:
return self._render_menu(width) return self._render_menu(width)
rows: list[str] = [] rows: list[str] = []
@@ -757,11 +890,15 @@ class TuiApp:
hint = [ hint = [
color(" [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获", C_GRAY, self.use_color), color(" [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获", C_GRAY, self.use_color),
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, C_GRAY,
self.use_color, self.use_color,
), ),
color(" [空格]暂停 [S]存档 [R]重置 [Q]退出", C_GRAY, self.use_color),
] ]
if self.show_keyboard_notice and not self.reader.available: if self.show_keyboard_notice and not self.reader.available:
# 读不到键盘时把原因摆在最显眼处,别让玩家对着界面干等 # 读不到键盘时把原因摆在最显眼处,别让玩家对着界面干等
@@ -804,9 +941,17 @@ class TuiApp:
C_GRAY, C_GRAY,
self.use_color, 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(" 水分", C_BOLD + C_GOLD, self.use_color)
+ color( + color(
" 作物每天消耗 1 点水分,水为 0 就停止生长。按 W 浇水恢复到 3 点。", f" 水为 0 就停止生长,按 W 浇满({config.WATER_CAPACITY} 点)。"
"顶部「今→明」的第二个字就是明天的天气预报。",
C_GRAY, C_GRAY,
self.use_color, self.use_color,
), ),
@@ -818,8 +963,24 @@ class TuiApp:
+ color( + color(
" 必须每天喂食才会产出;连续 3 天没吃东西会离开农场。", C_GRAY, self.use_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(" 市场", 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(" 快捷键", C_BOLD + C_GOLD, self.use_color)
+ color( + color(
@@ -829,10 +990,12 @@ class TuiApp:
), ),
color(" ", C_GRAY, self.use_color) color(" ", C_GRAY, self.use_color)
+ color( + color(
" B 买动物 / L 开垦土地 / K 买饲料 / 空格 暂停 / S 存档 / R 重置", " M 卖货 / G 建造 / B 买动物 / L 开垦土地 / K 买饲料",
C_GRAY, C_GRAY,
self.use_color, 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(" AI 接入", C_BOLD + C_GOLD, self.use_color)
+ color( + color(
+31 -2
View File
@@ -515,9 +515,32 @@ def cmd_state(args) -> int:
f"金币 {snap.get('gold')} 等级 Lv.{snap.get('level')} " f"金币 {snap.get('gold')} 等级 Lv.{snap.get('level')} "
f"({snap.get('exp')}/{snap.get('exp_needed')})" 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('ap')}/{snap.get('max_ap')} 饲料 {snap.get('feed')}")
print(f"净资产 {snap.get('net_worth')}") print(f"净资产 {snap.get('net_worth')}")
print(f"土地 {snap.get('plot_count')}/{snap.get('max_plots')} 块") 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", []): for plot in snap.get("plots", []):
if plot["is_empty"]: if plot["is_empty"]:
continue continue
@@ -558,10 +581,14 @@ def cmd_watch(args) -> int:
last_seq = max(last_seq, int(entry.get("seq", 0))) last_seq = max(last_seq, int(entry.get("seq", 0)))
print(f"[D{entry['day']:>3}] {entry['text']}") print(f"[D{entry['day']:>3}] {entry['text']}")
online = snap.get("online") 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 = ( 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"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: if online is not None:
head += f" | 在线 {online}" head += f" | 在线 {online}"
@@ -635,6 +662,8 @@ QUICKSTART = """
uv run main.py actions # 我当前能做什么 uv run main.py actions # 我当前能做什么
uv run main.py do harvest '{"plot":1}' 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 state --json # 机器可读的完整状态
uv run main.py watch # 实时看别人/机器人玩 uv run main.py watch # 实时看别人/机器人玩
+3 -3
View File
@@ -30,6 +30,8 @@ EXPECTED_ACTIONS = {
"collect", "collect",
"buy_feed", "buy_feed",
"buy_plot", "buy_plot",
"sell",
"build",
"pause", "pause",
} }
@@ -292,9 +294,7 @@ class HarvestAllTestCase(unittest.TestCase):
result = run_action(self.engine, "harvest_all", {}) result = run_action(self.engine, "harvest_all", {})
self.assertEqual(result["plots"], [1, 2]) self.assertEqual(result["plots"], [1, 2])
self.assertEqual( self.assertEqual(result["quantity"], result["value"] // config.CROPS["wheat"]["base_price"])
result["quantity"], result["earned"] // config.CROPS["wheat"]["base_price"]
)
self.assertEqual(result["errors"], []) self.assertEqual(result["errors"], [])
self.assertFalse(self.engine.state.plots[2].is_empty, "没熟的 3 号地要保持原样") self.assertFalse(self.engine.state.plots[2].is_empty, "没熟的 3 号地要保持原样")
+18 -2
View File
@@ -291,15 +291,31 @@ class RunTestCase(unittest.TestCase):
self.assertEqual(set(summary) >= {"name", "days", "actions", "failures", "net_worth"}, True) self.assertEqual(set(summary) >= {"name", "days", "actions", "failures", "net_worth"}, True)
def test_no_action_points_means_clocking_out(self): def test_no_action_points_means_clocking_out(self):
# AP 用完时,服务端的 available_actions 只会剩下 0 消耗的动作
# (卖货/建造/收获/收取),这里模拟"连这些都没有"的极端情况:
# 机器人应当直接收工,而不是空转或乱发请求。
client = FakeClient( client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}], actions=[],
state={**BASE_STATE, "ap": 0}, state={**BASE_STATE, "ap": 0},
catalog=CATALOG, catalog=CATALOG,
) )
bot = make_bot(client) bot = make_bot(client)
summary = bot.run(rounds=5) summary = bot.run(rounds=5)
self.assertEqual(summary["actions"], 0) 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): def test_idle_limit_triggers_the_waiting_path(self):
client = FakeClient(actions=[], state=BASE_STATE) client = FakeClient(actions=[], state=BASE_STATE)
+19 -14
View File
@@ -370,15 +370,17 @@ class ActionTestCase(unittest.TestCase):
self.assert_code("already_watered", self.engine.water, 2) 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.plant(1, "wheat")
self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"] self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"]
gold = self.engine.state.gold gold = self.engine.state.gold
with mock.patch("farm.engine.random.random", return_value=0.99): with mock.patch("farm.engine.random.random", return_value=0.99):
result = self.engine.harvest(1) result = self.engine.harvest(1)
self.assertEqual(result["quantity"], 1) self.assertEqual(result["quantity"], 1)
self.assertEqual(result["earned"], config.CROPS["wheat"]["base_price"]) self.assertEqual(result["price"], self.engine.market_price("wheat"))
self.assertEqual(self.engine.state.gold, gold + result["earned"]) 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) self.assertTrue(self.engine.state.plots[0].is_empty)
def test_lucky_harvest_doubles_the_yield(self): 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): with mock.patch("farm.engine.random.random", return_value=0.0):
result = self.engine.harvest(1) result = self.engine.harvest(1)
self.assertEqual(result["quantity"], 2) 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): def test_harvest_before_ripe_is_refused(self):
self.engine.plant(1, "carrot") 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, 0)
self.assert_code("bad_param", self.engine.feed, aid, "many") 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") self.engine.buy_animal("chicken")
animal = self.engine.state.animals[0] animal = self.engine.state.animals[0]
animal.pending = 3 animal.pending = 3
gold = self.engine.state.gold gold = self.engine.state.gold
result = self.engine.collect(animal.aid) result = self.engine.collect(animal.aid)
self.assertEqual(result["quantity"], 3) self.assertEqual(result["quantity"], 3)
self.assertEqual(result["earned"], config.ANIMALS["chicken"]["base_price"] * 3) self.assertEqual(result["price"], self.engine.market_price("animal:chicken"))
self.assertEqual(self.engine.state.gold, gold + result["earned"]) 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) self.assertEqual(animal.pending, 0)
def test_collect_without_output_is_refused(self): def test_collect_without_output_is_refused(self):
@@ -563,25 +567,26 @@ class GrowthTestCase(unittest.TestCase):
self.engine.plant(1, "carrot") # 3 天,刚好够观察缺水停摆 self.engine.plant(1, "carrot") # 3 天,刚好够观察缺水停摆
self.assertEqual(self.engine.state.plots[0].moisture, config.PLANT_INITIAL_MOISTURE) 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.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].moisture, 0)
self.assertEqual(self.engine.state.plots[0].growth, 2) 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.assertEqual(self.engine.state.plots[0].growth, 2, "没水就不该再长")
self.assertTrue(any("缺水" in text for text in events)) self.assertTrue(any("缺水" in text for text in events))
def test_watering_resumes_growth(self): def test_watering_resumes_growth(self):
self.engine.plant(1, "carrot") self.engine.plant(1, "carrot")
self.engine.advance_day() self.engine.advance_day(weather="sunny")
self.engine.advance_day() self.engine.advance_day(weather="sunny")
self.engine.advance_day() # 此刻缺水停摆 self.engine.advance_day(weather="sunny") # 此刻缺水停摆
self.assertEqual(self.engine.state.plots[0].growth, 2, "断水期间不该生长") self.assertEqual(self.engine.state.plots[0].growth, 2, "断水期间不该生长")
self.engine.water(1) # 浇到 3 点水 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.assertEqual(self.engine.state.plots[0].growth, 3, "浇水后又开始长了")
self.assertTrue(self.engine.state.plots[0].is_ripe) self.assertTrue(self.engine.state.plots[0].is_ripe)
+35 -5
View File
@@ -24,7 +24,7 @@ import urllib.request
from farm import config, net from farm import config, net
from farm.engine import GameError 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 ( from farm.server.hub import (
CLOSE_MESSAGE, CLOSE_MESSAGE,
Client, Client,
@@ -657,11 +657,16 @@ class BriefAndRulesTestCase(unittest.TestCase):
self.hub = ConnectionHub() self.hub = ConnectionHub()
self.session = self.manager.get("alice") self.session = self.manager.get("alice")
def test_brief_keys_exist_in_the_snapshot(self): def test_brief_keys_exist_in_the_brief_payload(self):
"""回归:snapshot 改字段名时,brief 必须跟着改,否则动作回执直接 500。""" """回归:brief_source 改字段名时,BRIEF_KEYS 必须跟着改,否则动作回执直接 500。
snap = self.session.snapshot()
BRIEF_KEYS 的数据源是 ``FarmEngine.brief_source()``(精简状态),
**不是**完整快照 ``session.snapshot()``——后者包含 market/plots 等大块数据,
拿它来断言会把"brief 只读精简字段"这个设计意图测丢。
"""
data = brief(self.session, self.hub)
for key in BRIEF_KEYS: 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): def test_brief_exposes_the_to_do_lists(self):
self.session.engine.plant(1, "wheat") self.session.engine.plant(1, "wheat")
@@ -805,6 +810,31 @@ class DaemonTestCase(unittest.TestCase):
self.assertEqual(len(lines), 5) self.assertEqual(len(lines), 5)
self.assertEqual(lines[-1], "第49行") 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 依赖它)") @unittest.skipUnless(HAS_TESTCLIENT, "未安装 httpx(TestClient 依赖它)")
class RestApiTestCase(unittest.TestCase): class RestApiTestCase(unittest.TestCase):
+6 -4
View File
@@ -455,9 +455,9 @@ class TuiLetterKeyBehaviourTestCase(unittest.TestCase):
self.assertEqual(self.engine.state.plots[0].moisture, config.WATER_CAPACITY) self.assertEqual(self.engine.state.plots[0].moisture, config.WATER_CAPACITY)
self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"] 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.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): def test_e_harvests_everything_ripe(self):
for number in (1, 2, 3): for number in (1, 2, 3):
@@ -481,9 +481,11 @@ class TuiLetterKeyBehaviourTestCase(unittest.TestCase):
def test_c_collects_the_focused_animal(self): def test_c_collects_the_focused_animal(self):
self.engine.buy_animal("chicken") self.engine.buy_animal("chicken")
self.engine.state.animals[0].pending = 2 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.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): def test_l_buys_land_and_k_buys_feed(self):
self.engine.state.level = 2 self.engine.state.level = 2