refactor: 存档层从「一牧场一个 JSON」换成 SQLite(单库单表)
`saves/farm.db` 一张 farms 表,farm_id 主键,**一个牧场一行**。 接口签名不变(save_state / load_state / delete_save 只多了 farm_id 参数), 调用方只改了路径语义。 设计取舍:标量列 + 整份 payload。 * day/gold/level/ap/weather/forecast/net_worth 单独成列——外部工具或 AI 一条 SELECT 就能排榜、画曲线,不必先把每份存档读进内存; * 完整状态仍整份存 payload JSON:游戏数据隔三差五长新字段,规范化成 几十张表意味着每加字段写一次迁移,而存档只做整体读、整体写, 所以结构化列管可查询,完整性交给 JSON——加字段不用改表结构。 崩溃安全升级:写入包在事务里。原来的"临时文件 + os.replace"能挡进程 崩溃,挡不住写到一半掉电;SQLite 的日志能。读取失败照旧降级开新档。 向后兼容:SAVE_VERSION=2 / MIN_SAVE_VERSION=1 不变。老 JSON 存档由 migrate_json_saves() 在服务器启动时自动导入:同名已存在就跳过(宁可不 迁不覆盖),导入成功原文件改名 *.json.migrated 留底,先写库再改名 保证幂等;坏档一律跳过——迁移不许有能力阻止服务器启动。 异常边界:StorageError = sqlite3.Error,刻意不是 OSError 子类—— 调用方原来的 except OSError 全部跟着改,否则写盘失败会穿透出去。 性能实测:PRAGMA journal_mode=WAL 是持久属性,建库时设一次就够, 每次连接重设白花 2.2ms;save_state 约 6.9ms/次,大头是 synchronous=FULL 的 fsync——不省,存档的价值就是不丢档。bench 热路径无回归。 顺手修一个真 bug:connect() 建表失败时连接会泄漏,Windows 上句柄没关 就删不掉文件,而调用方正是靠"读失败→开新档"容错的。补 close。 测试:StorageTestCase 重写(往返/版本/损坏降级/多牧场隔离/迁移), test_server 断言 *.json 的用例改查库。513 项全过,ruff/pyright 全绿。 端到端:预置 v1/v2 老档起真服务器验 22 项——自动迁移、字段还原、 list_farms() 排榜、重启后进度分毫不差,全过。
This commit is contained in:
1 parent
6268283fc6
commit
bc3ae96a38
10 files changed
+563
-113
No files matched your search
@@ -280,3 +280,37 @@
|
||||
走真实 HTTP 验了 **38 项全过**:catalog 四张新表、天气/预报换班、种→缺水→浇水→成熟、
|
||||
收获进仓不给钱、`sell` 变现与空仓报错、`build` 扣钱/涨容量/涨价/未解锁 locked/未知建筑。
|
||||
其中「等待期间手动浇水 1 次」是被干旱实打实逼出来的,说明天气→浇水这条链真在起作用。
|
||||
|
||||
## 2026-09-19 存档层换成 SQLite(单库单表,一牧场一行)
|
||||
|
||||
- 起因:给用户讲完存储实现后,用户一句「使用sqlite吧」——直接把 `farm/storage.py`
|
||||
从「一牧场一个 JSON + 临时文件原子替换」重写成 SQLite。
|
||||
- **结构**:`saves/farm.db` 一张 `farms` 表,`farm_id TEXT PRIMARY KEY`。
|
||||
设计取舍是**标量列 + 整份 payload JSON**:day/gold/level/ap/weather/forecast/net_worth
|
||||
单独成列(外部工具可以直接 SELECT 排榜画曲线),完整状态仍整份存 `payload`
|
||||
——游戏数据老长新字段,规范化成几十张表意味着每加字段写一次迁移,而存档
|
||||
只做「整体读、整体写」,所以结构化列管**可查询**,完整性交给 JSON。
|
||||
- **版本**:沿用 `SAVE_VERSION=2` / `MIN_SAVE_VERSION=1`,宽容向后不变。
|
||||
- **迁移**:`migrate_json_saves()` 启动时自动把老 JSON 导入;同名已存在就跳过
|
||||
(宁可不迁不覆盖);导入成功后原文件改名 `*.json.migrated` 留底不删;
|
||||
先写库再改名保证幂等。坏档一律跳过——迁移不许有能力阻止服务器启动。
|
||||
- **异常边界**:`StorageError = sqlite3.Error`(刻意**不是** OSError 子类),
|
||||
调用方原来的 `except OSError` 全部跟着改过,否则写盘失败会穿透。
|
||||
- **性能实测**:`PRAGMA journal_mode=WAL` 是持久属性,建库时设一次就够,
|
||||
每次连接重设白花 2.2ms(200 次实测);WAL + 事务化 upsert 后 save_state
|
||||
约 6.9ms/次,大头是 `synchronous=FULL` 的 fsync——**不省**,存档的价值就是不丢档。
|
||||
bench 热路径无回归。
|
||||
- **测试**:StorageTestCase 重写(往返/版本/损坏降级/多牧场隔离/迁移),
|
||||
test_server 里断言 `*.json` 的用例改查库。513 项全过,ruff/pyright 全绿。
|
||||
- **端到端**:起真服务器(预置 v1 老档 alice / v2 老档 bob)验了 22 项:
|
||||
自动迁移、字段还原、`list_farms()` 白拿的 SQL 排榜、**重启后进度分毫不差**
|
||||
(alice 第 9 天、迁移来的小麦还在地里、bob 隔离干净)。
|
||||
- **顺手修**:`connect()` 建表失败时连接会泄漏——Windows 上句柄没关就删不掉
|
||||
文件,而调用方正是靠「读失败→开新档」容错。补了 `except: conn.close(); raise`。
|
||||
- **文档**:README 四处(多人模型/守护进程表/目录结构/容错说明)与 main.py
|
||||
的 `--save-dir` help 同步更新。
|
||||
|
||||
### 教训
|
||||
- 本机 bash 连 `rm` 都是坏的(safe-bin shim 依赖 dirname,找不到就 127)——
|
||||
删文件**一律用 Python** `pathlib.unlink()` / `shutil.rmtree()`,别用 shell。
|
||||
- TaskUpdate 偶发 `EPERM: rename ...tmp -> ...json`,重发一次就好,不是真故障。
|
||||
@@ -45,8 +45,13 @@ TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务
|
||||
仓满时 `_store()` 抛 `granary_full` **且不改状态**(作物原地不动,卖了再来)。
|
||||
- **建筑**:`config.BUILDINGS`,第 n 座价 `int(cost * 1.6**owned)`;每天在 `advance_day`
|
||||
里**先于生长**执行(水井救的正是今天要断水那块地)。喂食器只吃库存饲料、绝不自动花钱。
|
||||
- **存档**:`SAVE_VERSION=2` 配 `MIN_SAVE_VERSION=1`——旧档缺的新字段一律走默认值,
|
||||
**不作废玩家手里的档**。加字段时两处版本号都要看一遍。
|
||||
- **存档**:**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()`,
|
||||
@@ -69,7 +74,7 @@ TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务
|
||||
方向键/空格/Tab/ESC 全部静默失效(字母键正常,所以很难注意到)。
|
||||
动 `farm/keys.py` 时,`farm/tui.py` 里所有具名键引用都要跟着核一遍。
|
||||
读不到键盘时(管道 / 非 Win32 控制台)TUI 会用 `reader.reason` 在底部明示原因。
|
||||
- **多牧场**:一个 farm = 一个玩家 = 一份 `saves/<名字>.json` + 一个引擎 + 独立时钟;
|
||||
- **多牧场**:一个 farm = 一个玩家 = 库里一行(`saves/farm.db`)+ 一个引擎 + 独立时钟;
|
||||
时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。
|
||||
- **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理,
|
||||
不能直接落到 `engine.save()`(会写默认路径、覆盖别人的档)。
|
||||
@@ -128,8 +133,9 @@ TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务
|
||||
- Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32);
|
||||
若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。
|
||||
一律"先读完 → 关掉句柄 → 再删"。
|
||||
- Bash 的 coreutils 残缺(`ls`/`head`/`tail`/`grep`/**`cat`** 缺失),**不要用管道**,会整条命令失败;
|
||||
查看/删除文件一律写 Python 脚本。跑测试也一样:`cd /d/SimpleFarm && uv run python -m unittest ... 2>&1`
|
||||
- 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`,
|
||||
|
||||
Reference in new issue
Block a user