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 仍有效且进度保留。
This commit is contained in:
1 parent
d1692d7b10
commit
698a5e497a
7 files changed
+391
-215
No files matched your search
@@ -28,3 +28,32 @@
|
||||
弹层实现。
|
||||
- 一个布尔标志被两个子系统共用时,其中一个高频清零,另一个低频读取——
|
||||
低频的那个等于瞎了。存盘依据必须由"成功的写入"来清。
|
||||
|
||||
## 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 工具。
|
||||
+50
-192
@@ -1,199 +1,57 @@
|
||||
# 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` 在底部明示原因。
|
||||
- **session 的 dirty / unsaved 必须分开**:`dirty` 只管推送节流(Publisher ~250ms 清一次),
|
||||
`unsaved` 才是自动存档依据(只有成功的 `save()` 清它)。别再合并——合回来 autosave 就瞎了。
|
||||
网页前端禁用 `window.prompt`/`confirm`(嵌入预览里被静默屏蔽),交互用页面内弹层。
|
||||
- **多牧场**:一个 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)。推送:
|
||||
`GCM_INTERACTIVE=always git push origin main`(缺它 GCM 拒交互,报
|
||||
"Failed to authenticate");还不行加 GCM_TRACE=1。沙箱会丢
|
||||
.git/refs/remotes 写入使 status 显示 [gone]——假象,验证推送看 `git ls-remote`。
|
||||
|
||||
## 序列化与性能约定(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` 各写一份。
|
||||
|
||||
## 环境禁忌(本机特有)
|
||||
- `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。
|
||||
@@ -146,6 +146,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 +166,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 推荐入口) |
|
||||
@@ -325,6 +330,10 @@ SSE 长连接在两种协议下都照常工作。
|
||||
## 7. 多人模型
|
||||
|
||||
* **一个牧场 = 一个玩家**:独立引擎、独立时钟,存档在 `saves/farm.db`(SQLite,**一个牧场一行**)。
|
||||
* **用户名登录**:网页首屏先登录(输入用户名即可,第一次用会自动开同名新牧场)。
|
||||
Cookie 是 HMAC 签名的 HttpOnly,30 天有效,密钥在 `saves/secret.key`——重启不掉线。
|
||||
登录后改 URL 也进不了别人的牧场;`curl`/脚本不带 Cookie 时保持 `?farm=` 旧语义
|
||||
(互信小圈子模型:用户名即身份,不设密码,防的是"随手改 URL"而不是蓄意攻击)。
|
||||
* 牧场之间完全隔离(金币、土地、市场各自独立),但共享排行榜(`GET /api/farms`)。
|
||||
* 同牧场可以有多人/多程序同时操作,引擎内的 `RLock` 保证串行,不会出现"两个人同时种同一块地"这类竞态。
|
||||
* 所有牧场的时钟由**一个** `FarmClock` 线程统一驱动(而不是每个牧场一条线程)。
|
||||
|
||||
+59
-8
@@ -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,
|
||||
@@ -178,7 +180,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 +217,7 @@ class _Services:
|
||||
|
||||
cfg: ServerConfig
|
||||
manager: SessionManager
|
||||
auth: LoginManager
|
||||
hub: ConnectionHub
|
||||
publisher: Publisher
|
||||
clock: FarmClock
|
||||
@@ -233,6 +237,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 +285,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 +431,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 +487,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 +679,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 +784,7 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
||||
_register_error_handlers(app, svc)
|
||||
_register_pages(app, svc)
|
||||
_register_meta_routes(app, svc)
|
||||
_register_auth_routes(app, svc)
|
||||
_register_world_routes(app, svc)
|
||||
_register_farm_routes(app, svc)
|
||||
_register_action_routes(app, svc)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""用户名登录:HMAC 签名的 HttpOnly Cookie,无密码。
|
||||
|
||||
信任模型是「互信小圈子」(用户选了仅用户名):用户名即身份,Cookie 防的是
|
||||
"别人随手改 URL 玩你的牧场",不是防蓄意攻击者。要点三个:
|
||||
|
||||
* Cookie 值 = base64(payload) + "." + HMAC-SHA256,payload 是 ``用户名|过期时间``。
|
||||
验签用 ``hmac.compare_digest``(恒时比较),防篡改;过期 30 天。
|
||||
* 签名密钥存 ``<save_dir>/secret.key``(0600):重启不掉线,也不用建表存 token。
|
||||
* 没有 Cookie 的请求(脚本 / AI / CLI)不受影响——继续走 ``?farm=``,
|
||||
兼容语义收在 ``app._Services.session_of`` 一处,这里只管签发与验签。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from farm.engine import GameError
|
||||
|
||||
from .session import validate_farm_id
|
||||
|
||||
COOKIE_NAME = "sf_auth"
|
||||
TTL_SECONDS = 30 * 86400
|
||||
|
||||
# 密钥文件名固定,放存档目录里——它跟存档一样属于"这台服务器的本地秘密"
|
||||
SECRET_FILE = "secret.key"
|
||||
|
||||
|
||||
class LoginManager:
|
||||
"""签发与校验登录 Cookie。"""
|
||||
|
||||
def __init__(self, secret_dir: str) -> None:
|
||||
os.makedirs(secret_dir, exist_ok=True)
|
||||
self.secret = self._load_or_create(os.path.join(secret_dir, SECRET_FILE))
|
||||
|
||||
@staticmethod
|
||||
def _load_or_create(path: str) -> bytes:
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
raw = bytes.fromhex(handle.read().strip().decode("ascii"))
|
||||
if len(raw) >= 32:
|
||||
return raw
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
# 文件不存在 / 不是合法 hex / 长度不够:统统当作没有,重新生成
|
||||
pass
|
||||
raw = secrets.token_bytes(32)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(raw.hex().encode("ascii"))
|
||||
return raw
|
||||
|
||||
def issue(self, username: str) -> str:
|
||||
"""给用户名签一枚 Cookie 值。用户名不合法直接抛 bad_farm。"""
|
||||
user = validate_farm_id(username)
|
||||
payload = f"{user}|{int(time.time()) + TTL_SECONDS}"
|
||||
sig = hmac.new(self.secret, payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
blob = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii")
|
||||
return f"{blob}.{sig}"
|
||||
|
||||
def verify(self, token: str | None) -> str | None:
|
||||
"""校验 Cookie 值,返回用户名;任何一步不对都返回 None(当没登录)。"""
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
blob, _, sig = token.rpartition(".")
|
||||
try:
|
||||
payload = base64.urlsafe_b64decode(blob.encode("ascii")).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
want = hmac.new(self.secret, payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(want, sig):
|
||||
return None
|
||||
user, _, exp_text = payload.rpartition("|")
|
||||
try:
|
||||
expires = int(exp_text)
|
||||
except ValueError:
|
||||
return None
|
||||
if expires < time.time():
|
||||
return None
|
||||
try:
|
||||
return validate_farm_id(user)
|
||||
except GameError:
|
||||
return None
|
||||
+63
-15
@@ -98,6 +98,8 @@ 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; }
|
||||
</style>
|
||||
@@ -107,9 +109,9 @@ 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>
|
||||
@@ -176,6 +178,17 @@ 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>
|
||||
@@ -185,7 +198,7 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
|
||||
<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;
|
||||
|
||||
const cropNames = {
|
||||
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
|
||||
@@ -212,7 +225,7 @@ 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(); }
|
||||
|
||||
function headers() {
|
||||
@@ -483,21 +496,50 @@ $("pickmask").addEventListener("click", (ev) => {
|
||||
if (ev.target.id === "pickmask") $("pickmask").classList.remove("show");
|
||||
});
|
||||
|
||||
$("join").addEventListener("click", () => {
|
||||
localStorage.setItem("sf.farm", farmName());
|
||||
localStorage.setItem("sf.token", token());
|
||||
fetch("/api/farms", {
|
||||
/* ---- 登录:用户名即牧场身份,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) {
|
||||
localStorage.setItem("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({ farm: farmName() })
|
||||
}).then((r) => r.json()).then((d) => { toast(d.message || ""); });
|
||||
connect();
|
||||
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();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Enter") $("join").click(); });
|
||||
|
||||
/* ---- 启动 ---- */
|
||||
$("farm").value = localStorage.getItem("sf.farm") || "default";
|
||||
$("token").value = localStorage.getItem("sf.token") || "";
|
||||
connect();
|
||||
$("token").addEventListener("change", () => {
|
||||
localStorage.setItem("sf.token", token());
|
||||
if (me) connect();
|
||||
});
|
||||
whoami();
|
||||
setInterval(renderBoard, 5000);
|
||||
</script>
|
||||
</body>
|
||||
@@ -522,6 +564,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"
|
||||
|
||||
@@ -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,
|
||||
@@ -1111,6 +1113,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(
|
||||
@@ -1122,6 +1146,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 说话。"""
|
||||
|
||||
Reference in new issue
Block a user