Compare commits
10
Commits
cb292f9f53
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9bc8238ab | ||
|
|
4bc7729715 | ||
|
|
15d290a60a | ||
|
|
92b1a628b1 | ||
|
|
cb4c1e7800 | ||
|
|
2676927c1a | ||
|
|
698a5e497a | ||
|
|
d1692d7b10 | ||
|
|
ea346be484 | ||
|
|
305ef0dcbe |
No files matched your search
@@ -0,0 +1,91 @@
|
||||
|
||||
## 2026-09-23 修网页版「点播种没反应」+ 顺手挖出自动存档失效的真 bug
|
||||
|
||||
- 用户报网页版点播种没反应。服务端 plant 动作完全正常 → 定位到前端。
|
||||
- **根因 1(前端)**:播种按钮走 `window.prompt` 选作物,重置走 `window.confirm`
|
||||
——这俩在 sandbox iframe(内置预览面板)里会被**静默屏蔽**:不弹窗、直接返回
|
||||
null,于是"点了没反应"。普通浏览器里是好的,所以一直没暴露。
|
||||
修复:改成页面内弹层(`#pickmask` + `openCropPick`,未锁定作物生成按钮 +
|
||||
取消按钮,点遮罩关闭);重置改两步确认(第一次点变"再点一次确认",3 秒还原)。
|
||||
- **根因 2(后端,真 bug)**:顺着验证存档时发现 `default` 牧场**根本没写进库**
|
||||
——内存里每次重启都是新档。排查出 `session.dirty` 一个标志位伺二主:
|
||||
Publisher 推送循环每 ~250ms 就把 dirty 清掉(无人在线时更是直接清),
|
||||
而 `autosave()` 每 60s 才看一眼 dirty——窗口太小,**自动存档几乎永远不触发**
|
||||
(约 0.4% 撞率)。进度只在手动存档/优雅停机时落盘,进程被强杀就全丢。
|
||||
修复:新增 `session.unsaved` 专管「有变化没落盘」,`_on_change`/`load`/
|
||||
`touch`/`advance_day` 置位、只有成功的 `save()` 清位;`autosave()` 改看
|
||||
unsaved;dirty 继续只管推送节流。hub.py 的推送循环一字未动。
|
||||
- 两只旧测试(`test_autosave_skips_when_clean` / `..._writes_when_dirty` /
|
||||
`autosave_all...`)直接操纵 dirty 驱动 autosave,固化了被修掉的耦合——
|
||||
全部改成用 unsaved 表达意图。新增回归测试:
|
||||
「推送循环清了 dirty 也要照常落盘」「没有新变化不重复写库」。
|
||||
- **真机验证**:0 客户端在线、纯 HTTP 种一块地,70 秒内看到库行更新
|
||||
(gold 120→112,种子钱都对得上)。
|
||||
- 门禁:ruff/pyright 全绿,515 项测试 OK(skip 35)。
|
||||
|
||||
### 教训
|
||||
- 嵌入预览面板里 `window.prompt`/`confirm`/`alert` 都不可信,交互一律用页面内
|
||||
弹层实现。
|
||||
- 一个布尔标志被两个子系统共用时,其中一个高频清零,另一个低频读取——
|
||||
低频的那个等于瞎了。存盘依据必须由"成功的写入"来清。
|
||||
|
||||
## 2026-09-23 用户名登录(多用户)
|
||||
|
||||
- 用户要求「添加多用户的 需要使用用户名登录」;AskUserQuestion 确认:
|
||||
**仅用户名**(不设密码)+ **一账号一个牧场**(用户名即牧场名)。
|
||||
- 新增 `farm/server/auth.py`:`LoginManager` 签发/校验 HMAC-SHA256 签名的
|
||||
HttpOnly Cookie(`sf_auth`,30 天,SameSite=Lax)。Cookie 值 =
|
||||
`base64("用户名|过期时间") + "." + hmac`,验签 `hmac.compare_digest`。
|
||||
密钥 32 字节存 `<save_dir>/secret.key`(0600)——**重启不掉线**,无需建表。
|
||||
- `app.py`:新增 `POST /api/login`(guard 照常检查全局 token)、
|
||||
`POST /api/logout`、`GET /api/me`(刻意不 guard,首屏探测用);
|
||||
`_Services` 加 `auth` 字段与 `user_of()`;**三个认 ?farm= 的地方全部改为
|
||||
「有 Cookie 强制用自己的牧场」**:`session_of`(?farm=/X-Farm-Id 被忽略)、
|
||||
SSE `/api/stream`、`POST /api/farms`。无 Cookie 的脚本/AI/CLI 保持旧语义
|
||||
(互信小圈子,`?farm=` 不受影响),bot/TUI 零改动。
|
||||
- `webui.py`:移除「牧场名」输入框和「进入牧场」按钮,改为登录遮罩
|
||||
(`#loginmask` 复用 `.mask/.pick` 样式)+ 用户徽章 `#who` + 退出按钮;
|
||||
JS 先 `whoami()` 再决定 `connect()` 还是弹登录;令牌输入框保留
|
||||
(change 时重连)。farmName() = 登录用户。
|
||||
- 测试 +13:`LoginCookieTestCase` 9 项纯逻辑(伪造/过期/跨密钥/垃圾串/非法名/
|
||||
密钥复用/session_of 优先级——starlette Request 可直接用 scope dict 构造,
|
||||
不需要 httpx)+ AuthTestCase 4 项 TestClient 档(照例 skip)。
|
||||
门禁 528 项 OK(skip 39),ruff/pyright 全绿。
|
||||
- 真机验收 16/16:登录/Cookie/HttpOnly/?farm= 被忽略/动作落自己牧场/
|
||||
篡改退回未登录/退出/非法用户名 400/首页元素。跨重启:secret.key 复用,
|
||||
Cookie 仍认 carol、显式 save 后作物也在。
|
||||
- 教训:幂等补丁脚本里 `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 <port>` 连。
|
||||
|
||||
### 教训
|
||||
- HTTP 头只允许 Latin-1:任何把用户可输入内容(用户名、中文名)塞进请求头
|
||||
的代码,中文输入即炸 fetch。要么不塞(走 Cookie/body),要么白名单校验。
|
||||
- api() 层必须接住网络异常并可见化——静默的 Promise 拒绝让这个 bug 从
|
||||
"登录功能上线"一直活到用户报障。
|
||||
@@ -0,0 +1,81 @@
|
||||
|
||||
## 2026-09-25 四座自动化设施
|
||||
|
||||
- 用户要「添加自动化设施」,AskUserQuestion 给了 4 个候选,用户全选:
|
||||
喷灌系统 / 自动收割机 / 自动售货机 / 饲料磨坊。
|
||||
- 设计上沿用既有建筑哲学:**只替代行动点动作,绝不自动花钱**。
|
||||
`_run_buildings` 扩成六段流水线(顺序即依赖):
|
||||
水井 → 喷灌 → 收割机 → 磨坊 → 喂食器 → 售货机。
|
||||
磨坊刻意排在喂食器前——早上磨的饲料当天就能喂上;
|
||||
收割机遇满仓停摆、作物留地(同喂食器缺料停摆)。
|
||||
- 数值:喷灌 2800/限1/全量浇水;收割机 900/限3/每天各收 2 块熟地、
|
||||
**刻意不给经验**(机器收的不算劳动,给手动玩法留价值);
|
||||
售货机 1200/限1/市价>基准价才卖、每天上限 5 份、越贵越先卖
|
||||
(_earn 复用 sell 的入账逻辑);磨坊 500/限1/1 份最便宜的作物磨 3 份饲料
|
||||
(FEED_UNIT_PRICE=6,小麦 14 磨 18 金的料,微赚但吃掉作物)。
|
||||
- 架构红利:config.BUILDINGS 驱动一切(build 动作、catalog、网页建筑区、
|
||||
可用动作推荐),新增建筑 engine 之外零改动。
|
||||
- 测试 +6,途中确认两个既有行为:引擎自带 START_FEED=5(feed 断言要先清零);
|
||||
advance_day 牲畜阶段末尾会把 fed 复位(「喂到了」的证据是 pending 累计,
|
||||
鸡 cycle=1 当天喂到就 pending=1)。
|
||||
- 门禁 534 项 OK(skip 39)、ruff/pyright 全绿;提交 cb4c1e7 已推送。
|
||||
服务器已重启到新代码,catalog 8 座建筑齐全。
|
||||
|
||||
## 2026-09-25 Canvas 二维农场地图
|
||||
|
||||
- 用户问「能不能做成二维的」,AskUserQuestion 三选一:CSS 农田视图 /
|
||||
Canvas 像素农场 / 网页+TUI 都改,用户选 **Canvas 像素农场**。
|
||||
- 实现:全在 webui.py(服务端零改动,state 里现成的 plots/animals/buildings
|
||||
够用)。页首插一张全宽 canvas 卡片:逻辑坐标绘制(456×152 起,随地块数变)、
|
||||
实际像素 = 容器宽 × dpr,命中检测自己算(逻辑坐标 = clientX × MAP.w/rect.width)。
|
||||
农田 4 列方阵(>12 块改 6 列)土壤颜色随水分插值、作物 emoji 随生长放大、
|
||||
成熟金光、干裂画裂纹;牧场围栏里牲畜随机漫步回弹、待收产物金泡、没喂 💢;
|
||||
底部建筑带(owned>0 才画);天气五種各有天色+雨滴/云/烈日。
|
||||
交互复用 openCropPick/act():空地点击弹选种、熟地收获、缺水浇水、
|
||||
牲畜收取优先于喂食。下方卡片面板全保留(地图负责沉浸,卡片负责精确)。
|
||||
- **踩坑:rAF 循环首帧同步执行**。updateMap 原顺序 bind→layout,首帧
|
||||
`f.ts` 在 MAP.field=null 上 TypeError,把 requestAnimationFrame 注册一起吞掉,
|
||||
表现是「画布全白但点击命中正常」(命中是纯数学不经过画布)。
|
||||
修复:layout→sync→bind→fit 顺序 + mapStep/mapDraw 空值护栏。
|
||||
排查手法:截图空白先别信,`getImageData` 读像素 + 探针 rAF 计数 + 云位移
|
||||
三连定位「画了没」和「循环活着没」。
|
||||
- 真机验收:登录、canvas 点击浇水(AP 4→3、水分 2→3、toast)、
|
||||
悬停 tooltip、截图目检全过;errors/console 干净。534 项测试全绿。
|
||||
|
||||
## 2026-09-25 移除行动点系统
|
||||
|
||||
- 用户拍板:「谁家浇水还要体力啊 体力删掉 都已经有时间了」——时间
|
||||
(40 分钟一天、只能自然流动)本身就是唯一节奏阀门,AP 是重复惩罚。
|
||||
- 连根拔的触点:config(START_AP/AP_COST)、models(ap/max_ap 字段,旧档
|
||||
payload 的 ap 键 from_dict 自然忽略)、engine(_spend_ap + 8 调用点 +
|
||||
每日恢复)、bot(AP 分支)、client/TUI/main/README/规则文本。
|
||||
- **SQLite 老库迁移**:ap 是标量 NOT NULL 列,connect() 里
|
||||
`PRAGMA table_info` 发现即 `ALTER TABLE farms DROP COLUMN ap`
|
||||
(Python 3.13 的 SQLite ≥3.35 支持)。实测 saves/farm.db 自动迁完。
|
||||
- 网页端:删"行动点"统计卡;"下一天"倒计时改**客户端本地走秒**
|
||||
(render 记 tickLeft/tickStamp 基点,1s 定时器补 SSE 推送间隙);
|
||||
字体栈补 MiSans/HarmonyOS Sans/PingFang、正文 15px、数字
|
||||
tabular-nums、canvas 小字加粗。
|
||||
- 测试 -2(AP 专属用例没了)改 5,532 项全绿;提交 15d290a 已推送。
|
||||
真机:老库迁移 ✅、无行动点卡 ✅、倒计时 39:04→39:02 走秒 ✅。
|
||||
|
||||
## 2026-09-25 地图手感打磨(4bc7729)
|
||||
|
||||
- **动作粒子**:MAP.fx 数组,act() 成功后 mapJuice(action, params)——
|
||||
地块号/牲畜 id 反查画布坐标喷粒子(重力+寿命衰减)+ 上浮表情浮字
|
||||
(浇水💧播种🌱收获✨喂食🍽)。api() 失败返回 null 天然不触发。
|
||||
- **昼夜色温**:dayTint(prog) 按一天进度叠半透明层(清晨淡金→正午无→
|
||||
黄昏橙→入夜靛紫)。tick_seconds 只在完整快照里、brief 没有,
|
||||
靠 refreshFromBrief 的 Object.assign 合并语义存活,兜底 2400。
|
||||
- 成熟金光改呼吸式(sin 起伏 shadowBlur);悬停可交互对象指针变手型。
|
||||
- 验收注意:粒子视觉链路可用 mapJuice 直接注入测(真动作要看地块状态
|
||||
凑巧——全满时浇水会走失败路径,正好验证"失败不触发")。
|
||||
- **沙箱进程回收边界(重要)**:WorkBuddy 会话里启动的进程都会被环境
|
||||
回收——后台任务(跑 15m~1h 后静默死、日志无错误)、甚至
|
||||
`--daemon` 的 DETACHED_PROCESS 守护进程(沙箱 Job 对象 kill-on-close
|
||||
连 breakaway 进程一起杀)都活不长。**长期挂机必须让用户自己在系统
|
||||
终端跑** `uv run main.py serve`(或 `--daemon`),那才是真正独立的进程。
|
||||
会话内只做临时拉起 + 自动存档(60s 周期)兜底损失。
|
||||
|
||||
|
||||
|
||||
+68
-187
@@ -1,196 +1,77 @@
|
||||
# SimpleFarm 项目长期约定
|
||||
|
||||
## 是什么
|
||||
终端农场经营游戏 + 小型多人游戏服务器(v2.0)。游戏跑在服务端进程里,
|
||||
TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务端不内置 AI)。
|
||||
|
||||
## 运行方式(v2.0 起由 uv 统一管理)
|
||||
- **单一可信来源是 `pyproject.toml`**,`.venv/` 由 uv 创建;`uv.lock` **要提交**(可复现构建依据)。
|
||||
加/删依赖走 `uv add` / `uv remove`,不要手工编辑依赖表,也不要再列 `pip install`。
|
||||
- 所有命令带 `uv run ` 前缀:`uv run main.py serve --daemon`,网页 <http://127.0.0.1:8765/>,文档 /docs
|
||||
- 玩:`uv run main.py play --farm 名字`;单机:`uv run main.py solo`
|
||||
- AI/脚本入口:`main.py actions | do <动作> '<JSON>' | batch | state --json | watch | bot`
|
||||
- **测试套件已重建(2026-09-19)**:`uv run python -m unittest discover -s tests`,**500 项**。
|
||||
但 **pyproject 里不认它**(用户明确选择「只写测试文件」):`tests` 不进 ruff `src`、
|
||||
不进 pyright `include`、没有 `per-file-ignores`,`httpx` 也保持已移除状态 ——
|
||||
于是 `TestClient` / 真机 `LiveServer` 那 35 项靠 skip-guard 自动跳过。
|
||||
代价是:`ruff check .` 仍会扫 `tests/`(命令行给了 `.`),**测试文件必须自己保持 lint 干净**。
|
||||
覆盖:`test_engine`(110) / `test_actions`(35) / `test_server`(131, skip 35) /
|
||||
`test_tui_keys`(75) / `test_bot`(36) / `test_client`(113)。
|
||||
- 质量门禁(改动后都应跑一遍):
|
||||
## 是什么与运行
|
||||
终端农场游戏 + 多人服务器(HTTP + SSE 给 AI/脚本;TUI/网页纯客户端)。
|
||||
uv 管理:所有命令带 `uv run `;依赖只改 pyproject(uv add/remove),uv.lock 要提交;
|
||||
requirements.txt 是导出产物只读。门禁(改动后必跑):
|
||||
`uv run ruff format . && uv run ruff check . && uv run pyright && uv run python -m unittest discover -s tests`
|
||||
(2026-09-19 起把测试并入标准动作;想复核性能再跑 `uv run python tools/bench.py --repeat 5`)
|
||||
- 不用 uv 的人:`requirements.txt` 是 `uv export` 的产物,只读、不要手工改。
|
||||
README 与 main.py 顶部保留了一段"没装 uv?把 `uv run ` 去掉即可"的兜底说明,**别删**。
|
||||
(现 528 项;TestClient/真机档 39 项因无 httpx 自动 skip;`ruff check .` 会扫 tests,
|
||||
测试文件要自己保持 lint 干净)。性能复核 `uv run python tools/bench.py --repeat 5`。
|
||||
|
||||
## 玩法节奏(2026-09-19 起)
|
||||
- **一天 40 分钟**:`config.DEFAULT_TICK_SECONDS = 2400`,`MAX_TICK_SECONDS = 86400`
|
||||
(旧上限 600 秒会把 40 分钟悄悄夹回去,已放宽)。`--tick` 可调,调试用 `--tick 3`。
|
||||
- **没有「手动推进一天」**:`engine.sleep()` / `SLEEP_COOLDOWN` / `/api/sleep` /
|
||||
TUI 的 `[N]休息` / 网页版按钮**全部移除**,时间只能自然流动。
|
||||
机器人改为 `RuleBot.wait_for_next_day(timeout)`,CLI `bot --wait` 控制等多久(0 = 直接收工)。
|
||||
再加任何“快进一天”的入口前先想清楚:它会让慢时钟彻底失效。
|
||||
- 时长显示统一走 `farm.models.format_duration()`(2400 秒 → `40:00`),
|
||||
TUI 头部倒计时 / `main.py state` / 网页版(JS 里同名函数)都用它,别再写 `{x}s`。
|
||||
- 新建牧场时 `FarmSession` 会立刻设好 `next_tick_at`,否则到时钟线程下一轮(≤0.2s)
|
||||
之间倒计时是 None,客户端会误显示成“已暂停/手动模式”。
|
||||
## 玩法与存档
|
||||
- 一天 40 分钟(DEFAULT_TICK_SECONDS=2400);**没有手动快进一天**的入口,别再加。
|
||||
- 天气 5 种决定水分蒸发,`state.forecast` 明天提前可见;收获进 `inventory` 谷仓、
|
||||
`sell` 才换钱;建筑每天在 advance_day 里**先于生长**执行。
|
||||
- 存档 SQLite `saves/farm.db` 一牧场一行:标量列 + 整份 payload JSON(加字段改
|
||||
`_raw_state()`,不改表结构);SAVE_VERSION=2 / MIN=1 向后兼容;老 JSON 启动时自动
|
||||
迁移(原文件 *.json.migrated 留底)。`StorageError = sqlite3.Error`,别写 except OSError。
|
||||
WAL 是持久属性,建库设一次即可。测试钩子:`advance_day(weather="sunny")` 钉死天气。
|
||||
|
||||
## 三大系统:天气 / 谷仓 / 建筑(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`
|
||||
里**先于生长**执行(水井救的正是今天要断水那块地)。喂食器只吃库存饲料、绝不自动花钱。
|
||||
- **存档**:**SQLite**(`saves/farm.db`,一张 `farms` 表,一牧场一行)。
|
||||
标量列(day/gold/level/ap/weather/forecast/net_worth)管可查询,
|
||||
完整状态整份存 `payload` JSON——**加字段不用改表结构**,改 `_raw_state()` 就行。
|
||||
`SAVE_VERSION=2` 配 `MIN_SAVE_VERSION=1`,旧档缺的新字段走默认值、不作废玩家档;
|
||||
老 JSON 由 `migrate_json_saves()` 启动时自动导入(原文件改名 `*.json.migrated` 留底)。
|
||||
`StorageError = sqlite3.Error`(不是 OSError 子类),吞异常别再写 `except OSError`。
|
||||
WAL 是持久属性,建库时设一次就够;每次连接重设白花 2ms。
|
||||
- **测试钩子**:`advance_day(weather="sunny")` 可钉死当天天气(不传就随机,测试会飘);
|
||||
这条链的入口是 `_resolve_weather` / `_evaporation` / `_run_buildings` / `_storm_damage`。
|
||||
- 动作表新增 `sell` 与 `build`,都是 **0 AP**;`bot` 在 AP=0 时仍会走一次 `step()`,
|
||||
正是为了执行这些免费动作。
|
||||
## 架构约定
|
||||
- 分层:engine 不认识网络;`server/actions.py` 是动作唯一登记表;RemoteFarm 与
|
||||
FarmEngine 同名同语义(加动作两边都补)。StateView.apply():带 plots=整体替换、
|
||||
不带=合并字段。farm/keys.py 是按键名唯一来源(全小写),tui.py 不许手写字符串字面量。
|
||||
- session 的 `dirty`(推送节流,250ms 清)与 `unsaved`(自动存档依据,只有 save() 清)
|
||||
必须分开——合并会让 autosave 瞎掉。网页禁用 window.prompt/confirm(嵌入预览里被
|
||||
静默屏蔽),交互一律页面内弹层。
|
||||
- **用户名登录(2026-09-23)**:仅用户名、一账号一牧场(用户名即 farm_id)。
|
||||
`auth.py` 签发 HMAC HttpOnly Cookie `sf_auth`(30 天),密钥 `<save_dir>/secret.key`
|
||||
(重启不掉线)。app.py 三个认 ?farm= 的地方(session_of / SSE / POST /api/farms)
|
||||
都是**有 Cookie 强制本人牧场、无 Cookie 保持 ?farm= 旧语义**(脚本/AI 兼容)。
|
||||
动登录逻辑别破坏这条边界;/api/me 刻意不 guard。
|
||||
- SSE `/api/stream` 是唯一实时通道(单向);写操作一律 POST /api/actions;停机必须
|
||||
hub.close_all()。uvicorn+zttp HTTP/2:uvicorn 0.53 不内置 h2,必须 `http="zttp"`;
|
||||
信号接管=子类覆盖 Server.handle_exit(不调 super());as_asgi() 是 pyright 类型桥。
|
||||
- app.py 结构:create_app 只装配,路由全在模块级 `_register_*`;轻量路径只走
|
||||
brief_source()(BRIEF_KEYS 白名单要同步);>60 行的函数应视为信号。
|
||||
- 守护进程:路径只有文件名时用 ensure_parent_dir()(别手写 makedirs(dirname));
|
||||
pid 由服务进程自己写;端口自检用 port_busy()(判断「能连上」而非「能 bind」)。
|
||||
|
||||
## 架构约定(改动时必须遵守)
|
||||
- **分层**:`farm.engine` 只管规则,不认识网络/终端;`farm.tui` 不认识 socket;
|
||||
`farm/server/*` 不认识终端。新增玩法动作只改 `farm/server/actions.py` 一张表。
|
||||
- **接口同构**:`farm.client.RemoteFarm` 必须与 `FarmEngine` 同名同语义,
|
||||
这样 `tui.py` 不需要区分本地/远程。加动作时两边都要补。两条容易踩的:
|
||||
`StateView.apply()` 里"带 `plots` = 完整快照(整体替换)、不带 = 精简状态(只合并字段)"——
|
||||
搞反了播一次种就清空整片农田;`RemoteFarm._normalize()` 必须**先补 scheme 再削尾部斜杠**,
|
||||
顺序反了 `"http://"` 会被削成 `"http:"`,地址没有主机名 → 请求溜到系统代理 → 报一个
|
||||
和真实原因无关的 502。空视图(`StateView()`)也要预置 `plots`/`animals` 为空列表,
|
||||
否则连不上服务器时读 `state.plots` 直接 AttributeError。
|
||||
- **按键名统一小写**:`farm/keys.py` 是按键名的唯一来源,具名键全小写
|
||||
(`UP`/`SPACE`/`TAB`/`ESC`/`CTRL_C` … 常量值即小写字符串),Windows 与 POSIX
|
||||
两条路径必须返回**完全一样**的名字。`tui.py` 一律用 `keys.XXX` 引用,**不要手写
|
||||
字符串字面量**:历史 bug 就是 Windows 侧产大写 `"UP"`、TUI 里写小写 `"up"`,
|
||||
方向键/空格/Tab/ESC 全部静默失效(字母键正常,所以很难注意到)。
|
||||
动 `farm/keys.py` 时,`farm/tui.py` 里所有具名键引用都要跟着核一遍。
|
||||
读不到键盘时(管道 / 非 Win32 控制台)TUI 会用 `reader.reason` 在底部明示原因。
|
||||
- **多牧场**:一个 farm = 一个玩家 = 库里一行(`saves/farm.db`)+ 一个引擎 + 独立时钟;
|
||||
时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。
|
||||
- **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理,
|
||||
不能直接落到 `engine.save()`(会写默认路径、覆盖别人的档)。
|
||||
- **实时通道(v2.1 起为 SSE)**:`GET /api/stream` 是唯一实时通道,**单向**推送
|
||||
(welcome/state/day/notice/bye),节流 250ms,消息带自增 `seq`,空闲每 15s 发
|
||||
`: keep-alive` 注释心跳。**写操作一律走 HTTP POST /api/actions**,不再有 WS 回执。
|
||||
停机必须 `hub.close_all()`(否则长连接拖住 ASGI 服务器的优雅退出)+ 配置
|
||||
`timeout_graceful_shutdown` 兜底;客户端 stop() 要 shutdown 底层 socket,
|
||||
光 close() 会等读线程的锁(最长等到下一次心跳)。
|
||||
- **ASGI 服务器(v2.3 起为 uvicorn + zttp 的 HTTP/2)**:`farm/server/__main__.py` 用
|
||||
`uvicorn.Config(http="zttp", http2=True)` + `uvicorn.Server`。
|
||||
**同一端口同时说 HTTP/1.1 与 HTTP/2**(TLS 走 ALPN;明文走 h2c prior-knowledge;
|
||||
分流由 `AutoZttpProtocol` 完成;浏览器只在 https 下用 h2)。
|
||||
关键:uvicorn 0.53 **自己不再内置 h2**,HTTP/2 必须装 `zttp` 并显式选 `http="zttp"`,
|
||||
否则 `Config.load()` 直接 raise(错误信息里就写着 pip install zttp)。
|
||||
两条停机路径汇到同一处:`POST /api/admin/shutdown` 与 Ctrl+C / SIGTERM / SIGBREAK
|
||||
都调 `handle.request_stop()`。信号接管靠**子类覆盖 `Server.handle_exit`**
|
||||
(0.53 的机制是 `capture_signals()` + `handle_exit`,没有 install_signal_handlers 可覆盖);
|
||||
覆盖后不调 super()、也不写 `_captured_signals`,退出时就不会重放信号。第二次信号 = 强制退出。
|
||||
`farm/server/app.py` 的 `as_asgi()` 是给 pyright 的类型桥:uvicorn 的
|
||||
`uvicorn._types.Scope` 与 `starlette.types.Scope` 两个 TypedDict 互不兼容。
|
||||
另:uvicorn 的启动顺序**也是**先 lifespan、后 bind 端口(旧注释曾写成"相反",已更正)。
|
||||
- **端口自检**:uvicorn 会设 `SO_REUSEADDR`,Windows 上第二个实例能
|
||||
"绑上"已被监听的端口并抢走新连接,所以入口启动前用 `daemon.port_busy()`
|
||||
(判断"能不能连上",而不是"能不能 bind")自检,被占用就退 3 并提示换端口。
|
||||
## 本机环境(coreutils 残缺禁管道 / LF / 句柄等详见用户级记忆 ~/.workbuddy/MEMORY.md)
|
||||
- `RemoteFarm.snapshot()` 是本地缓存不发请求;realtime=False 的脚本必须自己 refresh(),
|
||||
否则轮询读到旧数据像卡死。
|
||||
- 本机 HTTP 一律走 farm/net.py 的 open_url()(环境有 HTTP_PROXY 没 no_proxy)。
|
||||
- 幂等补丁脚本注意:`new == ""` 时 `if new in text` 恒真会误报 ALREADY 漏改;
|
||||
删除类编辑直接用 Edit 工具。
|
||||
- 批量改同一文件用幂等补丁脚本(read_bytes/write_bytes + APPLIED/ALREADY/MISS);
|
||||
同一文件一条消息里多次 Edit 会被静默覆盖。
|
||||
|
||||
## 序列化与性能约定(2026-09-19 优化后固化)
|
||||
- **轻量路径不许做完整序列化**:只要标量时(动作回执 / `session.summary()` / `app.brief()` /
|
||||
排行榜单行)一律走 `engine.brief_source()`,**不要**调 `engine.snapshot()` 或
|
||||
`state.to_dict()` 再挑字段——那是整局序列化(16 地 + 8 牲畜约 45µs,轻量路径只需 15µs)。
|
||||
完整快照只在 `GET /api/state`、SSE `state` 消息、`POST /api/actions?full=1` 用。
|
||||
`app.py` 的 `BRIEF_KEYS` / `BRIEF_TODO_KEYS` 是这两类字段的白名单,加字段要同步。
|
||||
- `GameState.to_dict()` 里 `net_worth` **只算一次**再复用(`net_worth` / `goal_index` /
|
||||
`next_goal` 三处都靠它);`net_worth` 是每次访问都重算的属性(约 5µs),别在循环里反复读。
|
||||
「下一个目标」用模块级 `models.next_goal_after(index)`,不要再加 `next_goal` property。
|
||||
- **`tools/bench.py` 是性能守卫**,不只是一次性脚本:它给 `to_dict` / `snapshot` /
|
||||
`brief_source` / `net_worth` 装了调用计数器,谁把轻量路径改回 `snapshot()` 会立刻计数变红。
|
||||
它用 ASGI 直调(不走 httpx),**不跑 FastAPI lifespan** —— 动了 lifespan 要用
|
||||
`tests/test_server.py::LiveServerTestCase`(真 in-process uvicorn)复验。
|
||||
- **`farm/server/app.py` 的结构**:`create_app()` 只做「建 `_Services` + 依次调
|
||||
`_register_*`」,所有路由/生命周期都住在模块级 `_register_*` 函数里(曾经是一个 475 行的
|
||||
内嵌闭包大函数)。加路由就加/改对应的 `_register_*`,别把逻辑塞回 `create_app`。
|
||||
- **函数长度参考**:>60 行的函数应视为信号(重构前 15 个,现在 8 个,最长
|
||||
`__main__._run_foreground` 131 行,其中大半是"必须内嵌的 uvicorn 停机子类")。
|
||||
- **重复的等级门槛**统一走 `engine._is_locked/_require_level/_newly_unlocked`,
|
||||
不要在 `catalog` / `available_actions` / `plant` / `buy_animal` / `_gain_exp` 各写一份。
|
||||
## 远端与推送
|
||||
- https://gitea.4i.hk/hhhge/SimpleFarm(public,main)。推送:
|
||||
`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。
|
||||
|
||||
## 环境禁忌(本机特有)
|
||||
- `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()`(会顺手带回最新状态)。
|
||||
## webui 前端铁律(2026-09-23)
|
||||
- **HTTP 头只允许 Latin-1**:用户可输入内容(中文用户名)塞进请求头会让
|
||||
fetch 发送前抛 TypeError。headers() 里非 ASCII 一律不塞(登录态走 Cookie)。
|
||||
- api() 必须接住 fetch 异常并 toast——静默 Promise 拒绝曾让登录后所有写
|
||||
操作失效却毫无痕迹。
|
||||
- localStorage 裸调用全走 lsGet/lsSet 兜底(sandbox iframe 无
|
||||
allow-same-origin 时访问即抛,会炸掉整个启动脚本)。
|
||||
|
||||
- Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32);
|
||||
若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。
|
||||
一律"先读完 → 关掉句柄 → 再删"。
|
||||
- Bash 的 coreutils 残缺(`ls`/`head`/`tail`/`grep`/**`cat`**/**`rm`** 缺失),**不要用管道**,会整条命令失败;
|
||||
查看/删除文件一律写 Python 脚本(`pathlib.unlink()` / `shutil.rmtree()`)——连删临时脚本都别用
|
||||
shell 的 `rm`(safe-bin shim 依赖 dirname,直接 127)。跑测试也一样:`cd /d/SimpleFarm && uv run python -m unittest ... 2>&1`
|
||||
必须**整条无管道**(`| tail -40` 会让命令直接 127 退出,看起来像"没输出")。
|
||||
PowerShell 跑原生命令的 `*>` 重定向会写成 UTF-16 + 中文乱码,**别用它读输出**。
|
||||
- **写多行 git commit message 不要用 `git commit -m "$(cat <<'EOF' ... EOF)"`**:本机没有 `cat`,
|
||||
会报 `cat: command not found` 然后 git 以为消息为空直接 `Aborting commit due to empty commit message`
|
||||
(此时 `git add -A` 已经成功,暂存区是好的)。正确做法:把消息写进一个文件
|
||||
(用 Write 工具,再 `read_bytes().replace(b"\r\n", b"\n")` 归一化成 LF,避免 CRLF 混进消息),
|
||||
然后 `git commit -F <文件>`,最后删掉它。**注意顺序**:先 `git add -A` 再建消息文件,
|
||||
这样消息文件不会进暂存区(而且见下条,它本来也会被忽略)。
|
||||
- `.gitignore` 里有 **`/_*`**(根目录下划线开头的文件全部忽略):好处是临时补丁脚本
|
||||
(`_splice_app.py`、`_commit_msg.txt` 之类)默认就不会污染 `git status`,跑完删不删都不碍事;
|
||||
代价是**放在根目录、以 `_` 开头的正经文件也会被静默忽略**,要提交就换名字或放子目录。
|
||||
- 环境设了 `HTTP_PROXY` 但没有 `no_proxy`:任何本机 HTTP 访问都要走 `farm/net.py` 的
|
||||
`open_url()`(回环直连),否则会拿到 502 并误判为"服务器没起来"。
|
||||
- Windows 下守护进程的 pid 必须由**服务进程自己**写(见 `farm/server/__main__.py`),
|
||||
父进程的 `Popen().pid` 不可信。
|
||||
- **写文件必须指定行尾**:Windows 上 `pathlib.write_text()` 会把 `\n` 转成 `\r\n`,
|
||||
破坏本仓库"统一 LF"的约定(`.gitattributes` 已声明 `eol=lf`)。
|
||||
用脚本改文件时一律 `read_bytes()/write_bytes()`,或 `write_text(..., newline="\n")`。
|
||||
- **同一文件在一条消息里多次 Edit 会被静默覆盖**(只落地一部分,随后报出"幽灵"错误)。
|
||||
批量改同一文件时改用**幂等 Python 补丁脚本**:整文件读入 → 精确匹配替换 →
|
||||
逐条打印 `ALREADY/APPLIED/MISS`,跑完即删。判断"已应用"要 `if new in text`,
|
||||
不要比对两行文本(会出现 new 是 old 子串的假阳性)。
|
||||
- `git status` 报 M 但 `git diff` 为空,是索引 stat 缓存过期,不是真差异;
|
||||
`git add -A`(内容相同时等于无操作)即恢复干净。
|
||||
|
||||
## 代码风格约定(ruff / pyright 已固化在 pyproject.toml)
|
||||
- 行宽 **100**;字符串统一双引号;`isort` 把 `farm` 视为第一方。
|
||||
- 类型标注用 PEP 585/604 写法(`dict[str, int]` / `X | None`),不要 `Dict` / `Optional`。
|
||||
- 吞异常要显式:`contextlib.suppress(...)` 优先于 `try/except/pass`。
|
||||
- 异常链:转换后重抛用 `raise ... from exc`;有意隐藏原始上下文用 `from None`(**不要省略**,会触发 B904)。
|
||||
- 路径保持 `str`(不要为了 lint 而转 `Path`);`farm/__init__.py` 的惰性 `__all__` 导出是刻意设计。
|
||||
- 改完代码的标准动作:`uv run ruff format . && uv run ruff check . && uv run pyright`
|
||||
|
||||
## 远端仓库与推送(gitea.4i.hk)
|
||||
- 远端:**https://gitea.4i.hk/hhhge/SimpleFarm**(public,默认分支 `main`,owner `hhhge`),
|
||||
本地 `origin` 已指向 `https://gitea.4i.hk/hhhge/SimpleFarm.git` 并设好 upstream。
|
||||
- **推送认证走 GCM 的 generic provider**(`credential.https://gitea.4i.hk.provider=generic`
|
||||
写在 `~/.gitconfig` 里)。关键:**非交互 shell 里必须带 `GCM_INTERACTIVE=always`**,
|
||||
否则 GCM 直接 `Cannot prompt because user interactivity has been disabled`,
|
||||
接着 git 报 `could not read Username / Failed to authenticate user`,看起来像认证配置坏了。
|
||||
加上这个环境变量后 GCM 会静默用已有的 OAuth 凭据续期,不需要弹窗也不需要手输:
|
||||
`GCM_INTERACTIVE=always git push -u origin main`
|
||||
- `tea` CLI(0.16.0)已用 OAuth 登录该实例,配置在 `%LOCALAPPDATA%\tea\config.yml`
|
||||
(token 存在系统 keyring,**不在配置文件里**)。可用来建仓与调 API:
|
||||
`tea repos create --name X --login gitea.4i.hk`(**没有 `--public` 参数,默认就是公开**)、
|
||||
`tea api <path> -l gitea.4i.hk [-X POST -d '<json>']`。
|
||||
限制:**OAuth token 不能签发访问令牌**(`POST /users/{u}/tokens` 需要 basic auth,
|
||||
会返回 401 `auth required`,这是 Gitea 防提权的设计);但**可以用它加 SSH 公钥**
|
||||
(`POST /user/keys`),所以将来若要免密推送,加一把密钥是可行的。
|
||||
- 本机 git 走 `http.proxy / https.proxy = http://127.0.0.1:7890`(写在 `~/.gitconfig`)。
|
||||
- **沙箱怪癖**:`.git/refs/remotes/**` 里"新建目录"的写入跨命令调用会被丢弃,
|
||||
于是 `git status -sb` 一直显示 `## main...origin/main [gone]`(远端其实好着呢)。
|
||||
修法是**在同一个命令里**先 `mkdir -p .git/refs/remotes/origin` + 写入 ref 文件,
|
||||
紧接着跑 `git status`——分开两条命令就又看不到。真实终端里直接 `git fetch` 即可。
|
||||
|
||||
## 相关文档
|
||||
- 项目入口文档:`README.md`(接口表、SSE 协议、边界处理、设计取舍全在里面)
|
||||
- 当天工作记录:`.workbuddy/memory/2026-09-19.md`
|
||||
## 文档
|
||||
README.md 是入口文档(接口表、SSE 协议、设计取舍);当天记录在
|
||||
.workbuddy/memory/YYYY-MM-DD.md。
|
||||
@@ -64,6 +64,39 @@ uv run main.py serve --port 8799 --tick 3 --save-dir .e2e-saves
|
||||
动作回执带新字段、错误码符合预期(如 `nothing_to_sell` / `locked` / `unknown_building`)。
|
||||
错误码去 `farm/engine.py` 里核对,别猜。
|
||||
|
||||
**动了存档/会话层必须加验一条**:只走 HTTP(不连 SSE、0 客户端在线)做一个动作,
|
||||
等 60 秒自动存档周期过去,直接查 `saves/farm.db` 那一行有没有更新。
|
||||
单元测试抓不住 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()`。
|
||||
|
||||
Canvas / rAF 动画类改动的额外坑(2026-09-25 踩过):动画循环的**第一帧是同步
|
||||
执行**的——必须先跑完布局再绑定循环,否则首帧 TypeError 会把
|
||||
requestAnimationFrame 的注册一起吞掉,表现为「画布全白但点击命中正常」
|
||||
(命中是纯数学,不经过画布)。且 headless 截图空白 ≠ 没画:先
|
||||
`ctx.getImageData` 读像素确认「画了没」,再放 rAF 计数探针 / 记录动画对象
|
||||
位移确认「循环活着没」,两连才能定位。
|
||||
|
||||
## 第 3 步:写记忆
|
||||
|
||||
- 当天日志追加到 `.workbuddy/memory/YYYY-MM-DD.md`(append-only)。
|
||||
@@ -78,20 +111,39 @@ uv run main.py serve --port 8799 --tick 3 --save-dir .e2e-saves
|
||||
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` 等清场再试。
|
||||
|
||||
## 别踩的坑(本机特有)
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ uv run main.py stop # ⑤ 优雅停机(会把所有牧场
|
||||
```
|
||||
|
||||
浏览器打开 <http://127.0.0.1:8765/> 就是网页版客户端(可多开几个标签页,用不同牧场名,
|
||||
能实时看到对方的操作)。
|
||||
能实时看到对方的操作)。页面顶部是 **Canvas 二维农场地图**:农田方阵、牧场围栏、
|
||||
建筑带画在一张俯视图上,作物随生长进度变大、成熟发光,雨/旱/暴风雨有对应天气效果;
|
||||
点地块直接播种/浇水/收获,点牲畜喂食/收取(下方的卡片面板保留,作精确操作用)。
|
||||
|
||||
不想装任何第三方库、只想单机玩玩:
|
||||
|
||||
@@ -56,14 +58,14 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
|
||||
| 系统 | 规则 |
|
||||
| --- | --- |
|
||||
| **时间** | 每个牧场每 **40 分钟**自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 |
|
||||
| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获、收取、卖货、建造都免费。用完只能等第二天——时间只能自然流动,没有手动快进 |
|
||||
| **节奏** | 动作**没有**行动点/体力限制,想种就种——稀缺的只有时间本身:一天就是 40 分钟,只能自然流动,没有手动快进 |
|
||||
| **天气** | 每天从晴 / 多云 / 雨 / 干旱 / 暴风雨里抽一种(同种不连着出),而且**明天的天气提前可见**——「今晚浇不浇」因此是个能算的决策 |
|
||||
| **水分** | 每天按天气消耗:晴 1、多云 0、干旱 2,雨天反补 3(上限 3);水分为 0 就停止生长。播种自带 2 点,只够晴天撑两天,所以下种前得先看预报 |
|
||||
| **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 |
|
||||
| **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** |
|
||||
| **谷仓** | 收获和收取**只入库、不当场给钱**(容量 40 起,随等级与谷仓建筑增长);仓满时收不进去,作物原样留在地里,卖了再来 |
|
||||
| **市场** | 价格每天在基础价 ±18% 内波动;**靠 `sell` 择时变现**,高位卖才真的赚 |
|
||||
| **建筑** | 谷仓(+25 仓容,最多 5 座)/ 水井(每天自动浇一块干透的地)/ 自动喂食器(每天自动喂一只,只吃库存饲料、绝不自动花钱)/ 温室(免疫暴风雨、水分蒸发减半);每多一座涨价 60% |
|
||||
| **建筑** | 谷仓(+25 仓容,最多 5 座)/ 水井(每天自动浇一块干透的地)/ 饲料磨坊(每天 1 份作物磨 3 份饲料,挑最便宜的)/ 自动喂食器(每天自动喂一只,只吃库存饲料)/ 自动收割机(每天自动收 2 块熟地入库,不给经验,可叠 3 座)/ 自动售货机(市价高于基准价时自动卖出,每天上限 5 份)/ 喷灌系统(所有缺水地块一次浇满)/ 温室(免疫暴风雨、水分蒸发减半);每多一座涨价 60%。自动化的共同底线:**只替你干活的动作,绝不自动花钱** |
|
||||
| **土地** | 初始 6 块,每升 1 级 +2 块,上限 16 块,买地价格指数递增 |
|
||||
| **目标** | 净资产(金币+土地+牲畜+库存+地里作物)做到 10000 金币,共 5 个里程碑 |
|
||||
| **丰收** | 收获有 12% 概率触发双倍产出 |
|
||||
@@ -71,7 +73,7 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
|
||||
净资产在 TUI 顶部实时显示,`goal_index` 表示已解锁的里程碑数。
|
||||
|
||||
> **关于节奏**:这是一款“放置型”农场,一天 40 分钟是刻意设计的,不是笔误。
|
||||
> 每天 10 点行动点,用完就是等时间流动——所以一天的玩法是“上来干十件事,然后离开”。
|
||||
> 动作没有体力限制,但作物、牲畜、市场的进展都随换天推进——所以一天的玩法是“上来把事干完,然后离开”。
|
||||
> 小麦 2 天(80 分钟)成熟,草莓 10 天(约 6.7 小时);断水会停长,所以实际天数看天吃饭——
|
||||
> 好在预报提前一天就给你,来得及浇。想调试就把节奏调快:
|
||||
> `uv run main.py play --tick 3`(一天 3 秒)。
|
||||
@@ -123,18 +125,18 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
|
||||
|
||||
```bash
|
||||
$ uv run main.py actions
|
||||
牧场 default · 第 3 天 · 金币 96 · 行动点 7/10
|
||||
牧场 default · 第 3 天 · 金币 96
|
||||
共 9 个候选动作:
|
||||
|
||||
[ 1] harvest {"plot": 2} (免费)
|
||||
2 号地的胡萝卜已成熟
|
||||
[ 2] plant {"plot": 4, "crop": "wheat"} (gold:5 ap:1)
|
||||
[ 2] plant {"plot": 4, "crop": "wheat"} (gold:5)
|
||||
4 号地空闲,可种小麦
|
||||
...
|
||||
|
||||
$ uv run main.py do harvest '{"plot":2}'
|
||||
✓ 收获 2 份胡萝卜(丰收!),获得 104 金币 (0.01s)
|
||||
第 3 天 · 金币 200 · 行动点 7/10 · 净资产 512.0
|
||||
第 3 天 · 金币 200 · 净资产 512.0
|
||||
```
|
||||
|
||||
---
|
||||
@@ -146,6 +148,8 @@ $ uv run main.py do harvest '{"plot":2}'
|
||||
|
||||
**多牧场**:用 `?farm=名字` 或请求头 `X-Farm-Id` 指定;不指定就是 `default`。
|
||||
牧场不存在时会**自动创建**,所以每个客户端只需挑一个名字。
|
||||
**网页端用用户名登录**(`POST /api/login`,Cookie 绑定同名牧场):登录后 `?farm=`
|
||||
被忽略、只能操作自己的牧场;脚本/AI 不带 Cookie 时保持旧语义,不受影响。
|
||||
|
||||
### 5.1 最短决策闭环
|
||||
|
||||
@@ -164,6 +168,9 @@ POST /api/actions → {"action":"<上一步的 action>","params":{.
|
||||
| GET | `/api/world` | 世界概览:排行榜、在线连接、时钟 |
|
||||
| GET | `/api/farms` | 牧场列表(按净资产排名) |
|
||||
| POST | `/api/farms` | `{"farm":"名字","owner":"昵称","tick":10}` 创建/进入 |
|
||||
| POST | `/api/login` | `{"username":"名字"}` 用户名登录,设置 HttpOnly Cookie |
|
||||
| POST | `/api/logout` | 退出登录(清除 Cookie) |
|
||||
| GET | `/api/me` | 当前登录用户;未登录为 `null`(无需令牌) |
|
||||
| GET | `/api/state` | 完整状态快照 |
|
||||
| GET | `/api/catalog` | 作物 / 牲畜 / 商品与实时价格 / 建筑 / 天气表 / 仓容 |
|
||||
| GET | `/api/actions` | 当前合法动作(AI 推荐入口) |
|
||||
@@ -182,7 +189,7 @@ POST /api/actions → {"action":"<上一步的 action>","params":{.
|
||||
plant(plot,crop) water(plot) harvest(plot) harvest_all()
|
||||
buy_animal(kind) feed(animal_id,quantity?) collect(animal_id)
|
||||
buy_feed(quantity?) buy_plot() pause(paused?)
|
||||
sell(key?,quantity?) build(key) ← 谷仓变现与建造,0 行动点
|
||||
sell(key?,quantity?) build(key) ← 谷仓变现与建造
|
||||
```
|
||||
|
||||
> `save` / `reset` 是**存档类动作**,不进通用动作表:它们必须由牧场会话处理,
|
||||
@@ -208,7 +215,7 @@ curl -X POST 'http://127.0.0.1:8765/api/batch?farm=alice' \
|
||||
{"action":"buy_feed","params":{"quantity":10}},
|
||||
{"action":"plant","params":{"plot":1,"crop":"carrot"}}]}'
|
||||
|
||||
# 收成变现 / 造建筑:都是 0 行动点,行动点用完了照样能做
|
||||
# 收成变现 / 造建筑
|
||||
curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \
|
||||
-H 'Content-Type: application/json' -d '{"action":"sell"}'
|
||||
curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \
|
||||
@@ -325,6 +332,10 @@ SSE 长连接在两种协议下都照常工作。
|
||||
## 7. 多人模型
|
||||
|
||||
* **一个牧场 = 一个玩家**:独立引擎、独立时钟,存档在 `saves/farm.db`(SQLite,**一个牧场一行**)。
|
||||
* **用户名登录**:网页首屏先登录(输入用户名即可,第一次用会自动开同名新牧场)。
|
||||
Cookie 是 HMAC 签名的 HttpOnly,30 天有效,密钥在 `saves/secret.key`——重启不掉线。
|
||||
登录后改 URL 也进不了别人的牧场;`curl`/脚本不带 Cookie 时保持 `?farm=` 旧语义
|
||||
(互信小圈子模型:用户名即身份,不设密码,防的是"随手改 URL"而不是蓄意攻击)。
|
||||
* 牧场之间完全隔离(金币、土地、市场各自独立),但共享排行榜(`GET /api/farms`)。
|
||||
* 同牧场可以有多人/多程序同时操作,引擎内的 `RLock` 保证串行,不会出现"两个人同时种同一块地"这类竞态。
|
||||
* 所有牧场的时钟由**一个** `FarmClock` 线程统一驱动(而不是每个牧场一条线程)。
|
||||
@@ -470,7 +481,7 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是
|
||||
* 未知动作 → `400 unknown_action`,并列出可用动作;
|
||||
* 缺必需参数 → `missing_param`;多传参数 → `bad_param`(明确拒绝而不是静默忽略);
|
||||
* 类型错误 → `bad_param`,但宽容地把 `"3"` 这类数字字符串转成整数(命令行/表单常见);
|
||||
* 越界地块 → `no_such_plot`;未解锁作物 → `locked`;金币/行动点不足 → `not_enough_gold` / `no_ap`;
|
||||
* 越界地块 → `no_such_plot`;未解锁作物 → `locked`;金币不足 → `not_enough_gold`;
|
||||
* 牧场名会被用作文件名 → 白名单校验,挡掉 `../`、`a/b` 之类的路径穿越;
|
||||
* 请求体上限 64KB、批量动作上限 50、`limit` 参数自动夹取到合法区间。
|
||||
|
||||
|
||||
+3
-14
@@ -13,8 +13,8 @@
|
||||
它只依赖 :mod:`farm.client`,因此既能在本机跑,也能从别处连上服务器跑——
|
||||
换个 ``--url`` 就是一个独立的外挂玩家。
|
||||
|
||||
节奏完全由服务器时钟决定。服务器**没有**"手动推进一天"的接口,所以行动点
|
||||
用完之后它只能等时间流动(或收工)::meth:`RuleBot.run` 的 ``wait_seconds``
|
||||
节奏完全由服务器时钟决定。服务器**没有**"手动推进一天"的接口,没事可做时
|
||||
它只能等时间流动(或收工)::meth:`RuleBot.run` 的 ``wait_seconds``
|
||||
就是"最多等多久",默认 0——一天 40 分钟,干等不划算。
|
||||
"""
|
||||
|
||||
@@ -69,7 +69,6 @@ class RuleBot:
|
||||
params = action.get("params") or {}
|
||||
gold = state.get("gold", 0)
|
||||
feed = state.get("feed", 0)
|
||||
ap = state.get("ap", 0)
|
||||
|
||||
if name == "harvest":
|
||||
return 100.0
|
||||
@@ -101,7 +100,7 @@ class RuleBot:
|
||||
(a for a in catalog.get("animals", []) if a["key"] == params.get("kind")), None
|
||||
)
|
||||
cost = info["cost"] if info else 0
|
||||
if cost and gold >= cost * 3 and ap >= 1:
|
||||
if cost and gold >= cost * 3:
|
||||
return 30.0 + (info.get("profit_per_day", 0) if info else 0)
|
||||
return 3.0
|
||||
if name == "sell":
|
||||
@@ -213,16 +212,6 @@ class RuleBot:
|
||||
self.say("到时间了,收工。")
|
||||
break
|
||||
|
||||
state = self.client.snapshot()
|
||||
if state.get("ap", 0) <= 0:
|
||||
# 行动点用完 ≠ 没事可做:sell / build / harvest / collect / buy_*
|
||||
# 都是 0 消耗,而 available_actions 本身就按行动点筛过一遍了,
|
||||
# 所以照常挑一次最优的;真的没得选才等下一天。
|
||||
if self.step() is None and not self.wait_for_next_day(wait_seconds):
|
||||
break
|
||||
idle = 0
|
||||
continue
|
||||
|
||||
did = self.step()
|
||||
if did is None:
|
||||
idle += 1
|
||||
|
||||
@@ -218,10 +218,6 @@ class StateView:
|
||||
def exp(self) -> int:
|
||||
return int(self._d.get("exp", 0))
|
||||
|
||||
@property
|
||||
def ap(self) -> int:
|
||||
return int(self._d.get("ap", 0))
|
||||
|
||||
@property
|
||||
def feed(self) -> int:
|
||||
return int(self._d.get("feed", 0))
|
||||
@@ -315,7 +311,6 @@ class RemoteFarm:
|
||||
"granary": {},
|
||||
"feed_price": config.FEED_UNIT_PRICE,
|
||||
"water_capacity": config.WATER_CAPACITY,
|
||||
"ap_cost": config.AP_COST,
|
||||
}
|
||||
self._log: deque[dict[str, Any]] = deque(maxlen=LOG_KEEP)
|
||||
self._log_seq = 0
|
||||
|
||||
+32
-15
@@ -14,7 +14,6 @@ from typing import Any
|
||||
|
||||
START_GOLD = 120 # 初始金币
|
||||
START_LEVEL = 1 # 初始等级
|
||||
START_AP = 10 # 每天行动点上限
|
||||
START_FEED = 5 # 初始饲料库存
|
||||
|
||||
BASE_PLOTS = 6 # 初始土地数量
|
||||
@@ -36,20 +35,6 @@ MAX_FEED_PURCHASE = 200 # 单次购买饲料的数量上限
|
||||
# 每级提供的土地上限(基础 6 + 2/级,封顶 MAX_PLOTS)
|
||||
PLOTS_PER_LEVEL = 2
|
||||
|
||||
# 行动点消耗表
|
||||
AP_COST = {
|
||||
"plant": 1,
|
||||
"water": 1,
|
||||
"harvest": 0,
|
||||
"feed": 1,
|
||||
"collect": 0,
|
||||
"buy_animal": 0,
|
||||
"buy_plot": 0,
|
||||
"buy_feed": 0,
|
||||
"build": 0,
|
||||
"sell": 0,
|
||||
}
|
||||
|
||||
|
||||
# 经验曲线:升到 level+1 所需经验
|
||||
def exp_to_next(level: int) -> int:
|
||||
@@ -289,6 +274,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 +307,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 座的价格系数
|
||||
|
||||
+122
-47
@@ -129,8 +129,6 @@ class FarmEngine:
|
||||
"level": state.level,
|
||||
"exp": state.exp,
|
||||
"exp_needed": state.exp_needed,
|
||||
"ap": state.ap,
|
||||
"max_ap": state.max_ap,
|
||||
"feed": state.feed,
|
||||
"plot_count": len(state.plots),
|
||||
"max_plots": state.max_plots,
|
||||
@@ -216,7 +214,6 @@ class FarmEngine:
|
||||
},
|
||||
"feed_price": config.FEED_UNIT_PRICE,
|
||||
"water_capacity": config.WATER_CAPACITY,
|
||||
"ap_cost": config.AP_COST,
|
||||
}
|
||||
|
||||
# ---- catalog 的三个子表。拆开是因为它们的"变化频率"各不相同:
|
||||
@@ -299,7 +296,7 @@ class FarmEngine:
|
||||
"""列出当前**合法**的动作,供 AI 直接决策使用。
|
||||
|
||||
每个动作包含:``action``(接口名)、``params``(参数模板)、
|
||||
``cost``(金币/行动点开销)、``reason``(为什么值得做)。
|
||||
``cost``(金币开销)、``reason``(为什么值得做)。
|
||||
"""
|
||||
with self._lock:
|
||||
state = self.state
|
||||
@@ -314,15 +311,12 @@ class FarmEngine:
|
||||
# ---- available_actions 的三个分区;拆开是为了各自能独立阅读/测试
|
||||
def _plot_actions(self, state: GameState) -> list[dict[str, Any]]:
|
||||
"""每块地当前能做的事:播种 / 收获 / 浇水。"""
|
||||
# 能种什么只取决于等级、金币和行动点,与**具体是哪块地**无关,
|
||||
# 能种什么只取决于等级和金币,与**具体是哪块地**无关,
|
||||
# 所以整趟只筛一次,不要放进地块循环里做 O(地块 × 作物) 次判断。
|
||||
plant_ap = config.AP_COST["plant"]
|
||||
plantable = [
|
||||
(key, info)
|
||||
for key, info in config.CROPS.items()
|
||||
if not self._is_locked(info)
|
||||
and state.gold >= info["seed_cost"]
|
||||
and state.ap >= plant_ap
|
||||
if not self._is_locked(info) and state.gold >= info["seed_cost"]
|
||||
]
|
||||
actions: list[dict[str, Any]] = []
|
||||
for plot in state.plots:
|
||||
@@ -333,7 +327,7 @@ class FarmEngine:
|
||||
{
|
||||
"action": "plant",
|
||||
"params": {"plot": position, "crop": key},
|
||||
"cost": {"gold": info["seed_cost"], "ap": plant_ap},
|
||||
"cost": {"gold": info["seed_cost"]},
|
||||
"reason": f"{position} 号地空闲,可种{info['name']}",
|
||||
}
|
||||
)
|
||||
@@ -342,11 +336,11 @@ class FarmEngine:
|
||||
{
|
||||
"action": "harvest",
|
||||
"params": {"plot": position},
|
||||
"cost": {"gold": 0, "ap": config.AP_COST["harvest"]},
|
||||
"cost": {"gold": 0},
|
||||
"reason": f"{position} 号地的{config.CROPS[plot.crop]['name']}已成熟",
|
||||
}
|
||||
)
|
||||
elif self._needs_water(plot) and state.ap >= config.AP_COST["water"]:
|
||||
elif self._needs_water(plot):
|
||||
reason = (
|
||||
f"{position} 号地缺水,作物已停止生长"
|
||||
if plot.moisture <= 0
|
||||
@@ -356,7 +350,7 @@ class FarmEngine:
|
||||
{
|
||||
"action": "water",
|
||||
"params": {"plot": position},
|
||||
"cost": {"gold": 0, "ap": config.AP_COST["water"]},
|
||||
"cost": {"gold": 0},
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
@@ -371,19 +365,19 @@ class FarmEngine:
|
||||
{
|
||||
"action": "collect",
|
||||
"params": {"animal_id": animal.aid},
|
||||
"cost": {"gold": 0, "ap": config.AP_COST["collect"]},
|
||||
"cost": {"gold": 0},
|
||||
"reason": f"{animal.name}有 {animal.pending} 份{animal.product}待收取",
|
||||
}
|
||||
)
|
||||
elif not animal.fed:
|
||||
need = animal.info["feed_per_day"]
|
||||
cost = 0 if state.feed >= need else need * config.FEED_EMERGENCY_PRICE
|
||||
if state.gold >= cost and state.ap >= config.AP_COST["feed"]:
|
||||
if state.gold >= cost:
|
||||
actions.append(
|
||||
{
|
||||
"action": "feed",
|
||||
"params": {"animal_id": animal.aid},
|
||||
"cost": {"gold": cost, "ap": config.AP_COST["feed"]},
|
||||
"cost": {"gold": cost},
|
||||
"reason": f"{animal.name}(#{animal.aid})今天还没喂食",
|
||||
}
|
||||
)
|
||||
@@ -398,7 +392,7 @@ class FarmEngine:
|
||||
{
|
||||
"action": "buy_plot",
|
||||
"params": {},
|
||||
"cost": {"gold": price, "ap": config.AP_COST["buy_plot"]},
|
||||
"cost": {"gold": price},
|
||||
"reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地",
|
||||
}
|
||||
)
|
||||
@@ -408,7 +402,7 @@ class FarmEngine:
|
||||
{
|
||||
"action": "buy_feed",
|
||||
"params": {"quantity": config.FEED_BULK},
|
||||
"cost": {"gold": bulk_cost, "ap": config.AP_COST["buy_feed"]},
|
||||
"cost": {"gold": bulk_cost},
|
||||
"reason": "饲料库存偏低,建议补货",
|
||||
}
|
||||
)
|
||||
@@ -421,7 +415,7 @@ class FarmEngine:
|
||||
{
|
||||
"action": "buy_animal",
|
||||
"params": {"kind": key},
|
||||
"cost": {"gold": info["cost"], "ap": config.AP_COST["buy_animal"]},
|
||||
"cost": {"gold": info["cost"]},
|
||||
"reason": f"可购买{info['name']}({info['cost']} 金币)",
|
||||
}
|
||||
)
|
||||
@@ -457,7 +451,7 @@ class FarmEngine:
|
||||
{
|
||||
"action": "sell",
|
||||
"params": {"key": key, "quantity": count},
|
||||
"cost": {"gold": 0, "ap": config.AP_COST["sell"]},
|
||||
"cost": {"gold": 0},
|
||||
"reason": ";".join(reasons),
|
||||
}
|
||||
)
|
||||
@@ -474,7 +468,7 @@ class FarmEngine:
|
||||
{
|
||||
"action": "build",
|
||||
"params": {"key": key},
|
||||
"cost": {"gold": entry["next_cost"], "ap": config.AP_COST["build"]},
|
||||
"cost": {"gold": entry["next_cost"]},
|
||||
"reason": f"可造第 {entry['owned'] + 1} 座{entry['name']}:{entry['effect']}",
|
||||
}
|
||||
)
|
||||
@@ -531,17 +525,6 @@ class FarmEngine:
|
||||
return animal
|
||||
raise GameError("no_such_animal", f"没有编号为 {aid} 的动物")
|
||||
|
||||
def _spend_ap(self, action: str) -> None:
|
||||
cost = config.AP_COST.get(action, 0)
|
||||
if cost and self.state.ap < cost:
|
||||
raise GameError(
|
||||
"no_ap",
|
||||
f"行动点不足:{action} 需要 {cost} 点,当前只剩 {self.state.ap} 点。"
|
||||
f"等一天的时间自然走完就会恢复。",
|
||||
)
|
||||
if cost:
|
||||
self.state.ap -= cost
|
||||
|
||||
def _spend_gold(self, amount: int, what: str) -> None:
|
||||
amount = int(amount)
|
||||
if amount < 0:
|
||||
@@ -639,7 +622,6 @@ class FarmEngine:
|
||||
)
|
||||
|
||||
self._spend_gold(info["seed_cost"], f"购买{info['name']}种子")
|
||||
self._spend_ap("plant")
|
||||
|
||||
plot.crop = crop
|
||||
plot.growth = 0.0
|
||||
@@ -666,7 +648,6 @@ class FarmEngine:
|
||||
"already_watered",
|
||||
f"{plot.index + 1} 号地水分已满({plot.moisture}/{config.WATER_CAPACITY})",
|
||||
)
|
||||
self._spend_ap("water")
|
||||
plot.moisture = config.WATER_CAPACITY
|
||||
self.add_log(f"给 {plot.index + 1} 号地浇水(水分 {config.WATER_CAPACITY})", "action")
|
||||
self._notify()
|
||||
@@ -684,7 +665,6 @@ class FarmEngine:
|
||||
)
|
||||
crop_key = plot.crop
|
||||
info = config.CROPS[crop_key]
|
||||
self._spend_ap("harvest")
|
||||
|
||||
quantity = self._roll_yield()
|
||||
# 先入库再清地:谷仓满了会在这里抛 granary_full,作物原地不动,
|
||||
@@ -729,7 +709,6 @@ class FarmEngine:
|
||||
raise GameError("pasture_full", f"牧场已满(上限 {config.MAX_ANIMALS} 只)")
|
||||
|
||||
self._spend_gold(info["cost"], f"购买{info['name']}")
|
||||
self._spend_ap("buy_animal")
|
||||
|
||||
animal = Animal(kind)
|
||||
self.state.animals.append(animal)
|
||||
@@ -766,7 +745,6 @@ class FarmEngine:
|
||||
self.state.feed += shortfall
|
||||
self.add_log(f"饲料不足,临时高价补货 {shortfall} 份(-{cost} 金币)", "warn")
|
||||
|
||||
self._spend_ap("feed")
|
||||
self.state.feed -= quantity
|
||||
animal.fed = True
|
||||
animal.starve = 0
|
||||
@@ -788,7 +766,6 @@ class FarmEngine:
|
||||
f"{animal.name} #{animal.aid} 暂时没有可收取的{animal.product}"
|
||||
f"(还需 {animal.days_to_product} 天)",
|
||||
)
|
||||
self._spend_ap("collect")
|
||||
quantity = animal.pending
|
||||
key = f"animal:{animal.kind}"
|
||||
# 与收获同理:先确认装得下,再清空待收,免得产出凭空消失
|
||||
@@ -850,7 +827,6 @@ class FarmEngine:
|
||||
f"土地已达当前等级上限({self.state.max_plots} 块),升级后可继续开垦",
|
||||
)
|
||||
self._spend_gold(price, "开垦新土地")
|
||||
self._spend_ap("buy_plot")
|
||||
plot = Plot(len(self.state.plots))
|
||||
self.state.plots.append(plot)
|
||||
self.state.plots_bought += 1
|
||||
@@ -971,7 +947,6 @@ class FarmEngine:
|
||||
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(
|
||||
@@ -1066,13 +1041,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")
|
||||
@@ -1145,7 +1221,6 @@ class FarmEngine:
|
||||
state = self.state
|
||||
info = self._resolve_weather(state, weather)
|
||||
state.day += 1
|
||||
state.ap = state.max_ap
|
||||
events: list[str] = []
|
||||
|
||||
# ---- 建筑自动化。必须排在生长结算之前:水井救的正是"今天就断水"的
|
||||
@@ -1202,7 +1277,7 @@ class FarmEngine:
|
||||
self.add_log(text, "warn")
|
||||
today = f"第 {state.day} 天开始({reason}),今日{info['name']}"
|
||||
if events:
|
||||
self.add_log(f"{today},行动点已恢复", "info")
|
||||
self.add_log(f"{today},新的一天开始了", "info")
|
||||
else:
|
||||
self.add_log(f"{today},一切安好", "info")
|
||||
|
||||
|
||||
+34
-25
@@ -80,9 +80,13 @@ class Plot:
|
||||
@property
|
||||
def stage(self) -> str:
|
||||
"""人类可读的生长阶段。"""
|
||||
return self._stage(self.grow_days)
|
||||
|
||||
def _stage(self, grow: int) -> str:
|
||||
"""stage 的实现体。to_dict 已算好 grow_days,走这里避免重复求值。"""
|
||||
if self.crop is None:
|
||||
return "空地"
|
||||
progress = self.growth / max(1, self.grow_days)
|
||||
progress = self.growth / max(1, grow)
|
||||
name = "已播种"
|
||||
for threshold, label in config.GROWTH_STAGES:
|
||||
if progress >= threshold:
|
||||
@@ -91,20 +95,24 @@ class Plot:
|
||||
|
||||
# ---------------------------------------------------------------- 序列化
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
# 热路径(每次快照对每块地调一次):grow_days 原来经 4 个属性各求值
|
||||
# 一遍(本处 + days_left + is_ripe + stage),这里算一次局部复用。
|
||||
# 规则本体仍以同名属性为唯一来源,此处只是等价的直接计算。
|
||||
info = config.CROPS.get(self.crop) if self.crop else None
|
||||
grow = int(info["grow_days"]) if info else 0
|
||||
return {
|
||||
"index": self.index,
|
||||
"position": self.index + 1,
|
||||
"crop": self.crop,
|
||||
"crop_name": info["name"] if info else None,
|
||||
"growth": round(self.growth, 2),
|
||||
"grow_days": self.grow_days,
|
||||
"days_left": self.days_left,
|
||||
"is_ripe": self.is_ripe,
|
||||
"is_empty": self.is_empty,
|
||||
"grow_days": grow,
|
||||
"days_left": max(0, grow - int(self.growth)) if info else 0,
|
||||
"is_ripe": info is not None and self.growth >= grow,
|
||||
"is_empty": self.crop is None,
|
||||
"moisture": self.moisture,
|
||||
"water_capacity": config.WATER_CAPACITY,
|
||||
"stage": self.stage,
|
||||
"stage": self._stage(grow),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -162,18 +170,22 @@ class Animal:
|
||||
|
||||
# ---------------------------------------------------------------- 序列化
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
# 热路径:info 原来经 name/product/cycle/days_to_product 查 4 次配置表,
|
||||
# 这里取一次局部复用;is_sick 与 days_to_product 的规则本体仍在属性里。
|
||||
info = config.ANIMALS[self.kind]
|
||||
cycle = int(info["cycle"])
|
||||
return {
|
||||
"id": self.aid,
|
||||
"kind": self.kind,
|
||||
"name": self.name,
|
||||
"product": self.product,
|
||||
"name": info["name"],
|
||||
"product": info["product"],
|
||||
"fed": self.fed,
|
||||
"progress": self.progress,
|
||||
"cycle": self.cycle,
|
||||
"days_to_product": self.days_to_product,
|
||||
"cycle": cycle,
|
||||
"days_to_product": max(0, cycle - self.progress),
|
||||
"pending": self.pending,
|
||||
"starve": self.starve,
|
||||
"is_sick": self.is_sick,
|
||||
"is_sick": self.starve >= 2,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -199,7 +211,6 @@ class GameState:
|
||||
self.gold: int = config.START_GOLD
|
||||
self.level: int = config.START_LEVEL
|
||||
self.exp: int = 0
|
||||
self.ap: int = config.START_AP
|
||||
self.feed: int = config.START_FEED
|
||||
self.plots: list[Plot] = [Plot(i) for i in range(config.BASE_PLOTS)]
|
||||
self.animals: list[Animal] = []
|
||||
@@ -231,10 +242,6 @@ class GameState:
|
||||
self.forecast: str = config.DEFAULT_WEATHER
|
||||
|
||||
# ---------------------------------------------------------------- 派生属性
|
||||
@property
|
||||
def max_ap(self) -> int:
|
||||
return config.START_AP
|
||||
|
||||
@property
|
||||
def max_plots(self) -> int:
|
||||
"""当前等级允许持有的土地上限。"""
|
||||
@@ -316,24 +323,27 @@ class GameState:
|
||||
# 所以只算一次再分发给 net_worth / goal_index / next_goal。
|
||||
worth = self.net_worth
|
||||
goal = config.goal_index(worth)
|
||||
winfo = config.weather_info(self.weather)
|
||||
finfo = config.weather_info(self.forecast)
|
||||
# granary_free 原来会把 granary_capacity 和 storage_used 各重算一遍
|
||||
cap = self.granary_capacity
|
||||
used = self.storage_used
|
||||
return {
|
||||
"day": self.day,
|
||||
"weather": self.weather,
|
||||
"weather_name": config.weather_info(self.weather)["name"],
|
||||
"weather_desc": config.weather_info(self.weather)["desc"],
|
||||
"weather_name": winfo["name"],
|
||||
"weather_desc": winfo["desc"],
|
||||
"forecast": self.forecast,
|
||||
"forecast_name": config.weather_info(self.forecast)["name"],
|
||||
"forecast_name": finfo["name"],
|
||||
"gold": self.gold,
|
||||
"level": self.level,
|
||||
"exp": self.exp,
|
||||
"exp_needed": self.exp_needed,
|
||||
"ap": self.ap,
|
||||
"max_ap": self.max_ap,
|
||||
"feed": self.feed,
|
||||
"inventory": dict(sorted(self.inventory.items())),
|
||||
"storage_used": self.storage_used,
|
||||
"granary_capacity": self.granary_capacity,
|
||||
"granary_free": self.granary_free,
|
||||
"storage_used": used,
|
||||
"granary_capacity": cap,
|
||||
"granary_free": max(0, cap - used),
|
||||
"buildings": dict(self.buildings),
|
||||
"plots": [p.to_dict() for p in self.plots],
|
||||
"plot_count": len(self.plots),
|
||||
@@ -362,7 +372,6 @@ class GameState:
|
||||
state.level = max(1, int(data.get("level", 1)))
|
||||
state.exp = max(0, int(data.get("exp", 0)))
|
||||
state.feed = max(0, int(data.get("feed", 0)))
|
||||
state.ap = int(clamp(int(data.get("ap", state.max_ap)), 0, state.max_ap))
|
||||
|
||||
plots = [Plot.from_dict(p) for p in data.get("plots", []) if isinstance(p, dict)]
|
||||
# 保证至少有初始土地,且不超过硬上限
|
||||
|
||||
+61
-12
@@ -6,7 +6,8 @@
|
||||
与人类可读的 ``message``,AI 可以直接拿 ``error`` 分支决策。
|
||||
2. **状态紧随结果**:每个写操作都会在响应里附一份精简状态(``state``),
|
||||
这样 AI 一次请求就能同时"做动作 + 看结果",省一半往返。
|
||||
3. **多牧场隔离**:用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家,
|
||||
3. **多牧场隔离**:网页用用户名登录(Cookie),登录后只能操作自己的牧场;
|
||||
脚本/AI 用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家,
|
||||
每个牧场有自己的引擎、时钟与存档;互不影响。
|
||||
4. **实时通道**:``GET /api/stream`` 是 SSE 长连接,只负责**服务器 → 客户端**
|
||||
的推送(状态、跨天、有人来串门);写操作统一走 ``POST /api/actions``。
|
||||
@@ -23,13 +24,14 @@ from collections.abc import AsyncIterator, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
|
||||
|
||||
from .. import config
|
||||
from ..engine import GameError
|
||||
from ..storage import StorageError
|
||||
from . import actions as actions_mod
|
||||
from .auth import COOKIE_NAME, TTL_SECONDS, LoginManager
|
||||
from .hub import (
|
||||
CLOSE_MESSAGE,
|
||||
HEARTBEAT_SECONDS,
|
||||
@@ -113,8 +115,6 @@ BRIEF_KEYS = (
|
||||
"level",
|
||||
"exp",
|
||||
"exp_needed",
|
||||
"ap",
|
||||
"max_ap",
|
||||
"feed",
|
||||
"plot_count",
|
||||
"max_plots",
|
||||
@@ -156,8 +156,8 @@ def rules_text() -> str:
|
||||
"==========================================\n"
|
||||
f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS / 60:.0f} 分钟"
|
||||
f"({config.DEFAULT_TICK_SECONDS:.0f} 秒)自动推进一天,可配置。"
|
||||
f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n"
|
||||
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天——"
|
||||
f"作物生长、牲畜产出、市场价格都在此刻结算。\n"
|
||||
"2. 节奏:动作没有行动点/体力限制,想种就种——稀缺的只有时间本身。"
|
||||
"时间只能自然流动,没有任何「手动推进一天」的接口,别白费请求。\n"
|
||||
f"3. 天气与水分:每天随机一种天气(晴/多云/雨/干旱/暴风雨)。作物每天按天气蒸发"
|
||||
f" 0~2 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长;"
|
||||
@@ -178,7 +178,8 @@ def rules_text() -> str:
|
||||
"不会替你花钱)、温室免疫暴风雨且水分蒸发减半。同类建筑越造越贵。\n"
|
||||
f"9. 目标:把净资产(金币 + 土地 + 牲畜 + 饲料 + 地里作物 + **谷仓存货**)"
|
||||
f"做到 {config.GOALS[-1][0]} 金币。\n"
|
||||
"10. 多人:每个 farm 是一个独立牧场,用 ?farm=名字 区分,互不影响;"
|
||||
'10. 多人:网页用用户名登录(POST /api/login "username",Cookie 绑定同名牧场,'
|
||||
"登录后只能操作自己的);脚本/AI 无 Cookie 时用 ?farm=名字 区分,互不影响;"
|
||||
"排行榜见 GET /api/world。\n"
|
||||
"\n推荐决策循环(AI 最短路径):\n"
|
||||
" GET /api/actions?farm=me → 拿到当前所有**合法**动作(含开销与理由)\n"
|
||||
@@ -214,6 +215,7 @@ class _Services:
|
||||
|
||||
cfg: ServerConfig
|
||||
manager: SessionManager
|
||||
auth: LoginManager
|
||||
hub: ConnectionHub
|
||||
publisher: Publisher
|
||||
clock: FarmClock
|
||||
@@ -233,6 +235,7 @@ class _Services:
|
||||
return cls(
|
||||
cfg=cfg,
|
||||
manager=manager,
|
||||
auth=LoginManager(cfg.save_dir),
|
||||
hub=hub,
|
||||
publisher=Publisher(manager, hub, autosave_interval=cfg.autosave_interval),
|
||||
clock=FarmClock(manager),
|
||||
@@ -280,9 +283,21 @@ class _Services:
|
||||
if not self.authorized(self.token_of(request)):
|
||||
raise GameError("unauthorized", "缺少或错误的 API Token", status=401)
|
||||
|
||||
def user_of(self, request: Request) -> str | None:
|
||||
"""登录 Cookie 里的用户名;没登录返回 None。"""
|
||||
return self.auth.verify(request.cookies.get(COOKIE_NAME))
|
||||
|
||||
def session_of(self, request: Request, owner: str = "") -> FarmSession:
|
||||
farm = request.headers.get("x-farm-id") or request.query_params.get("farm") or DEFAULT_FARM
|
||||
return self.manager.get(farm, create=True, owner=owner)
|
||||
user = self.user_of(request)
|
||||
if user:
|
||||
# 登录后只能玩自己的牧场:?farm= / X-Farm-Id 被忽略,
|
||||
# 改 URL 碰不到别人的档。没有 Cookie 的脚本保持旧语义(互信小圈子)。
|
||||
farm = user
|
||||
else:
|
||||
farm = (
|
||||
request.headers.get("x-farm-id") or request.query_params.get("farm") or DEFAULT_FARM
|
||||
)
|
||||
return self.manager.get(farm, create=True, owner=user or owner)
|
||||
|
||||
@staticmethod
|
||||
def full_requested(request: Request) -> bool:
|
||||
@@ -414,6 +429,36 @@ def _register_meta_routes(app: FastAPI, svc: _Services) -> None:
|
||||
}
|
||||
|
||||
|
||||
def _register_auth_routes(app: FastAPI, svc: _Services) -> None:
|
||||
@app.post("/api/login", tags=["登录"], summary="用户名登录(设置 HttpOnly Cookie)")
|
||||
async def login(request: Request, response: Response) -> dict[str, Any]:
|
||||
"""用户名即身份(互信小圈子模型):登录后 Cookie 绑定同名牧场。"""
|
||||
svc.guard(request)
|
||||
body = await svc.body_of(request)
|
||||
user = validate_farm_id(body.get("username") or body.get("user") or body.get("name"))
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
svc.auth.issue(user),
|
||||
max_age=TTL_SECONDS,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
return {"ok": True, "user": user, "farm": user, "message": f"欢迎回来,{user}!"}
|
||||
|
||||
@app.post("/api/logout", tags=["登录"], summary="退出登录(清除 Cookie)")
|
||||
async def logout(request: Request, response: Response) -> dict[str, Any]:
|
||||
svc.guard(request)
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
return {"ok": True, "message": "已退出登录"}
|
||||
|
||||
@app.get("/api/me", tags=["登录"], summary="当前登录的用户")
|
||||
async def me(request: Request) -> dict[str, Any]:
|
||||
# 刻意不 guard:只回答"登录了没有",不泄露任何牧场数据;
|
||||
# 网页首屏第一件事就是问它。
|
||||
return {"ok": True, "user": svc.user_of(request)}
|
||||
|
||||
|
||||
def _register_world_routes(app: FastAPI, svc: _Services) -> None:
|
||||
@app.get("/api/world", tags=["世界"], summary="世界概览:排行榜 + 在线连接 + 时钟")
|
||||
async def world(request: Request) -> dict[str, Any]:
|
||||
@@ -440,8 +485,10 @@ def _register_world_routes(app: FastAPI, svc: _Services) -> None:
|
||||
async def create_farm(request: Request) -> dict[str, Any]:
|
||||
svc.guard(request)
|
||||
body = await svc.body_of(request)
|
||||
farm = validate_farm_id(body.get("farm") or body.get("name"))
|
||||
owner = str(body.get("owner") or "")
|
||||
# 登录后牧场固定为本人:改 body 也进不了别人的牧场
|
||||
user = svc.user_of(request)
|
||||
farm = user or validate_farm_id(body.get("farm") or body.get("name"))
|
||||
owner = user or str(body.get("owner") or "")
|
||||
existed = svc.manager.find(farm) is not None
|
||||
session = svc.manager.get(farm, create=True, owner=owner)
|
||||
tick = body.get("tick")
|
||||
@@ -630,7 +677,8 @@ def _register_stream_route(app: FastAPI, svc: _Services) -> None:
|
||||
"""
|
||||
svc.guard(request)
|
||||
params = request.query_params
|
||||
farm = validate_farm_id(params.get("farm") or DEFAULT_FARM)
|
||||
# 登录用户只能听自己的牧场;没有 Cookie(脚本/TUI)用 ?farm=
|
||||
farm = svc.user_of(request) or validate_farm_id(params.get("farm") or DEFAULT_FARM)
|
||||
# 牧场数达上限等情况会抛 GameError,交给全局处理器回 4xx JSON
|
||||
session = svc.manager.get(farm, create=True)
|
||||
|
||||
@@ -734,6 +782,7 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
_register_error_handlers(app, svc)
|
||||
_register_pages(app, svc)
|
||||
_register_meta_routes(app, svc)
|
||||
_register_auth_routes(app, svc)
|
||||
_register_world_routes(app, svc)
|
||||
_register_farm_routes(app, svc)
|
||||
_register_action_routes(app, svc)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""用户名登录:HMAC 签名的 HttpOnly Cookie,无密码。
|
||||
|
||||
信任模型是「互信小圈子」(用户选了仅用户名):用户名即身份,Cookie 防的是
|
||||
"别人随手改 URL 玩你的牧场",不是防蓄意攻击者。要点三个:
|
||||
|
||||
* Cookie 值 = base64(payload) + "." + HMAC-SHA256,payload 是 ``用户名|过期时间``。
|
||||
验签用 ``hmac.compare_digest``(恒时比较),防篡改;过期 30 天。
|
||||
* 签名密钥存 ``<save_dir>/secret.key``(0600):重启不掉线,也不用建表存 token。
|
||||
* 没有 Cookie 的请求(脚本 / AI / CLI)不受影响——继续走 ``?farm=``,
|
||||
兼容语义收在 ``app._Services.session_of`` 一处,这里只管签发与验签。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from farm.engine import GameError
|
||||
|
||||
from .session import validate_farm_id
|
||||
|
||||
COOKIE_NAME = "sf_auth"
|
||||
TTL_SECONDS = 30 * 86400
|
||||
|
||||
# 密钥文件名固定,放存档目录里——它跟存档一样属于"这台服务器的本地秘密"
|
||||
SECRET_FILE = "secret.key"
|
||||
|
||||
|
||||
class LoginManager:
|
||||
"""签发与校验登录 Cookie。"""
|
||||
|
||||
def __init__(self, secret_dir: str) -> None:
|
||||
os.makedirs(secret_dir, exist_ok=True)
|
||||
self.secret = self._load_or_create(os.path.join(secret_dir, SECRET_FILE))
|
||||
|
||||
@staticmethod
|
||||
def _load_or_create(path: str) -> bytes:
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
raw = bytes.fromhex(handle.read().strip().decode("ascii"))
|
||||
if len(raw) >= 32:
|
||||
return raw
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
# 文件不存在 / 不是合法 hex / 长度不够:统统当作没有,重新生成
|
||||
pass
|
||||
raw = secrets.token_bytes(32)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(raw.hex().encode("ascii"))
|
||||
return raw
|
||||
|
||||
def issue(self, username: str) -> str:
|
||||
"""给用户名签一枚 Cookie 值。用户名不合法直接抛 bad_farm。"""
|
||||
user = validate_farm_id(username)
|
||||
payload = f"{user}|{int(time.time()) + TTL_SECONDS}"
|
||||
sig = hmac.new(self.secret, payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
blob = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii")
|
||||
return f"{blob}.{sig}"
|
||||
|
||||
def verify(self, token: str | None) -> str | None:
|
||||
"""校验 Cookie 值,返回用户名;任何一步不对都返回 None(当没登录)。"""
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
blob, _, sig = token.rpartition(".")
|
||||
try:
|
||||
payload = base64.urlsafe_b64decode(blob.encode("ascii")).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
want = hmac.new(self.secret, payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(want, sig):
|
||||
return None
|
||||
user, _, exp_text = payload.rpartition("|")
|
||||
try:
|
||||
expires = int(exp_text)
|
||||
except ValueError:
|
||||
return None
|
||||
if expires < time.time():
|
||||
return None
|
||||
try:
|
||||
return validate_farm_id(user)
|
||||
except GameError:
|
||||
return None
|
||||
+16
-2
@@ -85,6 +85,11 @@ class FarmSession:
|
||||
self.engine.next_tick_at = time.time() + self.engine.tick_seconds
|
||||
# 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写
|
||||
self.dirty = True
|
||||
# unsaved 与 dirty 分开:dirty 管推送节流(Publisher 会频繁清它),
|
||||
# unsaved 管「有变化还没落盘」,只有成功的 save() 才清。
|
||||
# 之前两个概念共用 dirty,而推送循环 ~250ms 就清一次,自动存档
|
||||
# 60s 才看一眼——窗口太小,autosave 几乎永远不触发。
|
||||
self.unsaved = True
|
||||
self.published_day = 0
|
||||
self.last_events: list[str] = []
|
||||
self.created_at = time.time()
|
||||
@@ -100,6 +105,7 @@ class FarmSession:
|
||||
def _on_change(self) -> None:
|
||||
"""引擎的任何状态变化都会走到这里(可能来自任意线程)。"""
|
||||
self.dirty = True
|
||||
self.unsaved = True
|
||||
|
||||
def load(self) -> str:
|
||||
state, message = load_state(self.db_path, self.farm_id)
|
||||
@@ -112,17 +118,23 @@ class FarmSession:
|
||||
self.load_message = f"{message},已开新档"
|
||||
self.published_day = self.engine.state.day
|
||||
self.dirty = True
|
||||
self.unsaved = True
|
||||
return self.load_message
|
||||
|
||||
def save(self) -> str:
|
||||
with self._lock:
|
||||
path = save_state(self.engine.state, self.db_path, self.farm_id)
|
||||
self.saved_at = time.time()
|
||||
self.unsaved = False
|
||||
return path
|
||||
|
||||
def autosave(self) -> bool:
|
||||
"""只在有变化时落盘,返回是否真的写了。"""
|
||||
if not self.dirty or self.save_dir == "":
|
||||
"""只在有未落盘的变化时写,返回是否真的写了。
|
||||
|
||||
看的是 ``unsaved`` 而不是 ``dirty``:dirty 是推送节流用的,
|
||||
Publisher 每 ~250ms 就清一次,拿来当存盘依据等于永不存盘。
|
||||
"""
|
||||
if not self.unsaved or self.save_dir == "":
|
||||
return False
|
||||
if time.time() - self.saved_at < 5:
|
||||
return False
|
||||
@@ -142,12 +154,14 @@ class FarmSession:
|
||||
if count_action:
|
||||
self.actions_taken += 1
|
||||
self.dirty = True
|
||||
self.unsaved = True
|
||||
|
||||
def advance_day(self, reason: str = "时间流逝") -> list[str]:
|
||||
with self._lock:
|
||||
events = self.engine.advance_day(reason=reason)
|
||||
self.last_events = events
|
||||
self.dirty = True
|
||||
self.unsaved = True
|
||||
return events
|
||||
|
||||
def reset(self) -> None:
|
||||
|
||||
+628
-35
@@ -27,7 +27,8 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--ink);
|
||||
font: 14px/1.6 "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
|
||||
font: 15px/1.6 "Segoe UI Variable Text", "Segoe UI", "MiSans",
|
||||
"HarmonyOS Sans SC", "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
|
||||
}
|
||||
header {
|
||||
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
|
||||
@@ -53,7 +54,7 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
main { padding: 16px 20px 40px; max-width: 1180px; margin: 0 auto; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 10px; }
|
||||
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; }
|
||||
.stat b { display: block; font-size: 18px; }
|
||||
.stat b { display: block; font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.stat small { color: var(--dim); }
|
||||
.cols { display: grid; grid-template-columns: 1.35fr 1fr; gap: 14px; margin-top: 14px; }
|
||||
@media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
|
||||
@@ -82,6 +83,15 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
.k-good { color: var(--green); } .k-bad { color: var(--red); }
|
||||
.k-warn { color: var(--gold); } .k-action { color: var(--cyan); }
|
||||
.notice { font-size: 12px; color: var(--dim); min-height: 18px; }
|
||||
/* 播种选作物 / 二次确认的页面内弹层(不依赖 window.prompt,嵌入预览里也能用) */
|
||||
.mask { position: fixed; inset: 0; background: rgba(30,40,30,.35);
|
||||
display: none; align-items: center; justify-content: center; z-index: 30; }
|
||||
.mask.show { display: flex; }
|
||||
.pick { background: var(--card); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 14px 16px; min-width: 230px; box-shadow: 0 10px 30px rgba(0,0,0,.18); }
|
||||
.pick h3 { margin: 0 0 10px; font-size: 14px; }
|
||||
.pick .row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.pick button.danger { border-color: var(--red); color: var(--red); }
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
|
||||
background: #23301f; color: #fff; padding: 9px 18px; border-radius: 20px;
|
||||
@@ -89,8 +99,12 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
}
|
||||
.toast.show { opacity: .93; }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.badge { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 5px 12px; font-size: 13px; }
|
||||
.hidden { display: none; }
|
||||
.animals li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dashed var(--line); list-style: none; }
|
||||
ul { padding: 0; margin: 0; }
|
||||
/* Canvas 农场地图:宽度跟随容器,高度按内部逻辑尺寸等比缩放 */
|
||||
#farmmap { width: 100%; height: auto; display: block; border-radius: 10px; cursor: pointer; touch-action: manipulation; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -98,14 +112,19 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
<h1>SimpleFarm <span>小型农场游戏服务器</span></h1>
|
||||
<div class="dot" id="status"><i></i><span id="statustext">未连接</span></div>
|
||||
<div class="spacer"></div>
|
||||
<input id="farm" size="10" placeholder="牧场名" />
|
||||
<span class="badge" id="who">未登录</span>
|
||||
<input id="token" size="10" placeholder="令牌(可选)" />
|
||||
<button class="primary" id="join">进入牧场</button>
|
||||
<button id="logout" class="hidden">退出</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<div class="card" style="margin-top:14px">
|
||||
<h2>农场地图 <em id="maphint">点空地播种 · 点熟地收获 · 缺水的地点一下浇水 · 点牲畜喂食/收取</em></h2>
|
||||
<canvas id="farmmap">你的浏览器不支持 canvas</canvas>
|
||||
</div>
|
||||
|
||||
<div class="cols">
|
||||
<div>
|
||||
<div class="card">
|
||||
@@ -167,9 +186,28 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<div class="mask" id="loginmask">
|
||||
<div class="pick">
|
||||
<h3>登录到你的牧场</h3>
|
||||
<p style="color:#6b7a66;margin:6px 0 10px">输入用户名即可——第一次用会自动开一个新牧场。</p>
|
||||
<div class="row" style="justify-content:center">
|
||||
<input id="loginname" size="14" placeholder="用户名" />
|
||||
<button class="primary" id="dologin">登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mask" id="pickmask">
|
||||
<div class="pick">
|
||||
<h3 id="picktitle">要种什么?</h3>
|
||||
<div class="row" id="pickcrops"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let es = null, state = null, catalog = null, pollTimer = null;
|
||||
let es = null, state = null, catalog = null, pollTimer = null, me = null;
|
||||
let tickLeft = null, tickStamp = 0;
|
||||
|
||||
const cropNames = {
|
||||
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
|
||||
@@ -183,6 +221,15 @@ function goodsName(key) {
|
||||
return cropNames[key] || key;
|
||||
}
|
||||
|
||||
/* sandbox iframe 可能禁用 localStorage(无 allow-same-origin 时访问即抛
|
||||
SecurityError),任何一处裸调用都会炸掉整个启动脚本。 */
|
||||
function lsGet(key) {
|
||||
try { return localStorage.getItem(key); } catch (e) { return null; }
|
||||
}
|
||||
function lsSet(key, val) {
|
||||
try { localStorage.setItem(key, val); } catch (e) {}
|
||||
}
|
||||
|
||||
function toast(text) {
|
||||
const el = $("toast");
|
||||
el.textContent = text;
|
||||
@@ -196,17 +243,35 @@ function setStatus(text, cls) {
|
||||
$("status").className = "dot " + (cls || "");
|
||||
}
|
||||
|
||||
function farmName() { return ($("farm").value || "default").trim() || "default"; }
|
||||
function farmName() { return me || "default"; }
|
||||
function token() { return ($("token").value || "").trim(); }
|
||||
|
||||
/* HTTP 头只允许 Latin-1 字符:中文用户名塞进 X-Farm-Id 会让 fetch 在发送前
|
||||
就抛 TypeError,登录后所有写操作静默失败(表现为“点播种没反应”)。
|
||||
登录态由 Cookie 鉴权,非 ASCII 的值一律不塞头。 */
|
||||
function latin1(s) {
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s.charCodeAt(i);
|
||||
if (c < 32 || c > 126) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function headers() {
|
||||
const h = { "Content-Type": "application/json", "X-Farm-Id": farmName() };
|
||||
if (token()) h["X-API-Token"] = token();
|
||||
const h = { "Content-Type": "application/json" };
|
||||
if (latin1(farmName())) h["X-Farm-Id"] = farmName();
|
||||
if (token() && latin1(token())) h["X-API-Token"] = token();
|
||||
return h;
|
||||
}
|
||||
|
||||
async function api(path, options) {
|
||||
const res = await fetch(path, options);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(path, options);
|
||||
} catch (e) {
|
||||
toast("网络错误:" + e.message);
|
||||
return null;
|
||||
}
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (e) { data = { ok: false, message: "响应不是 JSON" }; }
|
||||
if (!res.ok || data.ok === false) {
|
||||
@@ -223,6 +288,7 @@ async function act(action, params) {
|
||||
});
|
||||
if (data) {
|
||||
toast(data.message);
|
||||
mapJuice(action, params);
|
||||
if (data.state) refreshFromBrief(data.state);
|
||||
}
|
||||
}
|
||||
@@ -298,12 +364,16 @@ function render() {
|
||||
["第几天", s.day],
|
||||
["天气", (s.weather_name || "—") + " → " + (s.forecast_name || "?")],
|
||||
["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
|
||||
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed],
|
||||
["饲料", s.feed],
|
||||
["仓储", (s.storage_used || 0) + "/" + (s.granary_capacity || 0)],
|
||||
["净资产", Math.round(s.net_worth)],
|
||||
["在线", s.online !== undefined ? s.online : "—"],
|
||||
["下一天", fmtLeft(s.seconds_to_tick)]
|
||||
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join("");
|
||||
["下一天", fmtLeft(s.seconds_to_tick), "tickcell"]
|
||||
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b' +
|
||||
(p[2] ? ' id="' + p[2] + '"' : '') + '>' + p[1] + '</b></div>').join("");
|
||||
/* 倒计时基点:SSE 只在有变化时推送,中间的每一秒由本地定时器补齐 */
|
||||
tickLeft = (s.seconds_to_tick === undefined || s.seconds_to_tick === null) ? null : s.seconds_to_tick;
|
||||
tickStamp = Date.now();
|
||||
|
||||
/* 农田 */
|
||||
$("plots").innerHTML = s.plots.map((p) => {
|
||||
@@ -378,6 +448,7 @@ function render() {
|
||||
' title="' + b.effect + '">' + label + '</button>';
|
||||
}).join("") : '<span style="color:#6b7a66">目录加载中…</span>';
|
||||
|
||||
updateMap();
|
||||
renderLog(null);
|
||||
renderBoard();
|
||||
}
|
||||
@@ -415,23 +486,474 @@ async function renderBoard() {
|
||||
r.online + '</td></tr>').join("");
|
||||
}
|
||||
|
||||
/* ---- 农场地图:Canvas 俯视像素风 ----
|
||||
画布用"逻辑坐标"绘制(MAP.w/h),实际像素按容器宽度 × devicePixelRatio
|
||||
缩放,所以手机上小屏也看得清、高分屏不糊。所有交互(播种/浇水/收获/
|
||||
喂食/收取)最终都复用下面的 openCropPick / act(),地图只是同一批动作
|
||||
的另一张脸。 */
|
||||
const MAP = {
|
||||
cv: null, ctx: null, scale: 1, w: 0, h: 0,
|
||||
field: null, pen: null, builds: [], stripH: 0,
|
||||
walkers: [], animalIds: "", drops: null, clouds: null, hover: null, fx: []
|
||||
};
|
||||
|
||||
/* 动作粒子:浇水溅水花、收获爆金点、播种扬土,配一枚上浮的表情小字。
|
||||
act() 成功后调 mapJuice(action, params),坐标由地块号/牲畜 id 反查。 */
|
||||
function plotXY(position) {
|
||||
const f = MAP.field;
|
||||
const i = (state.plots || []).findIndex((p) => p.position === position);
|
||||
if (!f || i < 0) return null;
|
||||
return { x: f.x + (i % f.cols) * (f.ts + f.gap) + f.ts / 2,
|
||||
y: f.y + Math.floor(i / f.cols) * (f.ts + f.gap) + f.ts / 2 };
|
||||
}
|
||||
function animalXY(id) {
|
||||
const wd = (MAP.walkers || []).find((w) => w.a.id === id);
|
||||
return wd ? { x: wd.x, y: wd.y } : null;
|
||||
}
|
||||
function mapJuice(action, params) {
|
||||
if (!MAP.field || !params) return;
|
||||
let at = null;
|
||||
if (params.plot !== undefined) at = plotXY(params.plot);
|
||||
else if (params.animal_id !== undefined) at = animalXY(params.animal_id);
|
||||
if (!at) return;
|
||||
const conf = {
|
||||
water: { color: "#7cc4f5", n: 14, text: "💧" },
|
||||
plant: { color: "#a97b4f", n: 12, text: "🌱" },
|
||||
harvest: { color: "#ffd54d", n: 22, text: "✨" },
|
||||
feed: { color: "#e8b04a", n: 12, text: "🍽" },
|
||||
collect: { color: "#ffd54d", n: 18, text: "✨" }
|
||||
}[action];
|
||||
if (!conf) return;
|
||||
for (let i = 0; i < conf.n; i++) {
|
||||
const a = Math.random() * 6.28, v = 34 + Math.random() * 74;
|
||||
MAP.fx.push({ x: at.x, y: at.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 52,
|
||||
t: 0, life: 0.55 + Math.random() * 0.45, color: conf.color,
|
||||
r: 1.6 + Math.random() * 2.4 });
|
||||
}
|
||||
MAP.fx.push({ x: at.x, y: at.y - 8, floater: true, t: 0, life: 1.1, text: conf.text });
|
||||
}
|
||||
|
||||
/* 一天的进度 → 画面光色:清晨淡金 → 正午不叠色 → 黄昏橙 → 入夜靛紫。
|
||||
时间不该只在倒计时数字上流动,田里的光也要跟着走。 */
|
||||
function dayTint(prog) {
|
||||
if (prog < 0.12) {
|
||||
const k = prog / 0.12;
|
||||
return [255, 190, 110, 0.14 * (1 - k)];
|
||||
}
|
||||
if (prog < 0.70) return null;
|
||||
const k = (prog - 0.70) / 0.30;
|
||||
return [Math.round(mapLerp(255, 104, k)), Math.round(mapLerp(150, 80, k)),
|
||||
Math.round(mapLerp(72, 190, k)), 0.05 + 0.15 * k];
|
||||
}
|
||||
const cropEmoji = { wheat: "🌾", carrot: "🥕", tomato: "🍅", corn: "🌽", pumpkin: "🎃", strawberry: "🍓" };
|
||||
const animalEmoji = { chicken: "🐔", sheep: "🐑", cow: "🐄" };
|
||||
const buildingInfo = {
|
||||
granary: ["🛖", "谷仓"], well: ["⛲", "水井"], feeder: ["🥣", "喂食器"],
|
||||
greenhouse: ["🏠", "温室"], sprinkler: ["🚿", "喷灌"], harvester: ["🚜", "收割机"],
|
||||
vendor: ["🏪", "售货机"], mill: ["⚙️", "磨坊"]
|
||||
};
|
||||
|
||||
function mapWeather() {
|
||||
const w = (state && state.weather) || "";
|
||||
if (["sunny", "cloudy", "rain", "drought", "storm"].indexOf(w) >= 0) return w;
|
||||
return "sunny";
|
||||
}
|
||||
|
||||
/* 布局:农田方阵(4 列,地多改 6 列)+ 右侧牧场围栏 + 底部建筑带 */
|
||||
function mapLayout() {
|
||||
const TS = 56, GAP = 8, M = 16;
|
||||
const plots = (state && state.plots) || [];
|
||||
const cols = plots.length > 12 ? 6 : 4;
|
||||
const rows = Math.max(1, Math.ceil(plots.length / cols));
|
||||
const fieldW = cols * TS + (cols - 1) * GAP;
|
||||
const fieldH = rows * TS + (rows - 1) * GAP;
|
||||
const penW = 150, penH = Math.max(fieldH, 110);
|
||||
MAP.builds = Object.entries((state && state.buildings) || {}).filter((kv) => kv[1] > 0);
|
||||
MAP.stripH = MAP.builds.length ? 64 : 0;
|
||||
MAP.w = M + fieldW + 26 + penW + M;
|
||||
MAP.h = M + fieldH + (MAP.stripH ? MAP.stripH + 12 : 0) + M;
|
||||
MAP.field = { x: M, y: M, cols: cols, rows: rows, ts: TS, gap: GAP };
|
||||
MAP.pen = { x: M + fieldW + 26, y: M, w: penW, h: penH };
|
||||
}
|
||||
|
||||
/* 牲畜漫步者:id 集合没变就复用旧位置(换状态不瞬移),变了才重建 */
|
||||
function mapSyncAnimals() {
|
||||
const list = (state && state.animals) || [];
|
||||
const ids = list.map((a) => a.id).join(",");
|
||||
if (ids !== MAP.animalIds || !MAP.walkers.length !== !list.length) {
|
||||
MAP.animalIds = ids;
|
||||
const pen = MAP.pen;
|
||||
MAP.walkers = list.map((a) => ({
|
||||
a: a,
|
||||
x: pen.x + 16 + Math.random() * Math.max(10, pen.w - 32),
|
||||
y: pen.y + 16 + Math.random() * Math.max(10, pen.h - 32),
|
||||
vx: (Math.random() - 0.5) * 14, vy: (Math.random() - 0.5) * 10,
|
||||
ph: Math.random() * 6.28
|
||||
}));
|
||||
} else {
|
||||
MAP.walkers.forEach((wd) => {
|
||||
const fresh = list.find((x) => x.id === wd.a.id);
|
||||
if (fresh) wd.a = fresh;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mapFit() {
|
||||
if (!MAP.cv || !MAP.w) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = MAP.cv.clientWidth || 300;
|
||||
MAP.cv.width = Math.max(1, Math.round(cssW * dpr));
|
||||
MAP.cv.height = Math.max(1, Math.round(cssW * dpr * MAP.h / MAP.w));
|
||||
MAP.scale = MAP.cv.width / MAP.w;
|
||||
}
|
||||
|
||||
function mapLerp(a, b, t) { return a + (b - a) * Math.max(0, Math.min(1, t)); }
|
||||
|
||||
function mapStep(dt, now) {
|
||||
/* 雨滴:数组按需重建, storm 比 rain 密 */
|
||||
const w = mapWeather();
|
||||
const wantDrops = w === "storm" ? 150 : (w === "rain" ? 80 : 0);
|
||||
if (!MAP.drops || MAP.drops.length !== wantDrops) {
|
||||
MAP.drops = [];
|
||||
for (let i = 0; i < wantDrops; i++) {
|
||||
MAP.drops.push({ x: Math.random() * MAP.w, y: Math.random() * MAP.h,
|
||||
v: 340 + Math.random() * 180, len: 7 + Math.random() * 7 });
|
||||
}
|
||||
}
|
||||
(MAP.drops || []).forEach((d) => {
|
||||
d.y += d.v * dt; d.x -= d.v * dt * 0.18;
|
||||
if (d.y > MAP.h) { d.y = -d.len; d.x = Math.random() * (MAP.w + 60); }
|
||||
});
|
||||
/* 云 */
|
||||
const wantClouds = w === "cloudy" || w === "rain" || w === "storm" ? 4 : 1;
|
||||
if (!MAP.clouds || MAP.clouds.length !== wantClouds) {
|
||||
MAP.clouds = [];
|
||||
for (let i = 0; i < wantClouds; i++) {
|
||||
MAP.clouds.push({ x: Math.random() * MAP.w, y: 8 + Math.random() * 26,
|
||||
s: 0.7 + Math.random() * 0.8, v: 5 + Math.random() * 7 });
|
||||
}
|
||||
}
|
||||
(MAP.clouds || []).forEach((c) => { c.x += c.v * dt; if (c.x > MAP.w + 70) c.x = -70; });
|
||||
/* 特效粒子:受重力下坠、寿命到了消失;浮字匀速上飘 */
|
||||
for (let i = MAP.fx.length - 1; i >= 0; i--) {
|
||||
const f = MAP.fx[i];
|
||||
f.t += dt;
|
||||
if (f.t >= f.life) { MAP.fx.splice(i, 1); continue; }
|
||||
if (f.floater) { f.y -= 26 * dt; }
|
||||
else { f.vy += 190 * dt; f.x += f.vx * dt; f.y += f.vy * dt; }
|
||||
}
|
||||
/* 牲畜漫步 + 边界回弹(pen 未布局时直接跳过) */
|
||||
const pen = MAP.pen;
|
||||
if (!pen) return;
|
||||
(MAP.walkers || []).forEach((wd) => {
|
||||
wd.x += wd.vx * dt; wd.y += wd.vy * dt; wd.ph += dt * 2;
|
||||
if (wd.x < pen.x + 10) { wd.x = pen.x + 10; wd.vx = Math.abs(wd.vx); }
|
||||
if (wd.x > pen.x + pen.w - 10) { wd.x = pen.x + pen.w - 10; wd.vx = -Math.abs(wd.vx); }
|
||||
if (wd.y < pen.y + 12) { wd.y = pen.y + 12; wd.vy = Math.abs(wd.vy); }
|
||||
if (wd.y > pen.y + pen.h - 8) { wd.y = pen.y + pen.h - 8; wd.vy = -Math.abs(wd.vy); }
|
||||
});
|
||||
}
|
||||
|
||||
function mapRoundRect(x, y, w, h, r) {
|
||||
const c = MAP.ctx;
|
||||
c.beginPath();
|
||||
c.moveTo(x + r, y);
|
||||
c.arcTo(x + w, y, x + w, y + h, r);
|
||||
c.arcTo(x + w, y + h, x, y + h, r);
|
||||
c.arcTo(x, y + h, x, y, r);
|
||||
c.arcTo(x, y, x + w, y, r);
|
||||
c.closePath();
|
||||
}
|
||||
|
||||
function mapDrawTile(p, x, y, ts, now) {
|
||||
const c = MAP.ctx;
|
||||
const wet = p.moisture / (p.water_capacity || 3);
|
||||
const r = Math.round(mapLerp(206, 118, wet));
|
||||
const g = Math.round(mapLerp(168, 80, wet));
|
||||
const b = Math.round(mapLerp(112, 48, wet));
|
||||
c.fillStyle = "rgb(" + r + "," + g + "," + b + ")";
|
||||
mapRoundRect(x, y, ts, ts, 7); c.fill();
|
||||
/* 干裂的田:画几道深色裂纹,比只描红边更像"渴" */
|
||||
if (p.moisture === 0) {
|
||||
c.strokeStyle = "rgba(70,40,20,.55)"; c.lineWidth = 1.4;
|
||||
c.beginPath();
|
||||
c.moveTo(x + 12, y + 16); c.lineTo(x + 24, y + 26); c.lineTo(x + 20, y + 38);
|
||||
c.moveTo(x + 34, y + 12); c.lineTo(x + 40, y + 24); c.lineTo(x + 50, y + 30);
|
||||
c.stroke();
|
||||
}
|
||||
/* 作物:随生长进度放大,成熟加金光 */
|
||||
if (!p.is_empty) {
|
||||
const ratio = p.grow_days ? p.growth / p.grow_days : 1;
|
||||
const size = Math.round(16 + ratio * 20);
|
||||
c.save();
|
||||
if (p.is_ripe) {
|
||||
c.shadowColor = "rgba(255,200,40,.9)";
|
||||
c.shadowBlur = 10 + Math.sin(now / 320 + p.position * 1.7) * 5;
|
||||
}
|
||||
c.font = size + 'px "Segoe UI Emoji","Noto Color Emoji",sans-serif';
|
||||
c.textAlign = "center"; c.textBaseline = "middle";
|
||||
c.fillText(cropEmoji[p.crop] || "🌱", x + ts / 2, y + ts / 2 - 3);
|
||||
c.restore();
|
||||
} else {
|
||||
c.fillStyle = "rgba(60,40,20,.5)";
|
||||
c.font = '600 11px "Segoe UI","Microsoft YaHei",sans-serif';
|
||||
c.textAlign = "center"; c.textBaseline = "middle";
|
||||
c.fillText("空地", x + ts / 2, y + ts / 2);
|
||||
}
|
||||
/* 底部两条迷你进度条:生长(绿)/ 水分(蓝) */
|
||||
const bw = ts - 14;
|
||||
c.fillStyle = "rgba(0,0,0,.22)";
|
||||
c.fillRect(x + 7, y + ts - 11, bw, 3); c.fillRect(x + 7, y + ts - 6, bw, 3);
|
||||
const ratio = p.grow_days ? Math.min(1, p.growth / p.grow_days) : 1;
|
||||
c.fillStyle = p.is_ripe ? "#ffd54d" : "#7ddf87";
|
||||
c.fillRect(x + 7, y + ts - 11, bw * ratio, 3);
|
||||
c.fillStyle = "#6db9f0";
|
||||
c.fillRect(x + 7, y + ts - 6, bw * Math.min(1, wet), 3);
|
||||
/* 悬停高亮 + 地号 */
|
||||
if (MAP.hover && MAP.hover.type === "plot" && MAP.hover.p === p) {
|
||||
c.strokeStyle = "#fff"; c.lineWidth = 2.4;
|
||||
mapRoundRect(x - 1.5, y - 1.5, ts + 3, ts + 3, 8); c.stroke();
|
||||
}
|
||||
c.fillStyle = "rgba(255,255,255,.75)";
|
||||
c.font = '600 10px "Segoe UI",sans-serif';
|
||||
c.textAlign = "left"; c.textBaseline = "top";
|
||||
c.fillText(String(p.position), x + 4, y + 3);
|
||||
}
|
||||
|
||||
function mapDraw(now) {
|
||||
const cv = MAP.cv, c = MAP.ctx;
|
||||
if (!cv || !state || !MAP.field) return;
|
||||
c.setTransform(MAP.scale, 0, 0, MAP.scale, 0, 0);
|
||||
const w = mapWeather();
|
||||
/* 天空底色:晴朗偏亮、暴雨压暗 */
|
||||
const skies = {
|
||||
sunny: ["#aee2ff", "#cfe9b9"], cloudy: ["#c3cbd2", "#cfe0b4"],
|
||||
rain: ["#93a7b5", "#b9cf9f"], drought: ["#ffd9a0", "#e8cf96"],
|
||||
storm: ["#5f6f80", "#9db48a"]
|
||||
}[w];
|
||||
const grad = c.createLinearGradient(0, 0, 0, MAP.h);
|
||||
grad.addColorStop(0, skies[0]); grad.addColorStop(1, skies[1]);
|
||||
c.fillStyle = grad;
|
||||
c.fillRect(0, 0, MAP.w, MAP.h);
|
||||
/* 太阳 / 云 / 雨 */
|
||||
if (w === "sunny" || w === "drought") {
|
||||
c.save();
|
||||
c.shadowColor = "rgba(255,210,60,.95)"; c.shadowBlur = w === "drought" ? 26 : 14;
|
||||
c.fillStyle = w === "drought" ? "#ff9d3c" : "#ffd94d";
|
||||
c.beginPath(); c.arc(MAP.w - 36, 30, w === "drought" ? 17 : 13, 0, 6.29); c.fill();
|
||||
c.restore();
|
||||
}
|
||||
(MAP.clouds || []).forEach((cl) => {
|
||||
c.fillStyle = w === "storm" ? "rgba(70,80,92,.75)" : "rgba(255,255,255,.8)";
|
||||
c.beginPath();
|
||||
c.ellipse(cl.x, cl.y, 30 * cl.s, 11 * cl.s, 0, 0, 6.29);
|
||||
c.ellipse(cl.x + 22 * cl.s, cl.y + 3, 20 * cl.s, 9 * cl.s, 0, 0, 6.29);
|
||||
c.ellipse(cl.x - 20 * cl.s, cl.y + 4, 17 * cl.s, 8 * cl.s, 0, 0, 6.29);
|
||||
c.fill();
|
||||
});
|
||||
(MAP.drops || []).forEach((d) => {
|
||||
c.strokeStyle = "rgba(190,215,255,.6)"; c.lineWidth = 1.2;
|
||||
c.beginPath(); c.moveTo(d.x, d.y); c.lineTo(d.x - 2, d.y + d.len); c.stroke();
|
||||
});
|
||||
/* 农田 */
|
||||
const f = MAP.field, ts = f.ts;
|
||||
(state.plots || []).forEach((p, i) => {
|
||||
const col = i % f.cols, row = Math.floor(i / f.cols);
|
||||
mapDrawTile(p, f.x + col * (ts + f.gap), f.y + row * (ts + f.gap), ts, now);
|
||||
});
|
||||
/* 牧场围栏 */
|
||||
const pen = MAP.pen;
|
||||
c.fillStyle = "rgba(150,200,110,.5)";
|
||||
mapRoundRect(pen.x, pen.y, pen.w, pen.h, 10); c.fill();
|
||||
c.strokeStyle = "#8a6238"; c.lineWidth = 3.4;
|
||||
mapRoundRect(pen.x, pen.y, pen.w, pen.h, 10); c.stroke();
|
||||
c.fillStyle = "#8a6238";
|
||||
const posts = 5;
|
||||
for (let i = 0; i <= posts; i++) {
|
||||
c.fillRect(pen.x + (pen.w / posts) * i - 2, pen.y - 5, 4, 10);
|
||||
c.fillRect(pen.x + (pen.w / posts) * i - 2, pen.y + pen.h - 5, 4, 10);
|
||||
}
|
||||
c.fillStyle = "rgba(60,45,25,.75)";
|
||||
c.font = '600 11px "Segoe UI","Microsoft YaHei",sans-serif';
|
||||
c.textAlign = "center"; c.textBaseline = "top";
|
||||
c.fillText("牧场", pen.x + pen.w / 2, pen.y + 3);
|
||||
(MAP.walkers || []).forEach((wd) => {
|
||||
const bob = Math.sin(wd.ph) * 1.6;
|
||||
c.save();
|
||||
c.fillStyle = "rgba(0,0,0,.18)";
|
||||
c.beginPath(); c.ellipse(wd.x, wd.y + 9, 9, 3, 0, 0, 6.29); c.fill();
|
||||
c.font = '20px "Segoe UI Emoji","Noto Color Emoji",sans-serif';
|
||||
c.textAlign = "center"; c.textBaseline = "middle";
|
||||
c.fillText(animalEmoji[wd.a.kind] || "🐔", wd.x, wd.y + bob);
|
||||
/* 待收取的产出:金色小气泡 */
|
||||
if (wd.a.pending > 0) {
|
||||
c.fillStyle = "#ffd54d"; c.strokeStyle = "#8a6238"; c.lineWidth = 1;
|
||||
c.beginPath(); c.arc(wd.x + 10, wd.y - 10 + bob, 7, 0, 6.29); c.fill(); c.stroke();
|
||||
c.fillStyle = "#5a3c00"; c.font = '9px "Segoe UI",sans-serif';
|
||||
c.fillText(String(wd.a.pending), wd.x + 10, wd.y - 9.5 + bob);
|
||||
} else if (!wd.a.fed) {
|
||||
c.font = '12px "Segoe UI Emoji",sans-serif';
|
||||
c.fillText("💢", wd.x + 10, wd.y - 10 + bob);
|
||||
}
|
||||
if (MAP.hover && MAP.hover.type === "animal" && MAP.hover.a === wd.a) {
|
||||
c.strokeStyle = "#fff"; c.lineWidth = 2;
|
||||
c.beginPath(); c.arc(wd.x, wd.y + bob, 14, 0, 6.29); c.stroke();
|
||||
}
|
||||
c.restore();
|
||||
});
|
||||
/* 建筑带 */
|
||||
if (MAP.stripH) {
|
||||
const y0 = MAP.field.y + MAP.field.rows * ts + (MAP.field.rows - 1) * f.gap + 12;
|
||||
const cw = MAP.w / Math.max(1, MAP.builds.length);
|
||||
MAP.builds.forEach((kv, i) => {
|
||||
const info = buildingInfo[kv[0]] || ["🏗️", kv[0]];
|
||||
const cx = cw * i + cw / 2;
|
||||
c.fillStyle = "rgba(255,255,255,.5)";
|
||||
mapRoundRect(cx - 28, y0, 56, MAP.stripH - 6, 8); c.fill();
|
||||
c.font = '22px "Segoe UI Emoji","Noto Color Emoji",sans-serif';
|
||||
c.textAlign = "center"; c.textBaseline = "top";
|
||||
c.fillText(info[0], cx, y0 + 4);
|
||||
c.fillStyle = "#3a4a33"; c.font = '600 11px "Segoe UI","Microsoft YaHei",sans-serif';
|
||||
c.fillText(kv[1] > 1 ? info[1] + " ×" + kv[1] : info[1], cx, y0 + 32);
|
||||
});
|
||||
}
|
||||
/* 昼夜光色 + 动作特效:盖在场地上、悬停提示之下 */
|
||||
const dayTotal = state.tick_seconds || 2400;
|
||||
const tint = dayTint(1 - Math.min(1, (state.seconds_to_tick || 0) / dayTotal));
|
||||
if (tint && tint[3] > 0.004) {
|
||||
c.fillStyle = "rgba(" + tint[0] + "," + tint[1] + "," + tint[2] + "," + tint[3].toFixed(3) + ")";
|
||||
c.fillRect(0, 0, MAP.w, MAP.h);
|
||||
}
|
||||
(MAP.fx || []).forEach((f) => {
|
||||
const k = 1 - f.t / f.life;
|
||||
c.globalAlpha = f.floater ? Math.min(1, k * 1.8) : k;
|
||||
if (f.floater) {
|
||||
c.font = '700 16px "Segoe UI Emoji","Noto Color Emoji",sans-serif';
|
||||
c.textAlign = "center"; c.textBaseline = "middle";
|
||||
c.fillText(f.text, f.x, f.y);
|
||||
} else {
|
||||
c.fillStyle = f.color;
|
||||
c.beginPath(); c.arc(f.x, f.y, f.r, 0, 6.29); c.fill();
|
||||
}
|
||||
c.globalAlpha = 1;
|
||||
});
|
||||
/* 悬停提示框 */
|
||||
const h = MAP.hover;
|
||||
if (h) {
|
||||
let lines = null;
|
||||
if (h.type === "plot") {
|
||||
const p = h.p;
|
||||
lines = p.is_empty ? [p.position + " 号地 · 空地", "点一下播种"]
|
||||
: [p.position + " 号地 · " + (cropEmoji[p.crop] || "") + (p.crop_name || p.crop),
|
||||
p.is_ripe ? "已成熟!点一下收获" : p.stage + " · 还需 " + p.days_left + " 天",
|
||||
"水分 " + p.moisture + "/" + p.water_capacity + (p.moisture === 0 ? " · 点一下浇水" : "")];
|
||||
} else {
|
||||
const a = h.a;
|
||||
lines = ["#" + a.id + " " + (a.name || animalEmoji[a.kind] || a.kind),
|
||||
a.pending > 0 ? (a.product || "产出") + " ×" + a.pending + " · 点一下收取"
|
||||
: a.fed ? "今天已喂食" : "还没喂 · 点一下喂食"];
|
||||
}
|
||||
c.font = '12px "Segoe UI","Microsoft YaHei",sans-serif';
|
||||
const tw = Math.max.apply(null, lines.map((l) => c.measureText(l).width)) + 18;
|
||||
const th = lines.length * 17 + 10;
|
||||
let tx = h.x + 14, ty = h.y + 12;
|
||||
if (tx + tw > MAP.w) tx = h.x - tw - 10;
|
||||
if (ty + th > MAP.h) ty = h.y - th - 10;
|
||||
c.fillStyle = "rgba(28,38,26,.92)";
|
||||
mapRoundRect(tx, ty, tw, th, 7); c.fill();
|
||||
c.fillStyle = "#fff"; c.textAlign = "left"; c.textBaseline = "top";
|
||||
lines.forEach((l, i) => c.fillText(l, tx + 9, ty + 6 + i * 17));
|
||||
}
|
||||
}
|
||||
|
||||
function mapHit(mx, my) {
|
||||
const f = MAP.field, ts = f.ts;
|
||||
const plots = (state && state.plots) || [];
|
||||
for (let i = 0; i < plots.length; i++) {
|
||||
const col = i % f.cols, row = Math.floor(i / f.cols);
|
||||
const x = f.x + col * (ts + f.gap), y = f.y + row * (ts + f.gap);
|
||||
if (mx >= x && mx <= x + ts && my >= y && my <= y + ts) return { type: "plot", p: plots[i] };
|
||||
}
|
||||
const pen = MAP.pen;
|
||||
if (mx >= pen.x && mx <= pen.x + pen.w && my >= pen.y && my <= pen.y + pen.h) {
|
||||
let best = null, bd = 1e9;
|
||||
(MAP.walkers || []).forEach((wd) => {
|
||||
const d = (wd.x - mx) * (wd.x - mx) + (wd.y - my) * (wd.y - my);
|
||||
if (d < bd) { bd = d; best = wd; }
|
||||
});
|
||||
if (best && bd < 22 * 22) return { type: "animal", a: best.a };
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapPoint(ev) {
|
||||
const rect = MAP.cv.getBoundingClientRect();
|
||||
return {
|
||||
x: (ev.clientX - rect.left) * MAP.w / rect.width,
|
||||
y: (ev.clientY - rect.top) * MAP.h / rect.height
|
||||
};
|
||||
}
|
||||
|
||||
function mapBind() {
|
||||
MAP.cv = $("farmmap");
|
||||
MAP.ctx = MAP.cv.getContext("2d");
|
||||
MAP.cv.addEventListener("pointermove", (ev) => {
|
||||
const pt = mapPoint(ev);
|
||||
const hit = mapHit(pt.x, pt.y);
|
||||
MAP.hover = hit ? { type: hit.type, p: hit.p, a: hit.a, x: pt.x, y: pt.y } : null;
|
||||
MAP.cv.style.cursor = hit ? "pointer" : "default";
|
||||
});
|
||||
MAP.cv.addEventListener("pointerleave", () => { MAP.hover = null; MAP.cv.style.cursor = "default"; });
|
||||
MAP.cv.addEventListener("click", (ev) => {
|
||||
const pt = mapPoint(ev);
|
||||
const hit = mapHit(pt.x, pt.y);
|
||||
if (!hit) return;
|
||||
if (hit.type === "plot") {
|
||||
const p = hit.p;
|
||||
if (p.is_ripe) act("harvest", { plot: p.position });
|
||||
else if (p.is_empty) openCropPick(p.position);
|
||||
else if (p.moisture < p.water_capacity) act("water", { plot: p.position });
|
||||
else toast((p.crop_name || p.crop) + " · " + p.stage + ",水分充足");
|
||||
} else {
|
||||
const a = hit.a;
|
||||
if (a.pending > 0) act("collect", { animal_id: a.id });
|
||||
else if (!a.fed) act("feed", { animal_id: a.id });
|
||||
else toast("#" + a.id + " 今天已经喂过啦");
|
||||
}
|
||||
});
|
||||
window.addEventListener("resize", mapFit);
|
||||
let last = 0;
|
||||
(function loop(t) {
|
||||
const dt = Math.min(0.05, (t - last) / 1000 || 0.016);
|
||||
last = t;
|
||||
mapStep(dt, t);
|
||||
mapDraw(t);
|
||||
requestAnimationFrame(loop);
|
||||
})(0);
|
||||
}
|
||||
|
||||
function updateMap() {
|
||||
/* 顺序不能变:先布局(MAP.field/pen 才有值),再绑定(第一帧是同步跑的,
|
||||
布局没好就在 f.ts 上 TypeError,rAF 注册被吞、动画永远起不来) */
|
||||
mapLayout();
|
||||
mapSyncAnimals();
|
||||
if (!MAP.cv) mapBind();
|
||||
mapFit();
|
||||
}
|
||||
|
||||
/* ---- 事件委托:地块按钮 / 牲畜按钮 / 快捷动作 ---- */
|
||||
document.addEventListener("click", (ev) => {
|
||||
const t = ev.target;
|
||||
if (t.tagName !== "BUTTON") return;
|
||||
if (t.dataset.w) act("water", { plot: +t.dataset.w });
|
||||
else if (t.dataset.h) act("harvest", { plot: +t.dataset.h });
|
||||
else if (t.dataset.plant !== undefined) {
|
||||
$("pickmask").classList.remove("show");
|
||||
if (t.dataset.plant && pickPlot) act("plant", { plot: pickPlot, crop: t.dataset.plant });
|
||||
}
|
||||
else if (t.dataset.p) {
|
||||
const plot = +t.dataset.p;
|
||||
const pool = state ? Object.keys(cropNames).filter((k) => {
|
||||
const c = (catalog || { crops: [] }).crops.find((x) => x.key === k);
|
||||
return !c || !c.locked;
|
||||
}) : [];
|
||||
const pick = window.prompt("要种什么?输入编号:\n" +
|
||||
pool.map((k, i) => (i + 1) + ". " + cropNames[k]).join("\n"), "1");
|
||||
const idx = parseInt(pick, 10);
|
||||
if (!idx || idx < 1 || idx > pool.length) return;
|
||||
act("plant", { plot: plot, crop: pool[idx - 1] });
|
||||
openCropPick(+t.dataset.p);
|
||||
}
|
||||
else if (t.dataset.f) act("feed", { animal_id: +t.dataset.f });
|
||||
else if (t.dataset.c) act("collect", { animal_id: +t.dataset.c });
|
||||
@@ -439,25 +961,90 @@ document.addEventListener("click", (ev) => {
|
||||
else if (t.dataset.b) act("build", { key: t.dataset.b });
|
||||
else if (t.dataset.act === "buy_animal") act("buy_animal", { kind: $("buyanimal").value });
|
||||
else if (t.dataset.act === "buy_feed") act("buy_feed", { quantity: 10 });
|
||||
else if (t.dataset.act === "reset") { if (confirm("确定重置牧场?所有进度都会清空")) act("reset"); }
|
||||
else if (t.dataset.act === "reset") {
|
||||
if (t.dataset.armed) {
|
||||
t.dataset.armed = ""; t.textContent = "重置";
|
||||
act("reset");
|
||||
} else {
|
||||
t.dataset.armed = "1"; t.textContent = "再点一次确认";
|
||||
setTimeout(() => { t.dataset.armed = ""; t.textContent = "重置"; }, 3000);
|
||||
}
|
||||
}
|
||||
else if (t.dataset.act) act(t.dataset.act);
|
||||
});
|
||||
|
||||
$("join").addEventListener("click", () => {
|
||||
localStorage.setItem("sf.farm", farmName());
|
||||
localStorage.setItem("sf.token", token());
|
||||
fetch("/api/farms", {
|
||||
method: "POST", headers: headers(),
|
||||
body: JSON.stringify({ farm: farmName() })
|
||||
}).then((r) => r.json()).then((d) => { toast(d.message || ""); });
|
||||
connect();
|
||||
/* 播种选作物:页面内弹层。window.prompt 在 sandbox iframe(内置预览面板)里
|
||||
会被静默屏蔽——不弹窗、直接返回 null,表现为"点播种没反应"。 */
|
||||
let pickPlot = 0;
|
||||
function openCropPick(plot) {
|
||||
if (!state) { toast("还没连接上服务器"); return; }
|
||||
const pool = Object.keys(cropNames).filter((k) => {
|
||||
const c = (catalog || { crops: [] }).crops.find((x) => x.key === k);
|
||||
return !c || !c.locked;
|
||||
});
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Enter") $("join").click(); });
|
||||
if (!pool.length) { toast("还没有可种的作物"); return; }
|
||||
pickPlot = plot;
|
||||
$("picktitle").textContent = plot + " 号地 · 要种什么?";
|
||||
$("pickcrops").innerHTML = pool.map((k) =>
|
||||
'<button data-plant="' + k + '">' + cropNames[k] + "</button>").join("") +
|
||||
'<button class="danger" data-plant="">取消</button>';
|
||||
$("pickmask").classList.add("show");
|
||||
}
|
||||
$("pickmask").addEventListener("click", (ev) => {
|
||||
if (ev.target.id === "pickmask") $("pickmask").classList.remove("show");
|
||||
});
|
||||
|
||||
/* ---- 登录:用户名即牧场身份,Cookie 由服务器签发 ---- */
|
||||
async function whoami() {
|
||||
const d = await api("/api/me");
|
||||
me = (d && d.user) || null;
|
||||
applyIdentity();
|
||||
}
|
||||
function applyIdentity() {
|
||||
$("who").textContent = me ? "🌾 " + me : "未登录";
|
||||
$("logout").classList.toggle("hidden", !me);
|
||||
$("loginmask").classList.toggle("show", !me);
|
||||
if (me) {
|
||||
lsSet("sf.farm", me);
|
||||
connect();
|
||||
} else if (es) {
|
||||
try { es.close(); } catch (e) {}
|
||||
es = null;
|
||||
clearInterval(pollTimer);
|
||||
setStatus("未登录", "");
|
||||
}
|
||||
}
|
||||
async function doLogin() {
|
||||
const name = ($("loginname").value || "").trim();
|
||||
if (!name) { toast("先填一个用户名"); return; }
|
||||
const d = await api("/api/login", {
|
||||
method: "POST", headers: headers(),
|
||||
body: JSON.stringify({ username: name })
|
||||
});
|
||||
if (d && d.ok) { me = d.user; toast(d.message); applyIdentity(); }
|
||||
}
|
||||
$("dologin").addEventListener("click", doLogin);
|
||||
$("loginname").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
|
||||
$("logout").addEventListener("click", async () => {
|
||||
await api("/api/logout", { method: "POST", headers: headers() });
|
||||
me = null;
|
||||
applyIdentity();
|
||||
$("loginname").focus();
|
||||
});
|
||||
/* 倒计时本地走秒:状态只在动作/推送时到达,中间的每一秒自己补 */
|
||||
setInterval(() => {
|
||||
if (tickLeft === null) return;
|
||||
const el = $("tickcell");
|
||||
if (el) el.textContent = fmtLeft(Math.max(0, tickLeft - (Date.now() - tickStamp) / 1000));
|
||||
}, 1000);
|
||||
|
||||
/* ---- 启动 ---- */
|
||||
$("farm").value = localStorage.getItem("sf.farm") || "default";
|
||||
$("token").value = localStorage.getItem("sf.token") || "";
|
||||
connect();
|
||||
$("token").value = lsGet("sf.token") || "";
|
||||
$("token").addEventListener("change", () => {
|
||||
lsSet("sf.token", token());
|
||||
if (me) connect();
|
||||
});
|
||||
whoami();
|
||||
setInterval(renderBoard, 5000);
|
||||
</script>
|
||||
</body>
|
||||
@@ -482,6 +1069,12 @@ def dashboard_text(host: str, port: int) -> str:
|
||||
" -H 'Content-Type: application/json' \\\n"
|
||||
' -d \'{"action":"plant","params":{"plot":1,"crop":"wheat"}}\'\n'
|
||||
"\n"
|
||||
"登录(网页用;脚本可以不带 Cookie 继续 ?farm=)\n"
|
||||
"----------------------------------------------\n"
|
||||
'POST /api/login {"username":"名字"} → 设置 Cookie,登录后只能操作自己的牧场\n'
|
||||
"GET /api/me 当前登录用户(未登录为 null)\n"
|
||||
"POST /api/logout 退出登录\n"
|
||||
"\n"
|
||||
"读取类\n"
|
||||
"------\n"
|
||||
"GET /api/world 世界概览 + 排行榜\n"
|
||||
|
||||
+7
-8
@@ -54,7 +54,6 @@ CREATE TABLE IF NOT EXISTS farms (
|
||||
day INTEGER NOT NULL,
|
||||
gold NUMERIC NOT NULL,
|
||||
level INTEGER NOT NULL,
|
||||
ap INTEGER NOT NULL,
|
||||
weather TEXT NOT NULL,
|
||||
forecast TEXT NOT NULL,
|
||||
net_worth NUMERIC NOT NULL DEFAULT 0,
|
||||
@@ -112,6 +111,10 @@ def connect(path: str | None = None) -> sqlite3.Connection:
|
||||
if fresh:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.executescript(_SCHEMA)
|
||||
# v2 时代的库里留着已废弃的 ap 列(行动点从玩法里移除了),老库在此无痛迁移。
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(farms)")}
|
||||
if "ap" in cols:
|
||||
conn.execute("ALTER TABLE farms DROP COLUMN ap")
|
||||
except Exception:
|
||||
# 建表失败(比如这根本不是个 SQLite 文件)也要把句柄还回去。
|
||||
# Windows 上文件句柄没关就删不掉,而调用方正是靠"读失败→开新档"来容错的。
|
||||
@@ -131,9 +134,9 @@ def save_state(state: GameState, path: str | None = None, farm_id: str = DEFAULT
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO farms
|
||||
(farm_id, version, saved_at, updated_at, day, gold, level, ap,
|
||||
(farm_id, version, saved_at, updated_at, day, gold, level,
|
||||
weather, forecast, net_worth, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(farm_id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
saved_at = excluded.saved_at,
|
||||
@@ -141,7 +144,6 @@ def save_state(state: GameState, path: str | None = None, farm_id: str = DEFAULT
|
||||
day = excluded.day,
|
||||
gold = excluded.gold,
|
||||
level = excluded.level,
|
||||
ap = excluded.ap,
|
||||
weather = excluded.weather,
|
||||
forecast = excluded.forecast,
|
||||
net_worth = excluded.net_worth,
|
||||
@@ -155,7 +157,6 @@ def save_state(state: GameState, path: str | None = None, farm_id: str = DEFAULT
|
||||
state.day,
|
||||
state.gold,
|
||||
state.level,
|
||||
state.ap,
|
||||
state.weather,
|
||||
state.forecast,
|
||||
state.net_worth,
|
||||
@@ -174,7 +175,6 @@ def _raw_state(state: GameState) -> dict[str, Any]:
|
||||
"gold": state.gold,
|
||||
"level": state.level,
|
||||
"exp": state.exp,
|
||||
"ap": state.ap,
|
||||
"feed": state.feed,
|
||||
"inventory": dict(state.inventory),
|
||||
"buildings": dict(state.buildings),
|
||||
@@ -285,7 +285,7 @@ def list_farms(path: str | None = None) -> list[dict[str, Any]]:
|
||||
with contextlib.closing(connect(path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT farm_id, day, gold, level, ap, weather, forecast,
|
||||
SELECT farm_id, day, gold, level, weather, forecast,
|
||||
net_worth, saved_at
|
||||
FROM farms
|
||||
ORDER BY net_worth DESC, day DESC, farm_id
|
||||
@@ -300,7 +300,6 @@ def list_farms(path: str | None = None) -> list[dict[str, Any]]:
|
||||
"day": row["day"],
|
||||
"gold": row["gold"],
|
||||
"level": row["level"],
|
||||
"ap": row["ap"],
|
||||
"weather": row["weather"],
|
||||
"forecast": row["forecast"],
|
||||
"net_worth": row["net_worth"],
|
||||
|
||||
-12
@@ -630,10 +630,6 @@ class TuiApp:
|
||||
net_worth = snap["net_worth"]
|
||||
goal = snap["next_goal"]
|
||||
|
||||
ap_ratio = snap["ap"] / max(1, snap["max_ap"])
|
||||
ap_color = C_GREEN if ap_ratio > 0.4 else (C_ORANGE if ap_ratio > 0 else C_RED)
|
||||
ap_bar = bar(snap["ap"], snap["max_ap"], 10)
|
||||
|
||||
line1 = (
|
||||
color(f" {snap['gold']:>5}", C_BOLD + C_GOLD, self.use_color)
|
||||
+ color(" 金币", C_GRAY, self.use_color)
|
||||
@@ -641,10 +637,6 @@ class TuiApp:
|
||||
+ color(f"Lv.{snap['level']}", C_BOLD + C_PURPLE, self.use_color)
|
||||
+ color(f" {snap['exp']}/{snap['exp_needed']} exp", C_GRAY, self.use_color)
|
||||
+ " "
|
||||
+ color("行动点 ", C_GRAY, self.use_color)
|
||||
+ color(ap_bar, ap_color, self.use_color)
|
||||
+ color(f" {snap['ap']}/{snap['max_ap']}", C_WHITE, self.use_color)
|
||||
+ " "
|
||||
+ color(f"饲料 {snap['feed']}", C_ORANGE, self.use_color)
|
||||
)
|
||||
|
||||
@@ -955,10 +947,6 @@ class TuiApp:
|
||||
C_GRAY,
|
||||
self.use_color,
|
||||
),
|
||||
color(" 行动点", C_BOLD + C_GOLD, self.use_color)
|
||||
+ color(
|
||||
" 每天 10 点,播种/浇水/喂食各消耗 1 点,收获和收取免费。", C_GRAY, self.use_color
|
||||
),
|
||||
color(" 牲畜", C_BOLD + C_GOLD, self.use_color)
|
||||
+ color(
|
||||
" 必须每天喂食才会产出;连续 3 天没吃东西会离开农场。", C_GRAY, self.use_color
|
||||
|
||||
@@ -444,7 +444,7 @@ def cmd_do(args) -> int:
|
||||
if state:
|
||||
print(
|
||||
f" 第 {state.get('day')} 天 · 金币 {state.get('gold')} · "
|
||||
f"行动点 {state.get('ap')}/{state.get('max_ap')} · 净资产 {state.get('net_worth')}"
|
||||
f"净资产 {state.get('net_worth')}"
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -480,11 +480,10 @@ def cmd_actions(args) -> int:
|
||||
items = client.available_actions()
|
||||
print(
|
||||
f"牧场 {client.farm} · 第 {state.get('day')} 天 · 金币 {state.get('gold')} · "
|
||||
f"行动点 {state.get('ap')}/{state.get('max_ap')}"
|
||||
f"净资产 {state.get('net_worth')}"
|
||||
)
|
||||
if not items:
|
||||
print(warn("• 当前没有可执行的动作"))
|
||||
print(dim(" 多半是行动点用完了:时间走完一天就会恢复(默认一天 40 分钟)"))
|
||||
print(dim(" 也可能是作物还没熟:uv run main.py state 看进度"))
|
||||
return 0
|
||||
print(f"共 {len(items)} 个候选动作:\n")
|
||||
@@ -522,7 +521,7 @@ def cmd_state(args) -> int:
|
||||
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('feed')}")
|
||||
print(f"净资产 {snap.get('net_worth')}")
|
||||
print(f"土地 {snap.get('plot_count')}/{snap.get('max_plots')} 块")
|
||||
|
||||
@@ -589,7 +588,7 @@ def cmd_watch(args) -> int:
|
||||
capacity = snap.get("granary_capacity") or 0
|
||||
head = (
|
||||
f"第 {day_no} 天 {weather} | 金币 {snap.get('gold')} | "
|
||||
f"Lv.{snap.get('level')} | 行动点 {snap.get('ap')}/{snap.get('max_ap')} | "
|
||||
f"Lv.{snap.get('level')} | "
|
||||
f"仓储 {used}/{capacity} | 净资产 {snap.get('net_worth')}"
|
||||
)
|
||||
if online is not None:
|
||||
|
||||
@@ -304,7 +304,6 @@ class HarvestAllTestCase(unittest.TestCase):
|
||||
run_action(self.engine, "plant", {"plot": number, "crop": "wheat"})
|
||||
for index in (0, 1):
|
||||
self.engine.state.plots[index].growth = 2
|
||||
self.engine.state.ap = 10
|
||||
|
||||
# 把第一块地做成"看起来熟了、实际收不了"(作物键被改坏)
|
||||
self.engine.state.plots[0].crop = "wheat"
|
||||
|
||||
+11
-16
@@ -64,7 +64,6 @@ BASE_STATE = {
|
||||
"day": 1,
|
||||
"gold": 1000,
|
||||
"feed": 20,
|
||||
"ap": 10,
|
||||
"next_plot_price": 200,
|
||||
"paused": False,
|
||||
"seconds_to_tick": 120.0,
|
||||
@@ -123,11 +122,10 @@ class ScoreTestCase(unittest.TestCase):
|
||||
self.assertEqual(self.score(action, gold=599, next_plot_price=200), 4.0)
|
||||
self.assertEqual(self.score(action, gold=9999, next_plot_price=-1), 4.0, "没地可买")
|
||||
|
||||
def test_animals_need_a_cash_buffer_and_a_free_action_point(self):
|
||||
def test_animals_need_a_cash_buffer(self):
|
||||
action = {"action": "buy_animal", "params": {"kind": "chicken"}}
|
||||
self.assertEqual(self.score(action, gold=270, ap=1), 39.0)
|
||||
self.assertEqual(self.score(action, gold=269, ap=1), 3.0)
|
||||
self.assertEqual(self.score(action, gold=1000, ap=0), 3.0, "没行动点就别想")
|
||||
self.assertEqual(self.score(action, gold=270), 39.0)
|
||||
self.assertEqual(self.score(action, gold=269), 3.0)
|
||||
|
||||
def test_unknown_actions_have_the_lowest_score(self):
|
||||
self.assertEqual(self.score({"action": "fly_to_moon"}), 1.0)
|
||||
@@ -196,7 +194,7 @@ class StepTestCase(unittest.TestCase):
|
||||
state=BASE_STATE,
|
||||
catalog=CATALOG,
|
||||
)
|
||||
client.fail_with = GameError("no_ap", "行动点不足")
|
||||
client.fail_with = GameError("not_enough_gold", "金币不足")
|
||||
bot = make_bot(client)
|
||||
self.assertIsNone(bot.step())
|
||||
self.assertEqual(bot.failures, 1)
|
||||
@@ -208,7 +206,7 @@ class StepTestCase(unittest.TestCase):
|
||||
state=BASE_STATE,
|
||||
catalog=CATALOG,
|
||||
)
|
||||
client.fail_with = GameError("no_ap", "行动点不足")
|
||||
client.fail_with = GameError("not_enough_gold", "金币不足")
|
||||
bot = make_bot(client)
|
||||
bot.step()
|
||||
client.fail_with = None
|
||||
@@ -290,13 +288,11 @@ class RunTestCase(unittest.TestCase):
|
||||
summary = make_bot(client).run(rounds=1)
|
||||
self.assertEqual(set(summary) >= {"name", "days", "actions", "failures", "net_worth"}, True)
|
||||
|
||||
def test_no_action_points_means_clocking_out(self):
|
||||
# AP 用完时,服务端的 available_actions 只会剩下 0 消耗的动作
|
||||
# (卖货/建造/收获/收取),这里模拟"连这些都没有"的极端情况:
|
||||
# 机器人应当直接收工,而不是空转或乱发请求。
|
||||
def test_no_actions_means_clocking_out(self):
|
||||
# 没有任何可执行动作时机器人应当直接收工,而不是空转或乱发请求。
|
||||
client = FakeClient(
|
||||
actions=[],
|
||||
state={**BASE_STATE, "ap": 0},
|
||||
state=BASE_STATE,
|
||||
catalog=CATALOG,
|
||||
)
|
||||
bot = make_bot(client)
|
||||
@@ -304,12 +300,11 @@ class RunTestCase(unittest.TestCase):
|
||||
self.assertEqual(summary["actions"], 0)
|
||||
self.assertEqual(client.calls, [], "没得做就不该发动作请求")
|
||||
|
||||
def test_free_actions_still_run_without_action_points(self):
|
||||
# 新设计:收获不再直接给钱,得靠 sell 把谷仓里的货变现;而 sell/build
|
||||
# 是 0 消耗动作,AP=0 时也必须能执行,否则一整天只能干等着。
|
||||
def test_sell_still_runs_when_idle(self):
|
||||
# 收获不再直接给钱,得靠 sell 把谷仓里的货变现;sell 随时可执行。
|
||||
client = FakeClient(
|
||||
actions=[{"action": "sell", "params": {}}],
|
||||
state={**BASE_STATE, "ap": 0},
|
||||
state=BASE_STATE,
|
||||
catalog=CATALOG,
|
||||
)
|
||||
bot = make_bot(client)
|
||||
|
||||
@@ -227,10 +227,9 @@ class StateViewTestCase(unittest.TestCase):
|
||||
view = StateView(
|
||||
{"day": 1, "gold": 100, "plots": [plot_dict(), plot_dict(index=1)], "animals": []}
|
||||
)
|
||||
view.apply({"gold": 60, "ap": 5})
|
||||
view.apply({"gold": 60})
|
||||
|
||||
self.assertEqual(view.gold, 60)
|
||||
self.assertEqual(view.ap, 5)
|
||||
self.assertEqual(view.day, 1, "没提到的字段要保留")
|
||||
self.assertEqual(len(view.plots), 2, "一次播种不该让农田消失")
|
||||
|
||||
@@ -286,13 +285,12 @@ class StateViewTestCase(unittest.TestCase):
|
||||
"gold": 320,
|
||||
"level": 4,
|
||||
"exp": 11,
|
||||
"ap": 2,
|
||||
"feed": 9,
|
||||
"market": {"corn": 12},
|
||||
}
|
||||
)
|
||||
self.assertEqual((view.day, view.gold, view.level), (5, 320, 4))
|
||||
self.assertEqual((view.exp, view.ap, view.feed), (11, 2, 9))
|
||||
self.assertEqual((view.exp, view.feed), (11, 9))
|
||||
self.assertEqual(view.market["corn"], 12)
|
||||
|
||||
def test_get_and_item_access(self):
|
||||
|
||||
+102
-24
@@ -160,7 +160,6 @@ class GameStateTestCase(unittest.TestCase):
|
||||
state = GameState()
|
||||
self.assertEqual(state.day, 1)
|
||||
self.assertEqual(state.gold, config.START_GOLD)
|
||||
self.assertEqual(state.ap, config.START_AP)
|
||||
self.assertEqual(len(state.plots), config.BASE_PLOTS)
|
||||
self.assertEqual(len(state.animals), 0)
|
||||
|
||||
@@ -209,10 +208,9 @@ class GameStateTestCase(unittest.TestCase):
|
||||
self.assertIsNone(state.next_goal)
|
||||
|
||||
def test_from_dict_clamps_hostile_values(self):
|
||||
"""存档是外部输入:负数金币、越界行动点都要被夹回来。"""
|
||||
state = GameState.from_dict({"gold": -500, "ap": 9999, "level": 0, "day": -3})
|
||||
"""存档是外部输入:负数金币、越界等级都要被夹回来。"""
|
||||
state = GameState.from_dict({"gold": -500, "level": 0, "day": -3})
|
||||
self.assertEqual(state.gold, 0)
|
||||
self.assertEqual(state.ap, state.max_ap)
|
||||
self.assertEqual(state.level, 1)
|
||||
self.assertEqual(state.day, 1)
|
||||
|
||||
@@ -472,12 +470,11 @@ class ActionTestCase(unittest.TestCase):
|
||||
self.assertEqual(ctx.exception.status, 400)
|
||||
|
||||
# ------------------------------------------------------------------ 播种
|
||||
def test_plant_costs_gold_and_action_point(self):
|
||||
gold, ap = self.engine.state.gold, self.engine.state.ap
|
||||
def test_plant_costs_gold(self):
|
||||
gold = self.engine.state.gold
|
||||
result = self.engine.plant(1, "wheat")
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(self.engine.state.gold, gold - config.CROPS["wheat"]["seed_cost"])
|
||||
self.assertEqual(self.engine.state.ap, ap - config.AP_COST["plant"])
|
||||
|
||||
def test_plant_sets_initial_moisture_and_planted_day(self):
|
||||
self.engine.state.day = 4
|
||||
@@ -511,11 +508,6 @@ class ActionTestCase(unittest.TestCase):
|
||||
self.engine.water(1)
|
||||
self.assertEqual(self.engine.state.plots[0].moisture, config.WATER_CAPACITY)
|
||||
|
||||
def test_water_needs_ap(self):
|
||||
self.engine.plant(1, "wheat")
|
||||
self.engine.state.ap = 0
|
||||
self.assert_code("no_ap", self.engine.water, 1)
|
||||
|
||||
def test_water_rejects_full_and_empty_plots(self):
|
||||
self.assert_code("plot_empty", self.engine.water, 1)
|
||||
self.engine.plant(2, "wheat")
|
||||
@@ -650,7 +642,6 @@ class ActionTestCase(unittest.TestCase):
|
||||
self.engine.state.level = 99
|
||||
self.engine.state.gold = 10**9
|
||||
while self.engine.state.next_plot_price > 0:
|
||||
self.engine.state.ap = 10
|
||||
self.engine.buy_plot()
|
||||
self.assertEqual(len(self.engine.state.plots), config.MAX_PLOTS)
|
||||
self.assert_code("plot_limit", self.engine.buy_plot)
|
||||
@@ -760,15 +751,110 @@ 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()
|
||||
|
||||
def test_day_increments_and_action_points_refill(self):
|
||||
self.engine.state.ap = 0
|
||||
def test_day_increments(self):
|
||||
self.engine.advance_day()
|
||||
self.assertEqual(self.engine.state.day, 2)
|
||||
self.assertEqual(self.engine.state.ap, config.START_AP)
|
||||
|
||||
def test_fed_animals_make_progress_and_get_hungry_again(self):
|
||||
self.engine.buy_animal("chicken") # cycle 1
|
||||
@@ -941,19 +1027,11 @@ class AvailableActionsTestCase(unittest.TestCase):
|
||||
self.assertIn("collect", actions)
|
||||
self.assertNotIn("feed", actions)
|
||||
|
||||
def test_action_point_shortage_hides_action_point_actions(self):
|
||||
self.engine.state.ap = 0
|
||||
names = self.names()
|
||||
self.assertNotIn("plant", names)
|
||||
self.assertNotIn("water", names)
|
||||
self.assertNotIn("feed", names)
|
||||
|
||||
def test_actions_explain_themselves(self):
|
||||
for item in self.engine.available_actions():
|
||||
with self.subTest(action=item["action"]):
|
||||
self.assertTrue(item["reason"])
|
||||
self.assertIn("gold", item["cost"])
|
||||
self.assertIn("ap", item["cost"])
|
||||
|
||||
|
||||
class LogTestCase(unittest.TestCase):
|
||||
|
||||
+119
-6
@@ -24,6 +24,7 @@ import urllib.request
|
||||
|
||||
from farm import config, net
|
||||
from farm.engine import GameError
|
||||
from farm.server.auth import COOKIE_NAME, LoginManager
|
||||
from farm.server.daemon import DaemonManager, ensure_parent_dir, pid_alive, port_busy
|
||||
from farm.server.hub import (
|
||||
CLOSE_MESSAGE,
|
||||
@@ -53,6 +54,7 @@ try: # pragma: no cover - 环境相关
|
||||
ServerConfig,
|
||||
ServerHandle,
|
||||
_int,
|
||||
_Services,
|
||||
as_asgi,
|
||||
brief,
|
||||
create_app,
|
||||
@@ -216,6 +218,23 @@ class FarmSessionTestCase(unittest.TestCase):
|
||||
self.assertEqual(reopened.engine.state.gold, 777)
|
||||
self.assertIn("第 9 天", reopened.load_message)
|
||||
|
||||
def test_autosave_survives_the_push_loop_clearing_dirty(self):
|
||||
"""回归:dirty 是推送节流用的,Publisher ~250ms 就清一次;自动存档
|
||||
如果也看 dirty,就等于永不存盘(进程被强杀进度全丢)。它必须看
|
||||
只有成功的 save() 才清的 unsaved。"""
|
||||
session = FarmSession("alice", self.dir, 2400.0)
|
||||
session.touch()
|
||||
session.dirty = False # 模拟 Publisher 清推送标记
|
||||
self.assertTrue(session.autosave(), "推送循环清了 dirty 也要照常落盘")
|
||||
reopened = FarmSession("alice", self.dir, 2400.0)
|
||||
self.assertIn("已载入存档", reopened.load_message)
|
||||
|
||||
def test_autosave_skips_when_nothing_changed_since_last_save(self):
|
||||
session = FarmSession("alice", self.dir, 2400.0)
|
||||
session.touch()
|
||||
self.assertTrue(session.autosave())
|
||||
self.assertFalse(session.autosave(), "没有新变化就不该重复写库")
|
||||
|
||||
def test_broken_save_degrades_to_a_new_farm(self):
|
||||
path = os.path.join(self.dir, DB_NAME)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
@@ -247,13 +266,13 @@ class FarmSessionTestCase(unittest.TestCase):
|
||||
|
||||
def test_autosave_skips_when_clean(self):
|
||||
session = FarmSession("alice", self.dir, 2400.0)
|
||||
session.dirty = False
|
||||
session.unsaved = False
|
||||
session.saved_at = 0.0
|
||||
self.assertFalse(session.autosave())
|
||||
|
||||
def test_autosave_writes_when_dirty(self):
|
||||
def test_autosave_writes_when_unsaved(self):
|
||||
session = FarmSession("alice", self.dir, 2400.0)
|
||||
session.dirty = True
|
||||
session.unsaved = True
|
||||
session.saved_at = 0.0
|
||||
self.assertTrue(session.autosave())
|
||||
self.assertTrue(farm_exists(session.db_path, session.farm_id))
|
||||
@@ -377,8 +396,8 @@ class SessionManagerTestCase(unittest.TestCase):
|
||||
manager.get("alice")
|
||||
bob = manager.get("bob")
|
||||
alice = manager.get("alice")
|
||||
alice.dirty = True
|
||||
bob.dirty = False
|
||||
alice.unsaved = True
|
||||
bob.unsaved = False
|
||||
alice.saved_at = bob.saved_at = 0.0
|
||||
self.assertEqual(manager.autosave_all(), 1)
|
||||
|
||||
@@ -862,7 +881,6 @@ class RestApiTestCase(unittest.TestCase):
|
||||
|
||||
catalog = self.client.get("/api/catalog?farm=alice").json()["catalog"]
|
||||
self.assertEqual(len(catalog["crops"]), len(config.CROPS))
|
||||
self.assertIn("ap_cost", catalog)
|
||||
|
||||
def test_action_then_read_it_back(self):
|
||||
response = self.client.post(
|
||||
@@ -1094,6 +1112,28 @@ class AuthTestCase(unittest.TestCase):
|
||||
response = self.client.post("/api/actions?farm=alice", json={"action": "buy_feed"})
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_login_sets_cookie_and_me_reports_it(self):
|
||||
response = self.client.post("/api/login?token=s3cret", json={"username": "carol"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["user"], "carol")
|
||||
self.assertEqual(self.client.get("/api/me").json()["user"], "carol")
|
||||
|
||||
def test_login_needs_the_token_too(self):
|
||||
# 令牌是整台服务器的门,登录不豁免
|
||||
self.assertEqual(
|
||||
self.client.post("/api/login", json={"username": "carol"}).status_code, 401
|
||||
)
|
||||
|
||||
def test_logged_in_user_cannot_reach_other_farms(self):
|
||||
self.client.post("/api/login?token=s3cret", json={"username": "carol"})
|
||||
data = self.client.get("/api/state?token=s3cret&farm=boss").json()
|
||||
self.assertEqual(data["farm"], "carol")
|
||||
|
||||
def test_logout_clears_the_cookie(self):
|
||||
self.client.post("/api/login?token=s3cret", json={"username": "carol"})
|
||||
self.client.post("/api/logout?token=s3cret")
|
||||
self.assertIsNone(self.client.get("/api/me").json()["user"])
|
||||
|
||||
def test_remote_shutdown_can_be_switched_off(self):
|
||||
app = create_app(
|
||||
ServerConfig(
|
||||
@@ -1105,6 +1145,79 @@ class AuthTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI")
|
||||
class LoginCookieTestCase(unittest.TestCase):
|
||||
"""登录 Cookie 的签发与校验(纯逻辑档,不需要 httpx 也能跑)。"""
|
||||
|
||||
def setUp(self):
|
||||
from fastapi import Request
|
||||
|
||||
self.dir = tempfile.mkdtemp(prefix="simplefarm-login-")
|
||||
self.auth = LoginManager(self.dir)
|
||||
self._request_cls = Request
|
||||
|
||||
def request(self, cookie: str | None = None, query: bytes = b"", headers=()):
|
||||
raw = [(b"host", b"test")]
|
||||
if cookie:
|
||||
raw.append((b"cookie", cookie.encode("ascii")))
|
||||
raw.extend(headers)
|
||||
return self._request_cls(
|
||||
{"type": "http", "headers": raw, "query_string": query, "method": "GET"}
|
||||
)
|
||||
|
||||
def test_issue_then_verify_roundtrips_the_username(self):
|
||||
self.assertEqual(self.auth.verify(self.auth.issue("小明_2")), "小明_2")
|
||||
|
||||
def test_tampered_signature_is_rejected(self):
|
||||
token = self.auth.issue("carol")
|
||||
sig = token.rpartition(".")[2]
|
||||
self.assertIsNone(self.auth.verify(token[:-1] + ("0" if sig[-1] != "0" else "1")))
|
||||
|
||||
def test_token_from_another_secret_is_rejected(self):
|
||||
other = LoginManager(tempfile.mkdtemp(prefix="simplefarm-login2-"))
|
||||
self.assertIsNone(other.verify(self.auth.issue("carol")))
|
||||
|
||||
def test_expired_token_is_rejected(self):
|
||||
from farm.server import auth as auth_mod
|
||||
|
||||
saved = auth_mod.TTL_SECONDS
|
||||
auth_mod.TTL_SECONDS = -10 # 签出来就是过期的
|
||||
try:
|
||||
token = self.auth.issue("carol")
|
||||
finally:
|
||||
auth_mod.TTL_SECONDS = saved
|
||||
self.assertIsNone(self.auth.verify(token))
|
||||
|
||||
def test_garbage_tokens_are_rejected(self):
|
||||
for bad in ("", "abc", "a.b", "!!!.###", self.auth.issue("carol") + "x"):
|
||||
self.assertIsNone(self.auth.verify(bad))
|
||||
|
||||
def test_illegal_username_is_refused_at_issue(self):
|
||||
with self.assertRaises(GameError):
|
||||
self.auth.issue("有 空格")
|
||||
with self.assertRaises(GameError):
|
||||
self.auth.issue("x" * 30)
|
||||
|
||||
def test_secret_file_is_reused_across_restarts(self):
|
||||
again = LoginManager(self.dir)
|
||||
self.assertEqual(again.verify(self.auth.issue("carol")), "carol")
|
||||
|
||||
def test_session_of_prefers_the_cookie_over_the_url(self):
|
||||
svc = _Services.create(ServerConfig(save_dir=self.dir, tick_seconds=2400.0))
|
||||
cookie = f"{COOKIE_NAME}={self.auth.issue('carol')}"
|
||||
self.assertEqual(
|
||||
svc.session_of(self.request(cookie=cookie, query=b"farm=boss")).farm_id, "carol"
|
||||
)
|
||||
|
||||
def test_session_of_falls_back_without_a_cookie(self):
|
||||
svc = _Services.create(ServerConfig(save_dir=self.dir, tick_seconds=2400.0))
|
||||
self.assertEqual(svc.session_of(self.request(query=b"farm=boss")).farm_id, "boss")
|
||||
self.assertEqual(
|
||||
svc.session_of(self.request(headers=[(b"x-farm-id", b"boss")])).farm_id, "boss"
|
||||
)
|
||||
self.assertEqual(svc.session_of(self.request()).farm_id, DEFAULT_FARM)
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_UVICORN and HAS_FASTAPI, "未安装 uvicorn / FastAPI")
|
||||
class LiveServerTestCase(unittest.TestCase):
|
||||
"""真起一个服务器进程内的 uvicorn,用真 socket 说话。"""
|
||||
|
||||
+10
-1
@@ -36,6 +36,16 @@
|
||||
|
||||
一次 ``POST /api/actions?full=1`` 的重复工作量:``to_dict`` 2 → 1 次、
|
||||
``snapshot`` 2 → 1 次、``net_worth`` 6 → 2 次。
|
||||
|
||||
2026-09-26 那次优化(to_dict 里重复求值的属性改局部复用:Plot 的
|
||||
grow_days 4→1 次、Animal 的 info 查表 4→1 次、GameState 的仓储三兄弟
|
||||
只算一轮)前后,单次 snapshot 的函数调用数 1,128,001 → 588,001(-48%)::
|
||||
|
||||
场景 优化前 优化后
|
||||
engine.snapshot() 50.1 µs 44.2 µs -12%
|
||||
session.snapshot() 51.0 µs 41.9 µs -18%
|
||||
build_state_message() 50.9 µs 43.3 µs -15%
|
||||
engine.available_actions 52.7 µs 48.7 µs -8%
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -76,7 +86,6 @@ def fill_endgame(engine: FarmEngine) -> None:
|
||||
state = engine.state
|
||||
state.level = 6 # 6 + 2*(6-1) = 16,正好顶到 MAX_PLOTS
|
||||
state.gold = 50000
|
||||
state.ap = config.START_AP
|
||||
state.feed = 40
|
||||
state.plots = [Plot(i) for i in range(config.MAX_PLOTS)]
|
||||
for i, plot in enumerate(state.plots):
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ def run(endpoint: str, farm: str, token: str | None, seconds: float) -> int:
|
||||
snapshot = message.get("state", {})
|
||||
print(
|
||||
f"← state(seq={message.get('seq')}):第 {snapshot.get('day')} 天 · "
|
||||
f"金币 {snapshot.get('gold')} · 行动点 {snapshot.get('ap')} · "
|
||||
f"金币 {snapshot.get('gold')} · "
|
||||
f"在线 {message.get('online')}"
|
||||
)
|
||||
if acted:
|
||||
|
||||
Reference in new issue
Block a user