Compare commits

..
10 Commits
Author SHA1 Message Date
snowy-yang a9bc8238ab 序列化热路径去重:to_dict 里重复求值的属性改局部复用
为什么:snapshot() 同时压在 SSE 每 250ms 推送和 full=1 动作回执两条
高频路径上,而它的成本大头是重复求值——profile 显示单次快照
1,128,001 次函数调用里近半是属性链重复计算同一份配置数据。

- Plot.to_dict:grow_days 原来经 4 个属性各求值一遍(本体 + days_left
  + is_ripe + stage),16 块地就是 64 次配置表查询。改为算一次局部
  复用;stage 的实现体拆到 _stage(grow),规则本体仍只有一份。
- Animal.to_dict:info 配置表原来查 4 次、cycle int() 两遍,改取一次。
- GameState.to_dict:weather_info 3 键 2 次查询;granary_free 原来把
  granary_capacity 和 storage_used 整个重算,改算一轮复用。

行为零变化(规则本体全在原属性里,532 项测试全绿)。
基准(tools/bench.py --repeat 5,最坏形态 16 地 + 8 牲畜):

    场景                       优化前      优化后
    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%

单次 snapshot 函数调用数 1,128,001 → 588,001(-48%),本次数字已记入
bench.py docstring 作长期参考。重复计数器全 OK(to_dict/snapshot/
net_worth 每请求仍各 1 次,无回归)。
2026-09-26 11:08:14 +08:00
snowy-yang 4bc7729715 地图手感打磨:动作粒子 + 昼夜色温 + 成熟呼吸金光
为什么:地图功能已全通但缺"游戏手感"——点击有反馈(toast)却看不到
画面上的动静,时间流动也只在倒计时数字里。这一轮全部补上。

- 动作粒子特效(webui):act() 成功后在目标位置喷粒子 + 上浮表情小字
  (浇水💧水花、播种🌱扬土、收获/收取✨金点、喂食🍽)。粒子受重力、
  按寿命衰减,失败动作不触发(api() 返回 null 时 mapJuice 不执行)。
- 昼夜色温:一天的进度(1 - seconds_to_tick/tick_seconds)映射成
  清晨淡金 → 正午不叠色 → 黄昏橙 → 入夜靛紫的半透明覆盖层,
  让"40 分钟一天"在画面光色上也流动。tick_seconds 完整快照里有、
  brief 里没有,靠 refreshFromBrief 的合并语义保留。
- 成熟作物金光改呼吸式(shadowBlur 随 sin 起伏),悬停可交互对象时
  指针变手型。

验证:532 项测试全绿、ruff/pyright 干净;真机 headless Chrome 实测
粒子注入 23 枚 2 秒内衰减归零、失败动作不产生特效、dayTint 三时段
数值正确、截图目检清晨色调正常、控制台零报错。
2026-09-25 23:38:40 +08:00
snowy-yang 15d290a60a 移除行动点系统,倒计时本地走秒,字体优化
为什么:游戏的时间(40 分钟一天、只能自然流动)本身就是唯一的行动节奏
阀门,再叠一层"每天 10 点行动点"纯属重复惩罚——浇水还要体力既不拟真
也不好玩。删掉之后,稀缺的只剩时间本身,放置玩法的定位反而更纯粹。

改动范围(引擎到界面连根拔):
- config 删 START_AP/AP_COST;models 删 ap 字段与 max_ap,存档 payload
  里的旧 ap 键被 from_dict 自然忽略;
- engine 删 _spend_ap 与全部 8 个调用点、每日恢复逻辑与日志;
  available_actions 不再按行动点筛,cost 只剩金币;
- storage 建表去掉 ap 列,老库在 connect() 里 ALTER TABLE DROP COLUMN
  无痛迁移(实测 saves/farm.db 已自动迁完);
- bot 删 AP 分支(无 AP 后 available_actions 恒可用);client/TUI/CLI/
  README/帮助文本同步清理。

网页端顺带两件事:
- "下一天"倒计时改为客户端本地走秒——SSE 只在有变化时推送,以前倒计时
  只在推送瞬间跳变,现在每秒都在动(记基点 + 1s 定时器补齐);
- 字体优化:页面字体栈补齐 MiSans/HarmonyOS Sans/PingFang,正文字号
  14→15,统计数字 tabular-nums;canvas 小字统一字体栈并加粗提对比度。

测试适配:删 2 个 AP 专属用例(无 AP 可测)、改名/收敛 5 个,
532 项全绿(skip 39),ruff/pyright 干净。真机验证:老库迁移、
统计条无行动点、倒计时逐秒递减(39:04→39:02)、动作不再报 no_ap。
2026-09-25 22:44:08 +08:00
snowy-yang 92b1a628b1 网页端新增 Canvas 二维农场地图
为什么:地块和牲畜原本是响应式卡片列表,信息全但没有"农场"的形状感。
改成一张俯视像素风地图作主视觉:农田按 4 列排成方阵(地块多自动改 6 列),
土壤颜色随水分深浅变化、作物随生长进度放大、成熟发金光、缺水画干裂;
牧场围栏里牲畜随机漫步,待收产出挂金色气泡、没喂食标 💢;
已拥有的建筑在底部建筑带列队;五种天气各有天色,雨/暴雨有雨滴动画。
悬停出提示框,点地块即播种/浇水/收获、点牲畜即喂食/收取——全部复用
既有 openCropPick/act() 流程,卡片面板原样保留作精确操作入口。

实现要点:画布用逻辑坐标绘制、按容器宽 × devicePixelRatio 缩放,
高分屏不糊、窄屏等比缩;命中检测在逻辑坐标上自己算。
服务端零改动——state 里的 plots/animals/buildings 现成够用。

修复一个真机才抓得到的初始化 bug:动画循环首帧同步执行,原顺序
"先绑定后布局"让首帧在未初始化的字段上 TypeError,rAF 注册被一起吞掉,
表现为画布全白但点击命中正常(命中不经过画布)。改为先布局再绑定,
并给 mapStep/mapDraw 加空值护栏。

真浏览器端到端验证:登录、canvas 坐标点击浇水(AP/水分/toast 全对)、
悬停提示、动画循环存活(云位移 5.24px/600ms)、截图目检渲染效果,
errors/console 干净。534 项测试全绿,ruff/pyright 无告警。
2026-09-25 22:24:08 +08:00
snowy-yang cb4c1e7800 feat(engine): 四座自动化设施——喷灌 / 收割机 / 售货机 / 磨坊
为什么:自动化建筑只有水井和喂食器,中期以后每天的操作只剩浇水喂食
的重复劳动。新设施把"替代行动点动作"的思路推到完整流水线:
浇水(井/喷灌)→ 收割机收熟地 → 磨坊把陈粮变饲料 → 喂食器喂牲口
→ 售货机趁高价清库存,人不在也能持续产出。

- config.BUILDINGS 注册 4 座:喷灌系统 2800 金(全量浇水)、
  自动收割机 900 金 ×3(每天各收 2 块熟地,刻意不给经验——机器收的
  不算劳动,给手动玩法留价值)、自动售货机 1200 金(市价 > 基准价才
  出手、每天上限 5 份、越贵越先卖)、饲料磨坊 500 金(1 份最便宜的
  作物磨 3 份饲料)。
- engine._run_buildings 扩成六段流水线,顺序即依赖:磨坊排在喂食器前,
  早上磨的饲料当天就能喂上;收割机遇满仓停摆、作物留地(同喂食器
  缺料停摆哲学——自动系统绝不硬来,更绝不自动花钱)。
- webui / build 动作 / catalog 全部由 config.BUILDINGS 驱动,零改动。
- 测试 +6(喷灌全量浇水、收割入库且无经验、满仓停摆留作物、磨坊挑
  最便宜、磨坊→喂食器同日链路、售货机阈值与配额)。修复断言时确认
  两个既有行为:引擎自带 START_FEED=5;advance_day 末尾会把动物 fed
  复位("喂到了"的证据是 pending 累计而非 fed 本身)。
- 门禁:534 项 OK(skip 39)、ruff/pyright 全绿。
2026-09-25 21:05:27 +08:00
snowy-yang 2676927c1a fix(webui): 中文用户名登录后所有写操作静默失败
为什么:headers() 把用户名塞进 X-Farm-Id 请求头,而 HTTP 头只允许
Latin-1 字符——中文用户名(如"阳")会让 fetch 在发送前直接抛
TypeError(String contains non ISO-8859-1 code point)。登录时
me 还是 null(头为 ASCII 的 default)所以能登录成功,登录之后
me 变成中文,所有走 api() 的写操作(播种/浇水/收获/卖出)全部
静默失败;页面靠 SSE 推送照常渲染,看起来一切正常,用户表现为
"点播种没反应"。

- headers():非 ASCII 的牧场名/令牌不再塞头。登录态本来就由
  Cookie 鉴权,X-Farm-Id 只是匿名脚本模式的备用通道;ASCII 名
  (default、bot 等)行为不变。
- api():捕获 fetch 网络异常并 toast 提示。此前 Promise 拒绝
  无人接住,这类故障完全不可见,正是本 bug 难排查的原因。
- localStorage 读写加 try/catch 兜底:sandbox iframe 若不带
  allow-same-origin,裸访问即抛 SecurityError,会炸掉整个启动
  脚本(启动段就有三处裸调用)。

验证:headless Chrome 真机复现 → 修复 → UI 全流程回归(登录
"阳" → 点播种 → 选小麦 → toast 与地块状态正确更新);ruff /
pyright / 528 项测试全绿。
2026-09-23 22:28:40 +08:00
snowy-yang 698a5e497a feat: 用户名登录——一账号一个牧场,登录后改 URL 也进不了别人的档
多用户之前是「知道牧场名就能玩」:?farm=boss 直接进别人家里。
现在网页端用用户名登录(用户选择:不设密码、一账号一个牧场,
用户名即身份——互信小圈子模型,防的是随手改 URL,不是蓄意攻击)。

* 新增 farm/server/auth.py:HMAC-SHA256 签名的 HttpOnly Cookie
  (sf_auth,30 天,恒时比较防篡改)。密钥存 <save_dir>/secret.key,
  重启不掉线,也不用建表存 token。
* app.py 三个认 ?farm= 的地方(session_of / SSE / POST /api/farms)
  统一规则:有 Cookie 强制本人的牧场(?farm=/X-Farm-Id 被忽略),
  无 Cookie 保持旧语义——bot/TUI/CLI 脚本零改动照常工作。
* 新路由:POST /api/login(全局 token 照常把它挡在门外)、
  POST /api/logout、GET /api/me(刻意不 guard:首屏只问"登录没")。
* 网页:移除牧场名输入框,改为登录遮罩 + 用户徽章 + 退出按钮;
  首屏先 whoami() 再决定连接还是弹登录。

测试 +13:Cookie 伪造/过期/跨密钥/垃圾串/非法用户名/密钥跨重启复用/
session_of 优先级(starlette Request 用 scope dict 直接构造,不需要
httpx)9 项纯逻辑档 + 4 项 TestClient 档。528 项 OK(skip 39)。

真机验收 16/16:登录/HttpOnly/?farm= 被忽略/动作落自己牧场/篡改
退回未登录/退出/非法名 400;跨重启 Cookie 仍有效且进度保留。
2026-09-23 21:20:25 +08:00
snowy-yang d1692d7b10 docs: 记忆与验收技能跟上——存档改动必须真机验自动落盘
单元测试抓不住 autosave 失效(dirty 被高频清零这件事测试里复现
不了真机时序),所以技能里加一条:动了存档/会话层,必须 0 客户端
在线做一个动作、等 60s、直接查库那一行有没有更新。
2026-09-23 20:54:13 +08:00
snowy-yang ea346be484 fix: 自动存档几乎永不触发的真 bug——把 dirty 和 unsaved 拆开
顺着验收网页修复时发现:default 牧场根本没写进库,内存里每次
重启都是新档。根因是 session.dirty 一个标志位伺二主:

* Publisher 推送循环每 ~250ms 就把 dirty 清掉(无人在线时直接清,
  "没人在看就不做序列化,省 CPU");
* autosave() 每 60s 才看一眼 dirty。

dirty=True 的窗口只有约 250ms,自动存档撞上它的概率约 0.4%——
等于永不存盘。进度只在手动存档/优雅停机时落盘,进程被强杀全丢。

修法:新增 session.unsaved 专管「有变化没落盘」,_on_change / load /
touch / advance_day 置位,只有成功的 save() 清位;autosave() 改看
unsaved;dirty 继续只管推送节流,hub.py 的推送循环一字未动。

两只旧测试直接操纵 dirty 驱动 autosave,固化了被修掉的耦合,改成
unsaved 表达意图;新增回归测试:推送循环清了 dirty 也要照常落盘、
没有新变化不重复写库。

真机验证:0 客户端在线纯 HTTP 种一块地,70 秒内库行更新(金币
120→112,种子钱都对得上)。515 项测试 OK。
2026-09-23 20:54:12 +08:00
snowy-yang 305ef0dcbe fix: 网页播种/重置在嵌入预览里没反应——弃用 window.prompt/confirm
播种按钮原来弹 window.prompt 选作物,重置弹 confirm——这俩在
sandbox iframe(内置预览面板)里会被静默屏蔽:不弹窗、直接返回
null,表现为"点了没反应"。普通浏览器没事,所以一直没暴露。

改成页面内交互:播种弹 #pickmask 弹层,列出未锁定作物的按钮 +
取消,点遮罩关闭;重置改两步确认,第一次点变"再点一次确认",
3 秒不点自动还原。
2026-09-23 20:54:11 +08:00
24 changed files with 1552 additions and 433 deletions

No files matched your search

+91
View File
@@ -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 从
"登录功能上线"一直活到用户报障。
+81
View File
@@ -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 周期)兜底损失。
+69 -188
View File
@@ -1,196 +1,77 @@
# SimpleFarm 项目长期约定
## 是什么
终端农场经营游戏 + 小型多人游戏服务器(v2.0)。游戏跑在服务端进程里,
TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务端不内置 AI)。
## 是什么与运行
终端农场游戏 + 多人服务器(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`
(现 528 项;TestClient/真机档 39 项因无 httpx 自动 skip;`ruff check .` 会扫 tests,
测试文件要自己保持 lint 干净)。性能复核 `uv run python tools/bench.py --repeat 5`。
## 运行方式(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)。
- 质量门禁(改动后都应跑一遍):
`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 ` 去掉即可"的兜底说明,**别删**。
## 玩法与存档
- 一天 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 起)
- **一天 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,客户端会误显示成“已暂停/手动模式”。
## 架构约定
- 分层: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」)。
## 三大系统:天气 / 谷仓 / 建筑(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()`,
正是为了执行这些免费动作。
## 本机环境(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 会被静默覆盖。
## 架构约定(改动时必须遵守)
- **分层**:`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 并提示换端口。
## 远端与推送
- 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。
## 序列化与性能约定(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` 各写一份。
## webui 前端铁律(2026-09-23)
- **HTTP 头只允许 Latin-1**:用户可输入内容(中文用户名)塞进请求头会让
fetch 发送前抛 TypeError。headers() 里非 ASCII 一律不塞(登录态走 Cookie)。
- api() 必须接住 fetch 异常并 toast——静默 Promise 拒绝曾让登录后所有写
操作失效却毫无痕迹。
- localStorage 裸调用全走 lsGet/lsSet 兜底(sandbox iframe 无
allow-same-origin 时访问即抛,会炸掉整个启动脚本)。
## 环境禁忌(本机特有)
- `os.makedirs(os.path.dirname(path), exist_ok=True)` 在 path **只有文件名**时
(`--pid-file pid.txt`)`dirname` 是空串,会以 FileNotFoundError 崩掉。
已抽 `farm/server/daemon.py` 的 `ensure_parent_dir()` 兜底,新代码用它,别再手写。
- `RemoteFarm.snapshot()` 是**本地缓存、不发请求**。关掉实时通道(`realtime=False`)的
脚本必须自己调 `refresh()`,否则轮询永远读到同一份旧数据——服务器在推进却看起来像卡死。
TUI / 网页走 SSE 所以没这个问题;`bot` 走 `available_actions()`(会顺手带回最新状态)。
- Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32);
若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。
一律"先读完 → 关掉句柄 → 再删"。
- 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` 等清场再试。
## 别踩的坑(本机特有)
+21 -10
View File
@@ -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
View File
@@ -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
-5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+84
View File
@@ -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
View File
@@ -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
View File
@@ -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;
});
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");
});
document.addEventListener("keydown", (e) => { if (e.key === "Enter") $("join").click(); });
/* ---- 登录:用户名即牧场身份,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
View File
@@ -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
View File
@@ -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
+4 -5
View File
@@ -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:
-1
View File
@@ -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
View File
@@ -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)
+2 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: