From cb4c1e7800b21c02890e6e75ea78e768cdd8b826 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:05:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(engine):=20=E5=9B=9B=E5=BA=A7=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8C=96=E8=AE=BE=E6=96=BD=E2=80=94=E2=80=94=E5=96=B7?= =?UTF-8?q?=E7=81=8C=20/=20=E6=94=B6=E5=89=B2=E6=9C=BA=20/=20=E5=94=AE?= =?UTF-8?q?=E8=B4=A7=E6=9C=BA=20/=20=E7=A3=A8=E5=9D=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:自动化建筑只有水井和喂食器,中期以后每天的操作只剩浇水喂食 的重复劳动。新设施把"替代行动点动作"的思路推到完整流水线: 浇水(井/喷灌)→ 收割机收熟地 → 磨坊把陈粮变饲料 → 喂食器喂牲口 → 售货机趁高价清库存,人不在也能持续产出。 - config.BUILDINGS 注册 4 座:喷灌系统 2800 金(全量浇水)、 自动收割机 900 金 ×3(每天各收 2 块熟地,刻意不给经验——机器收的 不算劳动,给手动玩法留价值)、自动售货机 1200 金(市价 > 基准价才 出手、每天上限 5 份、越贵越先卖)、饲料磨坊 500 金(1 份最便宜的 作物磨 3 份饲料)。 - engine._run_buildings 扩成六段流水线,顺序即依赖:磨坊排在喂食器前, 早上磨的饲料当天就能喂上;收割机遇满仓停摆、作物留地(同喂食器 缺料停摆哲学——自动系统绝不硬来,更绝不自动花钱)。 - webui / build 动作 / catalog 全部由 config.BUILDINGS 驱动,零改动。 - 测试 +6(喷灌全量浇水、收割入库且无经验、满仓停摆留作物、磨坊挑 最便宜、磨坊→喂食器同日链路、售货机阈值与配额)。修复断言时确认 两个既有行为:引擎自带 START_FEED=5;advance_day 末尾会把动物 fed 复位("喂到了"的证据是 pending 累计而非 fed 本身)。 - 门禁:534 项 OK(skip 39)、ruff/pyright 全绿。 --- .workbuddy/memory/2026-09-23.md | 32 +++++ .workbuddy/memory/MEMORY.md | 24 +++- .../simplefarm-verify-and-ship/SKILL.md | 44 ++++++- README.md | 2 +- farm/config.py | 32 +++++ farm/engine.py | 111 +++++++++++++++++- tests/test_engine.py | 97 +++++++++++++++ 7 files changed, 332 insertions(+), 10 deletions(-) diff --git a/.workbuddy/memory/2026-09-23.md b/.workbuddy/memory/2026-09-23.md index 874abd6..890d4dc 100644 --- a/.workbuddy/memory/2026-09-23.md +++ b/.workbuddy/memory/2026-09-23.md @@ -57,3 +57,35 @@ - 教训:幂等补丁脚本里 `new == ""` 时 `if new in text` 恒真,会误报 ALREADY 跳过删除类替换(这次是删全局 Enter 监听那行,靠核对输出抓回来)。 删除类编辑别走补丁脚本,直接用 Edit 工具。 + +## 2026-09-23(晚)「种不了东西」:中文用户名 + X-Farm-Id 头 + +- 用户报种不了东西。API 直发 plant 全正常、页面代码静态看着也正常, + 最后用 headless Chrome(本机 Chrome + `--cdp 9333` 连接模式)真机复现: + 登录"阳"后点播种→选小麦,toast 不变、地块不变,act() 抛 + `TypeError: Failed to execute 'fetch': String contains non ISO-8859-1 code point`。 +- **根因**:`headers()` 把用户名塞进 `X-Farm-Id` 头,HTTP 头只允许 Latin-1, + 中文用户名让 fetch 在发送前就抛异常。登录时 me=null(头是 ASCII 的 + default)所以能登录成功,**登录后所有走 api() 的写操作全部静默失败** + (Promise 拒绝无人接住);页面靠 SSE 照常渲染,看起来一切正常。 +- 修复(webui.py,幂等补丁 6 处全中):headers() 非 ASCII 值不塞头 + (登录态走 Cookie,头只是匿名脚本备用通道);api() 捕获 fetch 异常并 + toast,杜绝静默失败;localStorage 裸调用加 try/catch(sandbox iframe + 无 allow-same-origin 时访问即抛,启动段有三处裸调用会炸掉整个脚本)。 +- 真机回归:登录阳→点播种→选小麦→toast「1 号地已种下小麦」+ 地块状态 + 实时更新。门禁 528 项 OK(skip 39)、ruff/pyright 全绿。提交 2676927。 +- 顺带发现:上一轮遗留的调试实例用 `--tick 60` 跑了好几个小时(day 冲到 + 92),已用默认 40 分钟/天重启。 +- **推送排障(重要环境坑)**:push 卡死 5 分钟 = **system 级 + `credential.helper=helper-selector` 在等人点选择框**(进程 + git-credential-helper-selector.exe)。修法:global 里放**空 helper 重置 + 列表**再依次挂 wincred + GCM(`!"C:/Program Files/..."` **必须带 `!` 和 + 引号**,否则空格截断)。GCM 路径还要 `NO_PROXY=gitea.4i.hk`——系统代理 + 对它 502。修完推送秒过。Chrome/Edge 直接 `--executable-path` 起不来 + (exit 0 无端口),要自己 headless 起进程再 `--cdp ` 连。 + +### 教训 +- HTTP 头只允许 Latin-1:任何把用户可输入内容(用户名、中文名)塞进请求头 + 的代码,中文输入即炸 fetch。要么不塞(走 Cookie/body),要么白名单校验。 +- api() 层必须接住网络异常并可见化——静默的 Promise 拒绝让这个 bug 从 + "登录功能上线"一直活到用户报障。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 83bfcb2..587a524 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -48,9 +48,29 @@ requirements.txt 是导出产物只读。门禁(改动后必跑): ## 远端与推送 - https://gitea.4i.hk/hhhge/SimpleFarm(public,main)。推送: - `GCM_INTERACTIVE=always git push origin main`(缺它 GCM 拒交互,报 - "Failed to authenticate");还不行加 GCM_TRACE=1。沙箱会丢 + `NO_PROXY=gitea.4i.hk no_proxy=gitea.4i.hk GCM_INTERACTIVE=always git push origin main` + (NO_PROXY 缺了被系统代理拦成 502;GCM_INTERACTIVE 缺了 GCM 拒交互报 + "Failed to authenticate")。还不行加 GCM_TRACE=1。沙箱会丢 .git/refs/remotes 写入使 status 显示 [gone]——假象,验证推送看 `git ls-remote`。 +- credential.helper 链已修(2026-09-23 晚):system 级 `helper-selector` 会开 + 选择框等人点导致 push 永久挂死,已用 global **空 helper 重置列表**顶掉, + 现为:"" → wincred → `!"C:/Program Files/Git/mingw64/bin/git-credential-manager.exe"` + (**必须带 `!` 和引号**,否则空格截断)。push 反复挂死先 taskkill 清 + git.exe/GCM 僵尸(占 GCM 单例锁)。凭据库有 GCM 刷新令牌 + `git:https://refresh_token.gitea.4i.hk`。 +- 真浏览器验收(UI bug API 测不出):headless Chrome 自己起进程 + (--headless=new --remote-debugging-port=9333 --user-data-dir=临时目录, + 后台任务保活),agent-browser 用 `--cdp 9333` 连(每条命令都要带); + 系统浏览器 --executable-path 直起会 exit 0 连不上。详见项目技能 + simplefarm-verify-and-ship。 + +## webui 前端铁律(2026-09-23) +- **HTTP 头只允许 Latin-1**:用户可输入内容(中文用户名)塞进请求头会让 + fetch 发送前抛 TypeError。headers() 里非 ASCII 一律不塞(登录态走 Cookie)。 +- api() 必须接住 fetch 异常并 toast——静默 Promise 拒绝曾让登录后所有写 + 操作失效却毫无痕迹。 +- localStorage 裸调用全走 lsGet/lsSet 兜底(sandbox iframe 无 + allow-same-origin 时访问即抛,会炸掉整个启动脚本)。 ## 文档 README.md 是入口文档(接口表、SSE 协议、设计取舍);当天记录在 diff --git a/.workbuddy/skills/simplefarm-verify-and-ship/SKILL.md b/.workbuddy/skills/simplefarm-verify-and-ship/SKILL.md index 035ef0c..18accea 100644 --- a/.workbuddy/skills/simplefarm-verify-and-ship/SKILL.md +++ b/.workbuddy/skills/simplefarm-verify-and-ship/SKILL.md @@ -69,6 +69,27 @@ uv run main.py serve --port 8799 --tick 3 --save-dir .e2e-saves 单元测试抓不住 autosave 失效——2026-09-23 就漏过一次:`dirty` 被推送循环高频清零, autosave 60s 才看一眼,等于永不存盘,全量测试照样绿。 +**动了 webui 前端必须加验一条**:API 正常 ≠ 页面能用(window.prompt 被 +sandbox 屏蔽、中文用户名炸 fetch 头,都是 API 测不出来的)。真浏览器验收: + +```bash +# 1) 起 headless Chrome(后台任务保活;--executable-path 直起会 exit 0 连不上) +uv run python -c " +import subprocess, time +subprocess.Popen([r'C:\Program Files\Google\Chrome\Application\chrome.exe', + '--headless=new','--remote-debugging-port=9333', + '--user-data-dir='+__import__('os').environ['TEMP']+r'\abchrome', + '--no-first-run','about:blank']) +while True: time.sleep(3600)" +# 2) agent-browser 走 CDP 连接(每条命令都要带 --cdp) +PATH="/c/Program Files/nodejs:$PATH" agent-browser --cdp 9333 open http://127.0.0.1:8765/ +PATH="/c/Program Files/nodejs:$PATH" agent-browser --cdp 9333 eval "..." # 页面内点按钮、读 toast +``` + +注意:系统 Chromium(Chrome 153+ / Edge)`--executable-path` 模式起不来, +必须自己起进程再 `--cdp` 连;`find text "登录"` 会点到标题而非按钮, +用 `eval` 精确 `document.getElementById('dologin').click()`。 + ## 第 3 步:写记忆 - 当天日志追加到 `.workbuddy/memory/YYYY-MM-DD.md`(append-only)。 @@ -83,20 +104,39 @@ autosave 60s 才看一眼,等于永不存盘,全量测试照样绿。 git add -A && git commit -F - <<'MSG' ...多行中文 commit message... MSG -GCM_INTERACTIVE=always git push origin main +NO_PROXY=gitea.4i.hk no_proxy=gitea.4i.hk GCM_INTERACTIVE=always git push origin main ``` +- **`NO_PROXY=gitea.4i.hk`(大小写都要)是必需的。** 环境代理对它返回 502, + 直连秒通。缺了这个会误判成认证问题。 +- **push 卡死几分钟不动 = system 级 `credential.helper=helper-selector` 在 + 等人点选择框**(tasklist 里能看到 git-credential-helper-selector.exe)。 + 已修:global 里用**空 helper 重置列表**顶掉它,再依次挂 wincred 和 GCM: + + ```bash + git config --global --get-all credential.helper + # 应该是(顺序重要):空串 → wincred → GCM + # "" 、 !"C:/Users/Administrator/.workbuddy/binaries/PortableGit/versions/1.2.0/mingw64/bin/git-credential-wincred.exe" + # 、 !"C:/Program Files/Git/mingw64/bin/git-credential-manager.exe" + ``` + + GCM 路径**必须带 `!` 前缀和引号**(`!"C:/Program Files/..."`)—— + 否则空格截断成 `C:/Program`,报 "No such file or directory"。 - **`GCM_INTERACTIVE=always` 是必需的。** 默认(非交互)下 GCM 直接拒绝 `Cannot prompt because user interactivity has been disabled`, 报错却显示成远端的 `Failed to authenticate user`,很容易误判成 token 失效。 加上它之后 GCM 会静默续期。 - 还是失败就加 `GCM_TRACE=1` 重新推一次,日志会明确说 `No existing credentials found` 还是拿到了 token。别急着去翻 tea 的 - `credentials.json.enc`(加密的,读不出来)。 + `credentials.json.enc`(加密的,读不出来)。凭据库里有 + `git:https://refresh_token.gitea.4i.hk`(GCM 的刷新令牌)。 - **验证推送成功看 `git ls-remote origin`**,不要看 `git status -sb`—— 沙箱会丢掉 `.git/refs/remotes/**` 新建目录的写入,status 会谎报 `## main...origin/main [gone]`。`ls-remote` 的 sha 与 `git rev-parse HEAD` 一致就是推上去了,`[gone]` 是无害的本地缓存问题。 +- 被杀掉的 push 会留下 git.exe / git-credential-manager.exe 僵尸进程, + 占住 GCM 的单例锁让后续 push 排队挂死。推送反复挂时先 + `taskkill /F /IM git.exe` 等清场再试。 ## 别踩的坑(本机特有) diff --git a/README.md b/README.md index 4028cd2..a4193d8 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ uv run main.py solo # 本地引擎 + 终端界面,不开 | **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** | | **谷仓** | 收获和收取**只入库、不当场给钱**(容量 40 起,随等级与谷仓建筑增长);仓满时收不进去,作物原样留在地里,卖了再来 | | **市场** | 价格每天在基础价 ±18% 内波动;**靠 `sell` 择时变现**,高位卖才真的赚 | -| **建筑** | 谷仓(+25 仓容,最多 5 座)/ 水井(每天自动浇一块干透的地)/ 自动喂食器(每天自动喂一只,只吃库存饲料、绝不自动花钱)/ 温室(免疫暴风雨、水分蒸发减半);每多一座涨价 60% | +| **建筑** | 谷仓(+25 仓容,最多 5 座)/ 水井(每天自动浇一块干透的地)/ 饲料磨坊(每天 1 份作物磨 3 份饲料,挑最便宜的)/ 自动喂食器(每天自动喂一只,只吃库存饲料)/ 自动收割机(每天自动收 2 块熟地入库,不给经验,可叠 3 座)/ 自动售货机(市价高于基准价时自动卖出,每天上限 5 份)/ 喷灌系统(所有缺水地块一次浇满)/ 温室(免疫暴风雨、水分蒸发减半);每多一座涨价 60%。自动化的共同底线:**只替你干活的动作,绝不自动花钱** | | **土地** | 初始 6 块,每升 1 级 +2 块,上限 16 块,买地价格指数递增 | | **目标** | 净资产(金币+土地+牲畜+库存+地里作物)做到 10000 金币,共 5 个里程碑 | | **丰收** | 收获有 12% 概率触发双倍产出 | diff --git a/farm/config.py b/farm/config.py index af5133e..bba2069 100644 --- a/farm/config.py +++ b/farm/config.py @@ -289,6 +289,10 @@ MAX_GRANARY = 400 # 仓容硬上限 # max_level : 最多能造几座 # effect : 一句话说明(原样出现在 catalog 与界面上) +HARVESTER_PLOTS = 2 # 每台自动收割机每天收获的地块数 +VENDOR_QUOTA = 5 # 自动售货机每天最多卖出的份数 +MILL_OUTPUT = 3 # 饲料磨坊每份作物磨出的饲料量 + BUILDINGS: dict[str, dict[str, Any]] = { "granary": { "name": "谷仓", @@ -318,6 +322,34 @@ BUILDINGS: dict[str, dict[str, Any]] = { "max_level": 1, "effect": "作物不再被暴风雨损毁,水分蒸发减半", }, + "sprinkler": { + "name": "喷灌系统", + "cost": 2800, + "level_req": 4, + "max_level": 1, + "effect": "每天自动给所有缺水的地块浇满", + }, + "harvester": { + "name": "自动收割机", + "cost": 900, + "level_req": 3, + "max_level": 3, + "effect": f"每天自动收获 {HARVESTER_PLOTS} 块成熟作物入谷仓(不给经验)", + }, + "vendor": { + "name": "自动售货机", + "cost": 1200, + "level_req": 4, + "max_level": 1, + "effect": f"每天自动卖出市价高于基准价的库存(上限 {VENDOR_QUOTA} 份)", + }, + "mill": { + "name": "饲料磨坊", + "cost": 500, + "level_req": 2, + "max_level": 1, + "effect": f"每天把 1 份库存作物磨成 {MILL_OUTPUT} 份饲料(挑最便宜的)", + }, } BUILD_PRICE_GROWTH = 1.6 # 同类建筑第 n 座的价格系数 diff --git a/farm/engine.py b/farm/engine.py index 4423b4f..1d7deab 100644 --- a/farm/engine.py +++ b/farm/engine.py @@ -1066,13 +1066,114 @@ class FarmEngine: return evaporate def _run_buildings(self, state: GameState) -> list[str]: - """结算建筑的每日自动动作:水井浇水、喂食器喂食。 + """结算建筑的每日自动动作。 - 两件事都只替代"玩家本来要花行动点做的事",且都**不替玩家花钱**: - 喂食器只在库存饲料够时动作,缺了就停摆并记一条日志,而不是像手动 - 喂食那样以应急价临时采购——自动系统悄悄花光金币是最容易被骂的设计。 + 顺序是一条流水线:浇水(水井/喷灌)保住作物 → 收割机把熟了的收进 + 谷仓 → 磨坊把陈粮磨成饲料 → 喂食器喂牲口 → 售货机趁高价清库存。 + 共同的设计底线:**只替代玩家本来要花行动点做的事,绝不自动花钱** + ——喂食器缺饲料停摆、收割机遇满仓停摆,都宁可停也不硬来。 """ - return self._wells(state) + self._feeders(state) + return ( + self._wells(state) + + self._sprinklers(state) + + self._harvesters(state) + + self._mill(state) + + self._feeders(state) + + self._vendor(state) + ) + + def _sprinklers(self, state: GameState) -> list[str]: + """喷灌系统:水井的全量版,一口气浇完所有干透的地(限 1 座)。""" + if state.building_count("sprinkler") <= 0: + return [] + events: list[str] = [] + for plot in state.plots: + if plot.crop is None or plot.moisture > 0: + continue + plot.moisture = config.WATER_CAPACITY + events.append(f"喷灌系统给 {plot.index + 1} 号地浇了水") + return events + + def _harvesters(self, state: GameState) -> list[str]: + """自动收割机:每天替玩家收熟地,入库逻辑与手动收获完全一致。 + + 刻意**不给经验**——机器收的不算你的劳动,手动收获才涨等级, + 这是自动化换手感时留的手动价值。谷仓满了就停摆(同喂食器哲学), + 作物留在地里,卖了就能恢复。 + """ + quota = state.building_count("harvester") * config.HARVESTER_PLOTS + if quota <= 0: + return [] + events: list[str] = [] + for plot in state.plots: + if quota <= 0: + break + if plot.crop is None or not plot.is_ripe: + continue + crop_key = plot.crop + quantity = self._roll_yield() + try: + self._store(crop_key, quantity) + except GameError: + events.append("谷仓满了,自动收割机停摆") + break + plot.crop = None + plot.growth = 0.0 + plot.moisture = 0 + state.total_harvested += quantity + quota -= 1 + events.append( + f"自动收割机把 {plot.index + 1} 号地的{config.CROPS[crop_key]['name']}收进了谷仓" + ) + return events + + def _mill(self, state: GameState) -> list[str]: + """饲料磨坊:把 1 份库存作物磨成饲料,挑**最便宜的**下手。 + + 排在喂食器前面:早上磨好,喂食器当天就能吃上。只磨谷仓里的存货, + 绝不自动买粮——"自动系统悄悄花光金币"是本项目的红线。 + """ + if state.building_count("mill") <= 0 or not state.inventory: + return [] + key = min(state.inventory, key=config.base_price) + state.inventory[key] -= 1 + if state.inventory[key] <= 0: + del state.inventory[key] + state.feed += config.MILL_OUTPUT + return [f"饲料磨坊把 1 份{config.goods_name(key)}磨成了 {config.MILL_OUTPUT} 份饲料"] + + def _vendor(self, state: GameState) -> list[str]: + """自动售货机:市价高于基准价的库存自动出手,越贵越先卖。 + + 卖出是赚钱不是花钱,所以它是唯一"主动碰金币"的自动化——但每天 + 有份数上限,且基准价以下的存货绝不碰,留着等高价。 + """ + quota = state.building_count("vendor") * config.VENDOR_QUOTA + if quota <= 0 or not state.inventory: + return [] + offers = [] + for key in sorted(state.inventory): + base = config.base_price(key) + if base <= 0: + continue + ratio = self.market_price(key) / base + if ratio > 1.0: + offers.append((ratio, key)) + offers.sort(reverse=True) + events: list[str] = [] + for _, key in offers: + if quota <= 0: + break + qty = min(state.inventory[key], quota) + earned = self._earn(self.market_price(key) * qty, config.goods_name(key)) + left = state.inventory[key] - qty + if left > 0: + state.inventory[key] = left + else: + del state.inventory[key] + quota -= qty + events.append(f"自动售货机卖出 {qty} 份{config.goods_name(key)},+{earned} 金币") + return events def _wells(self, state: GameState) -> list[str]: quota = state.building_count("well") diff --git a/tests/test_engine.py b/tests/test_engine.py index 9dbba6a..8f2c350 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -760,6 +760,103 @@ class GrowthTestCase(unittest.TestCase): self.assertEqual(plot.growth, plot.grow_days) +class AutomationTestCase(unittest.TestCase): + """四座自动化设施:喷灌 / 收割机 / 磨坊 / 售货机。 + + 直接改 ``state.buildings`` 绕过等级门槛——解锁检查是 build() 的通用 + 逻辑(有独立用例),这里只验"建成后每天自动干了什么"。 + """ + + def setUp(self): + self.engine = make_engine() + + def test_sprinkler_waters_every_dry_plot(self): + """水井只能救一块地,喷灌系统要全救。""" + e = self.engine + for no in (1, 2, 3): + e.plant(no, "wheat") + e.state.plots[0].moisture = 0 + e.state.plots[1].moisture = 0 + e.state.buildings["sprinkler"] = 1 + + events = e.advance_day(weather="sunny") + + self.assertEqual(e.state.plots[0].moisture, config.WATER_CAPACITY - 1) + self.assertEqual(e.state.plots[1].moisture, config.WATER_CAPACITY - 1) + self.assertEqual(sum(text.count("喷灌系统") for text in events), 2) + + def test_harvester_auto_harvests_ripe_plots_without_exp(self): + e = self.engine + e.plant(1, "wheat") # 2 天成熟 + e.state.buildings["harvester"] = 1 + e.advance_day(weather="sunny") + e.advance_day(weather="sunny") + exp_before = e.state.exp + + events = e.advance_day(weather="sunny") + + self.assertTrue(any("自动收割机" in text for text in events)) + self.assertIsNone(e.state.plots[0].crop) + self.assertGreaterEqual(e.state.inventory.get("wheat", 0), 1) + self.assertEqual(e.state.exp, exp_before, "机器收的不该给经验") + + def test_harvester_stalls_when_granary_full(self): + e = self.engine + e.plant(1, "wheat") + e.state.buildings["harvester"] = 1 + e.advance_day(weather="sunny") + e.advance_day(weather="sunny") + e.state.inventory["carrot"] = config.GRANARY_BASE # 塞满谷仓 + + events = e.advance_day(weather="sunny") + + self.assertTrue(any("停摆" in text for text in events)) + self.assertEqual(e.state.plots[0].crop, "wheat", "满仓时作物留在地里") + + def test_mill_grinds_the_cheapest_crop(self): + e = self.engine + e.state.inventory = {"wheat": 2, "pumpkin": 1} + e.state.feed = 0 # 引擎自带 START_FEED,清零才好数磨坊的产出 + e.state.buildings["mill"] = 1 + + events = e.advance_day(weather="sunny") + + self.assertEqual(e.state.inventory["wheat"], 1) + self.assertEqual(e.state.inventory["pumpkin"], 1, "南瓜更贵,不该被磨") + self.assertEqual(e.state.feed, config.MILL_OUTPUT) + self.assertTrue(any("饲料磨坊" in text for text in events)) + + def test_mill_feeds_the_feeder_in_the_same_morning(self): + """流水线顺序:磨坊产饲料 → 喂食器当天就能吃上。 + + 注意 fed 会在当天的牲畜结算末尾被复位(今天的粮今天消化), + 所以"喂到了"的证据是周期推进 / 产出累计,不是 fed 本身。 + """ + e = self.engine + e.buy_animal("chicken") + e.state.buildings = {"mill": 1, "feeder": 1} + e.state.inventory = {"wheat": 1} + e.state.feed = 0 + + e.advance_day(weather="sunny") + + self.assertEqual(e.state.animals[0].pending, 1, "当天喂到就该产出 1 份待收") + + def test_vendor_sells_above_baseline_respecting_quota(self): + e = self.engine + e.state.inventory = {"wheat": 8, "carrot": 3} + e.state.market["wheat"] = 20.0 # 基准 14,高于基准 + e.state.market["carrot"] = 10.0 # 基准 26,低于基准 + e.state.buildings["vendor"] = 1 + gold_before = e.state.gold + + events = e.advance_day(weather="sunny") + + self.assertEqual(dict(e.state.inventory), {"wheat": 3, "carrot": 3}) + self.assertEqual(e.state.gold - gold_before, 100) # 5 份 × 20 + self.assertTrue(any("自动售货机" in text for text in events)) + + class AdvanceDayTestCase(unittest.TestCase): def setUp(self): self.engine = make_engine()