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:
snowy-yang committed 2026-09-19 19:04:30 +08:00
1 parent 6268283fc6
commit bc3ae96a38
10 files changed
+563 -113

No files matched your search

+34
View File
@@ -280,3 +280,37 @@
走真实 HTTP 验了 **38 项全过**:catalog 四张新表、天气/预报换班、种→缺水→浇水→成熟、 走真实 HTTP 验了 **38 项全过**:catalog 四张新表、天气/预报换班、种→缺水→浇水→成熟、
收获进仓不给钱、`sell` 变现与空仓报错、`build` 扣钱/涨容量/涨价/未解锁 locked/未知建筑。 收获进仓不给钱、`sell` 变现与空仓报错、`build` 扣钱/涨容量/涨价/未解锁 locked/未知建筑。
其中「等待期间手动浇水 1 次」是被干旱实打实逼出来的,说明天气→浇水这条链真在起作用。 其中「等待期间手动浇水 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`,重发一次就好,不是真故障。
+11 -5
View File
@@ -45,8 +45,13 @@ TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务
仓满时 `_store()` 抛 `granary_full` **且不改状态**(作物原地不动,卖了再来)。 仓满时 `_store()` 抛 `granary_full` **且不改状态**(作物原地不动,卖了再来)。
- **建筑**:`config.BUILDINGS`,第 n 座价 `int(cost * 1.6**owned)`;每天在 `advance_day` - **建筑**:`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")` 可钉死当天天气(不传就随机,测试会飘); - **测试钩子**:`advance_day(weather="sunny")` 可钉死当天天气(不传就随机,测试会飘);
这条链的入口是 `_resolve_weather` / `_evaporation` / `_run_buildings` / `_storm_damage`。 这条链的入口是 `_resolve_weather` / `_evaporation` / `_run_buildings` / `_storm_damage`。
- 动作表新增 `sell` 与 `build`,都是 **0 AP**;`bot` 在 AP=0 时仍会走一次 `step()`, - 动作表新增 `sell` 与 `build`,都是 **0 AP**;`bot` 在 AP=0 时仍会走一次 `step()`,
@@ -69,7 +74,7 @@ TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务
方向键/空格/Tab/ESC 全部静默失效(字母键正常,所以很难注意到)。 方向键/空格/Tab/ESC 全部静默失效(字母键正常,所以很难注意到)。
动 `farm/keys.py` 时,`farm/tui.py` 里所有具名键引用都要跟着核一遍。 动 `farm/keys.py` 时,`farm/tui.py` 里所有具名键引用都要跟着核一遍。
读不到键盘时(管道 / 非 Win32 控制台)TUI 会用 `reader.reason` 在底部明示原因。 读不到键盘时(管道 / 非 Win32 控制台)TUI 会用 `reader.reason` 在底部明示原因。
- **多牧场**:一个 farm = 一个玩家 = 一份 `saves/<名字>.json` + 一个引擎 + 独立时钟; - **多牧场**:一个 farm = 一个玩家 = 库里一行(`saves/farm.db`)+ 一个引擎 + 独立时钟;
时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。 时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。
- **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理, - **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理,
不能直接落到 `engine.save()`(会写默认路径、覆盖别人的档)。 不能直接落到 `engine.save()`(会写默认路径、覆盖别人的档)。
@@ -128,8 +133,9 @@ TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务
- Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32); - Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32);
若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。 若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。
一律"先读完 → 关掉句柄 → 再删"。 一律"先读完 → 关掉句柄 → 再删"。
- Bash 的 coreutils 残缺(`ls`/`head`/`tail`/`grep`/**`cat`** 缺失),**不要用管道**,会整条命令失败; - Bash 的 coreutils 残缺(`ls`/`head`/`tail`/`grep`/**`cat`**/**`rm`** 缺失),**不要用管道**,会整条命令失败;
查看/删除文件一律写 Python 脚本。跑测试也一样:`cd /d/SimpleFarm && uv run python -m unittest ... 2>&1` 查看/删除文件一律写 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 退出,看起来像"没输出")。 必须**整条无管道**(`| tail -40` 会让命令直接 127 退出,看起来像"没输出")。
PowerShell 跑原生命令的 `*>` 重定向会写成 UTF-16 + 中文乱码,**别用它读输出**。 PowerShell 跑原生命令的 `*>` 重定向会写成 UTF-16 + 中文乱码,**别用它读输出**。
- **写多行 git commit message 不要用 `git commit -m "$(cat <<'EOF' ... EOF)"`**:本机没有 `cat`, - **写多行 git commit message 不要用 `git commit -m "$(cat <<'EOF' ... EOF)"`**:本机没有 `cat`,
+6 -4
View File
@@ -324,7 +324,7 @@ SSE 长连接在两种协议下都照常工作。
## 7. 多人模型 ## 7. 多人模型
* **一个牧场 = 一个玩家**:独立引擎、独立时钟、独立存档(`saves/<牧场名>.json`)。 * **一个牧场 = 一个玩家**:独立引擎、独立时钟,存档在 `saves/farm.db`(SQLite,**一个牧场一行**)。
* 牧场之间完全隔离(金币、土地、市场各自独立),但共享排行榜(`GET /api/farms`)。 * 牧场之间完全隔离(金币、土地、市场各自独立),但共享排行榜(`GET /api/farms`)。
* 同牧场可以有多人/多程序同时操作,引擎内的 `RLock` 保证串行,不会出现"两个人同时种同一块地"这类竞态。 * 同牧场可以有多人/多程序同时操作,引擎内的 `RLock` 保证串行,不会出现"两个人同时种同一块地"这类竞态。
* 所有牧场的时钟由**一个** `FarmClock` 线程统一驱动(而不是每个牧场一条线程)。 * 所有牧场的时钟由**一个** `FarmClock` 线程统一驱动(而不是每个牧场一条线程)。
@@ -351,7 +351,7 @@ uv run main.py play --url http://192.168.1.20:8765 --farm alice --token 找个
| 日志 | `run/server.log`(`uv run main.py logs -n 50`) | | 日志 | `run/server.log`(`uv run main.py logs -n 50`) |
| 停止 | `uv run main.py stop`(先走 HTTP 优雅停机,失败再发信号) | | 停止 | `uv run main.py stop`(先走 HTTP 优雅停机,失败再发信号) |
| 状态 | `uv run main.py status` | | 状态 | `uv run main.py status` |
| 存档 | `saves/<牧场名>.json`(纯 JSON,可直接阅读分析) | | 存档 | `saves/farm.db`(SQLite;`sqlite3 saves/farm.db "SELECT farm_id,day,gold,net_worth FROM farms"` 可直接查) |
| 接口文档 | <http://127.0.0.1:8765/docs> | | 接口文档 | <http://127.0.0.1:8765/docs> |
实现上不依赖 systemd / nohup / 任何第三方库:POSIX 用新会话、Windows 用 `DETACHED_PROCESS`, 实现上不依赖 systemd / nohup / 任何第三方库:POSIX 用新会话、Windows 用 `DETACHED_PROCESS`,
@@ -382,7 +382,7 @@ farm/
config.py 数值与静态表(作物、牲畜、经济、时间) config.py 数值与静态表(作物、牲畜、经济、时间)
models.py Plot / Animal / GameState —— 只持有数据 models.py Plot / Animal / GameState —— 只持有数据
engine.py FarmEngine / Scheduler —— 纯游戏规则(不依赖网络与终端) engine.py FarmEngine / Scheduler —— 纯游戏规则(不依赖网络与终端)
storage.py 存档读写(临时文件 + 原子替换) storage.py 存档读写(SQLite,一牧场一行;老 JSON 档启动时自动导入)
keys.py 跨平台按键读取 keys.py 跨平台按键读取
net.py 回环请求绕过系统代理的小工具 net.py 回环请求绕过系统代理的小工具
tui.py 终端界面(自适应宽度、双宽字符对齐,与引擎解耦) tui.py 终端界面(自适应宽度、双宽字符对齐,与引擎解耦)
@@ -481,7 +481,9 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是
* 推送节流 250ms,高频操作自动合并成一帧;每条推送带自增 `seq`,客户端据此丢弃乱序帧; * 推送节流 250ms,高频操作自动合并成一帧;每条推送带自增 `seq`,客户端据此丢弃乱序帧;
* 引擎调用统一丢进线程池,不阻塞事件循环; * 引擎调用统一丢进线程池,不阻塞事件循环;
* 单条连接/单个牧场出错不影响其它牧场; * 单条连接/单个牧场出错不影响其它牧场;
* 存档用"临时文件 + `os.replace`"原子替换,崩溃不会写坏存档;存档读取失败会降级为开新档而不是崩溃。 * 存档是 SQLite(`saves/farm.db`,一牧场一行):写入包在事务里,掉电也不会写坏档;
老版本 JSON 存档启动时自动导入(原文件改名 `*.json.migrated` 留底);
存档读取失败会降级为开新档而不是崩溃。
**本地请求不走系统代理**(`farm/net.py`): **本地请求不走系统代理**(`farm/net.py`):
+2 -1
View File
@@ -28,6 +28,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Str
from .. import config from .. import config
from ..engine import GameError from ..engine import GameError
from ..storage import StorageError
from . import actions as actions_mod from . import actions as actions_mod
from .hub import ( from .hub import (
CLOSE_MESSAGE, CLOSE_MESSAGE,
@@ -597,7 +598,7 @@ def _register_admin_routes(app: FastAPI, svc: _Services) -> None:
try: try:
session.save() session.save()
saved.append(session.farm_id) saved.append(session.farm_id)
except OSError as exc: except (OSError, StorageError) as exc:
raise GameError( raise GameError(
"save_failed", f"{session.farm_id} 存档失败:{exc}", status=500 "save_failed", f"{session.farm_id} 存档失败:{exc}", status=500
) from exc ) from exc
+17 -7
View File
@@ -24,7 +24,13 @@ from typing import Any
from .. import config from .. import config
from ..engine import FarmEngine, GameError, tick_decision from ..engine import FarmEngine, GameError, tick_decision
from ..storage import load_state, save_state from ..storage import (
DB_NAME,
StorageError,
load_state,
migrate_json_saves,
save_state,
)
DEFAULT_FARM = "default" DEFAULT_FARM = "default"
MAX_FARM_ID = 24 MAX_FARM_ID = 24
@@ -68,7 +74,8 @@ class FarmSession:
self.farm_id = farm_id self.farm_id = farm_id
self.owner = validate_owner(owner) or farm_id self.owner = validate_owner(owner) or farm_id
self.save_dir = save_dir self.save_dir = save_dir
self.path = os.path.join(save_dir, f"{farm_id}.json") # 所有牧场共用同一个库文件,靠 farm_id 这行主键区分
self.db_path = os.path.join(save_dir, DB_NAME)
self._lock = threading.RLock() self._lock = threading.RLock()
self.engine = FarmEngine(tick_seconds=tick_seconds) self.engine = FarmEngine(tick_seconds=tick_seconds)
@@ -95,7 +102,7 @@ class FarmSession:
self.dirty = True self.dirty = True
def load(self) -> str: def load(self) -> str:
state, message = load_state(self.path) state, message = load_state(self.db_path, self.farm_id)
if state is not None: if state is not None:
self.engine.state = state self.engine.state = state
self.load_message = message self.load_message = message
@@ -109,7 +116,7 @@ class FarmSession:
def save(self) -> str: def save(self) -> str:
with self._lock: with self._lock:
path = save_state(self.engine.state, self.path) path = save_state(self.engine.state, self.db_path, self.farm_id)
self.saved_at = time.time() self.saved_at = time.time()
return path return path
@@ -122,12 +129,12 @@ class FarmSession:
try: try:
self.save() self.save()
return True return True
except OSError: except (OSError, StorageError):
return False return False
def shutdown(self) -> None: def shutdown(self) -> None:
# 停机时尽力落盘;写失败也不能挡住进程退出 # 停机时尽力落盘;写失败也不能挡住进程退出
with contextlib.suppress(OSError): with contextlib.suppress(OSError, StorageError):
self.save() self.save()
def touch(self, count_action: bool = False) -> None: def touch(self, count_action: bool = False) -> None:
@@ -203,6 +210,9 @@ class SessionManager:
self._lock = threading.RLock() self._lock = threading.RLock()
self._sessions: dict[str, FarmSession] = {} self._sessions: dict[str, FarmSession] = {}
os.makedirs(save_dir, exist_ok=True) os.makedirs(save_dir, exist_ok=True)
# 老版本是一牧场一个 JSON;第一次起来时把它们并进库里。
# 迁移失败不该挡住启动,所以这里只看结果、不抛。
self.migrated: list[str] = migrate_json_saves(save_dir)
# ------------------------------------------------------------ 取用 # ------------------------------------------------------------ 取用
def get(self, farm_id: str, create: bool = True, owner: str = "") -> FarmSession: def get(self, farm_id: str, create: bool = True, owner: str = "") -> FarmSession:
@@ -270,7 +280,7 @@ class SessionManager:
continue continue
try: try:
session.shutdown() session.shutdown()
except OSError: except (OSError, StorageError):
continue # 存不下就别丢,宁可占着内存 continue # 存不下就别丢,宁可占着内存
with self._lock: with self._lock:
self._sessions.pop(session.farm_id, None) self._sessions.pop(session.farm_id, None)
+278 -39
View File
@@ -1,13 +1,28 @@
"""存档读写。 """存档读写(SQLite)。
存档是纯 JSON,方便人工检查,也方便外部工具/AI 直接读取分析。 存档是一个 SQLite 库文件(默认 ``saves/farm.db``),**一个牧场一行**。
写入采用"临时文件 + 原子替换",避免中途崩溃导致存档损坏。 从"一牧场一个 JSON"换过来的理由:
* **崩溃安全**:写入包在事务里。原来的"临时文件 + ``os.replace``"能挡住
进程崩溃,挡不住写到一半掉电;SQLite 的日志能。
* **一个文件装下所有牧场**,不用再往目录里撒几十个小 JSON。
* **常用字段单独成列**(天数 / 金币 / 等级 / 天气 / 净值),外部工具或 AI
可以直接 ``SELECT`` 排个榜、画条曲线,不必先把每份存档读进内存。
* **完整状态仍然整份存在 ``payload`` 里**(JSON)。这是刻意的取舍:
游戏数据隔三差五就长出新字段,规范化成几十张表意味着每加一个字段都要写
一次迁移;而存档从来只做"整体读、整体写",不做部分查询。所以结构化列
只负责**可查询**,完整性交给 JSON——加字段不必改表结构。
老版本的 JSON 存档由 :func:`migrate_json_saves` 自动导入,原文件改名成
``*.json.migrated`` 留在原地(不删——那是玩家的真实进度)。
""" """
from __future__ import annotations from __future__ import annotations
import contextlib
import json import json
import os import os
import sqlite3
import time import time
from typing import Any from typing import Any
@@ -18,33 +33,135 @@ SAVE_VERSION = 2 # v2 起:谷仓存货 / 建筑 / 天气
# 一律走默认值,所以没必要因为升了一版就把玩家手里的档作废。 # 一律走默认值,所以没必要因为升了一版就把玩家手里的档作废。
MIN_SAVE_VERSION = 1 MIN_SAVE_VERSION = 1
DB_NAME = "farm.db"
# 单机模式只有一个牧场,固定用这个名字
DEFAULT_FARM_ID = "default"
# SQLite 在锁冲突时等多久(多牧场共用一个库,写操作是串行的)
BUSY_TIMEOUT = 10.0
# 存储层对外只暴露这一个异常基类:调用方不必认识 sqlite3,
# 换实现时也不用满仓库去改 except 子句。
# 注意它**不是** OSError 的子类——原来那几处 ``except OSError`` 要跟着改,
# 否则写盘失败会直接穿透出去(reap 里"存不下就别丢"的保护就失效了)。
StorageError = sqlite3.Error
_SCHEMA = """
CREATE TABLE IF NOT EXISTS farms (
farm_id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
saved_at TEXT NOT NULL,
updated_at NUMERIC NOT NULL,
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,
payload TEXT NOT NULL
);
"""
def default_save_dir() -> str:
"""默认存档目录:项目根目录下的 saves/。"""
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(root, "saves")
def default_save_path() -> str: def default_save_path() -> str:
"""默认存档位置:项目根目录下的 saves/save.json。""" """默认存档库:项目根目录下的 saves/farm.db。"""
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.join(default_save_dir(), DB_NAME)
return os.path.join(root, "saves", "save.json")
def save_state(state: GameState, path: str | None = None) -> str: def normalize_farm_id(raw: Any) -> str:
"""把状态写入磁盘,返回实际写入路径。""" """把牧场名规范化成主键;空值落到 default。
只做最基本的防御——名字能不能用是会话层(``validate_farm_id``)的事,
存储层不该反过来依赖它。
"""
if raw is None or raw == "":
return DEFAULT_FARM_ID
if isinstance(raw, (int, float)):
raw = str(raw)
if not isinstance(raw, str):
raise ValueError("牧场 ID 必须是字符串")
text = raw.strip()
return text or DEFAULT_FARM_ID
def connect(path: str | None = None) -> sqlite3.Connection:
"""打开(必要时创建)存档库。
每次调用都开一条新连接:存档是"每 60 秒写一次"的低频操作,开连接的开销
可以忽略,换来的是不用操心跨线程共享连接的问题。
"""
path = path or default_save_path() path = path or default_save_path()
directory = os.path.dirname(os.path.abspath(path)) key = os.path.abspath(path)
os.makedirs(directory, exist_ok=True) parent = os.path.dirname(key)
os.makedirs(parent, exist_ok=True)
payload = { # 空文件也算新库(比如刚被截断过)。WAL 是写在库文件头里的**持久**属性,
"version": SAVE_VERSION, # 建库时设一次就够;每次连接都重设会白花约 2ms(实测),而存档在
"saved_at": time.strftime("%Y-%m-%d %H:%M:%S"), # 每 60 秒一次的自动存盘路径上,能省就省。
"state": state.to_dict(), fresh = not os.path.exists(key) or os.path.getsize(key) == 0
}
# 注意:to_dict() 里的派生字段在读取时会被忽略,这里只取原始字段
payload["state"] = _raw_state(state)
tmp_path = path + ".tmp" conn = sqlite3.connect(key, timeout=BUSY_TIMEOUT)
with open(tmp_path, "w", encoding="utf-8") as handle: try:
json.dump(payload, handle, ensure_ascii=False, indent=2) conn.row_factory = sqlite3.Row
handle.flush() if fresh:
os.fsync(handle.fileno()) conn.execute("PRAGMA journal_mode=WAL")
os.replace(tmp_path, path) conn.executescript(_SCHEMA)
except Exception:
# 建表失败(比如这根本不是个 SQLite 文件)也要把句柄还回去。
# Windows 上文件句柄没关就删不掉,而调用方正是靠"读失败→开新档"来容错的。
conn.close()
raise
return conn
def save_state(state: GameState, path: str | None = None, farm_id: str = DEFAULT_FARM_ID) -> str:
"""把一个牧场写进库,返回库文件路径。"""
path = path or default_save_path()
key = normalize_farm_id(farm_id)
payload = json.dumps(_raw_state(state), ensure_ascii=False)
# ``with conn`` 是事务:要么整行更新,要么什么都没发生
with contextlib.closing(connect(path)) as conn, conn:
conn.execute(
"""
INSERT INTO farms
(farm_id, version, saved_at, updated_at, day, gold, level, ap,
weather, forecast, net_worth, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(farm_id) DO UPDATE SET
version = excluded.version,
saved_at = excluded.saved_at,
updated_at = excluded.updated_at,
day = excluded.day,
gold = excluded.gold,
level = excluded.level,
ap = excluded.ap,
weather = excluded.weather,
forecast = excluded.forecast,
net_worth = excluded.net_worth,
payload = excluded.payload
""",
(
key,
SAVE_VERSION,
time.strftime("%Y-%m-%d %H:%M:%S"),
time.time(),
state.day,
state.gold,
state.level,
state.ap,
state.weather,
state.forecast,
state.net_worth,
payload,
),
)
return path return path
@@ -92,34 +209,42 @@ def _raw_state(state: GameState) -> dict[str, Any]:
} }
def load_state(path: str | None = None) -> tuple[GameState | None, str]: def load_state(
"""读取存档。 path: str | None = None, farm_id: str = DEFAULT_FARM_ID
) -> tuple[GameState | None, str]:
"""读取一个牧场的存档。
返回 ``(state, message)``;失败时 ``state`` 为 None 并给出原因, 返回 ``(state, message)``;失败时 ``state`` 为 None 并给出原因,
调用方应当降级为"开新档"而不是崩溃。 调用方应当降级为"开新档"而不是崩溃。
""" """
path = path or default_save_path() path = path or default_save_path()
key = normalize_farm_id(farm_id)
if not os.path.exists(path): if not os.path.exists(path):
return None, f"未找到存档 {path}" return None, f"未找到存档 {path}"
try: try:
with open(path, encoding="utf-8") as handle: with contextlib.closing(connect(path)) as conn:
payload = json.load(handle) row = conn.execute(
except (OSError, json.JSONDecodeError) as exc: "SELECT version, payload FROM farms WHERE farm_id = ?", (key,)
return None, f"存档无法解析:{exc}" ).fetchone()
except sqlite3.DatabaseError as exc:
return None, f"存档无法读取:{exc}"
if not isinstance(payload, dict): if row is None:
return None, "存档格式错误:根节点不是对象" return None, f"存档里没有牧场「{key}」"
version = payload.get("version", 0) version = row["version"]
if not isinstance(version, int) or not (MIN_SAVE_VERSION <= version <= SAVE_VERSION): if not isinstance(version, int) or not (MIN_SAVE_VERSION <= version <= SAVE_VERSION):
return None, ( return None, (
f"存档版本不兼容(文件 v{version},当前支持 v{MIN_SAVE_VERSION}~v{SAVE_VERSION})" f"存档版本不兼容(文件 v{version},当前支持 v{MIN_SAVE_VERSION}~v{SAVE_VERSION})"
) )
raw = payload.get("state") try:
raw = json.loads(row["payload"])
except (TypeError, json.JSONDecodeError) as exc:
return None, f"存档内容异常:{exc}"
if not isinstance(raw, dict): if not isinstance(raw, dict):
return None, "存档缺少 state 字段" return None, "存档格式错误:状态不是对象"
try: try:
state = GameState.from_dict(raw) state = GameState.from_dict(raw)
@@ -129,10 +254,124 @@ def load_state(path: str | None = None) -> tuple[GameState | None, str]:
return state, f"已载入存档(第 {state.day} 天)" return state, f"已载入存档(第 {state.day} 天)"
def delete_save(path: str | None = None) -> bool: def delete_save(path: str | None = None, farm_id: str = DEFAULT_FARM_ID) -> bool:
"""删掉一个牧场,返回是否真的删到了。
只删那一行,**不删库文件**:同一台服务器上还有别的牧场,
而且连接开开关关,删文件容易留下悬空的 WAL。
"""
path = path or default_save_path() path = path or default_save_path()
try: key = normalize_farm_id(farm_id)
os.remove(path) if not os.path.exists(path):
return True
except OSError:
return False return False
try:
with contextlib.closing(connect(path)) as conn, conn:
cursor = conn.execute("DELETE FROM farms WHERE farm_id = ?", (key,))
return cursor.rowcount > 0
except sqlite3.DatabaseError:
return False
def list_farms(path: str | None = None) -> list[dict[str, Any]]:
"""列出库里的所有牧场(按净值降序)。
这是换成 SQLite 之后白拿的能力:一条 SQL 就能排榜、做巡检,
不用把每份存档都读进内存再算。
"""
path = path or default_save_path()
if not os.path.exists(path):
return []
try:
with contextlib.closing(connect(path)) as conn:
rows = conn.execute(
"""
SELECT farm_id, day, gold, level, ap, weather, forecast,
net_worth, saved_at
FROM farms
ORDER BY net_worth DESC, day DESC, farm_id
"""
).fetchall()
except sqlite3.DatabaseError:
return []
return [
{
"farm": row["farm_id"],
"day": row["day"],
"gold": row["gold"],
"level": row["level"],
"ap": row["ap"],
"weather": row["weather"],
"forecast": row["forecast"],
"net_worth": row["net_worth"],
"saved_at": row["saved_at"],
}
for row in rows
]
def migrate_json_saves(save_dir: str) -> list[str]:
"""把老版本的 JSON 存档导入数据库,返回成功导入的牧场名。
* 文件名(去掉 ``.json``)就是牧场名,与旧的"一牧场一个文件"约定一致;
* 新库里已经有同名牧场 → 跳过,并且**不动那个文件**(宁可不迁,也不覆盖);
* 导入成功后把文件改名成 ``<名字>.json.migrated`` 留在原地。
这里对每一份读不懂的档都是"跳过"而不是报错:迁移不该有能力阻止服务器
启动,坏档留给用户自己看。
"""
if not save_dir or not os.path.isdir(save_dir):
return []
db_path = os.path.join(save_dir, DB_NAME)
imported: list[str] = []
for entry in sorted(os.listdir(save_dir)):
if not entry.endswith(".json"):
continue
src = os.path.join(save_dir, entry)
if not os.path.isfile(src):
continue
farm_id = entry[: -len(".json")]
try:
with open(src, encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError):
continue
if not isinstance(payload, dict):
continue
version = payload.get("version", 0)
if not isinstance(version, int) or not (MIN_SAVE_VERSION <= version <= SAVE_VERSION):
continue
raw = payload.get("state")
if not isinstance(raw, dict):
continue
if farm_exists(db_path, farm_id):
continue
try:
state = GameState.from_dict(raw)
except (TypeError, ValueError, KeyError):
continue
save_state(state, db_path, farm_id)
# 先写库再改名:万一改名失败,下次启动还会重来一遍(幂等)
os.replace(src, src + ".migrated")
imported.append(normalize_farm_id(farm_id))
return imported
def farm_exists(path: str, farm_id: str) -> bool:
"""库里有没有这个牧场。库文件不存在时返回 False,且不创建它。"""
if not os.path.exists(path):
return False
try:
with contextlib.closing(connect(path)) as conn:
row = conn.execute(
"SELECT 1 FROM farms WHERE farm_id = ?", (normalize_farm_id(farm_id),)
).fetchone()
except sqlite3.DatabaseError:
return False
return row is not None
+3 -1
View File
@@ -117,7 +117,9 @@ def _add_server_parsers(sub) -> None:
default=config.DEFAULT_TICK_SECONDS, default=config.DEFAULT_TICK_SECONDS,
help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒", help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒",
) )
p_serve.add_argument("--save-dir", default=None, help="存档目录,默认 <项目>/saves") p_serve.add_argument(
"--save-dir", default=None, help="存档目录(里面是 SQLite 库 farm.db),默认 <项目>/saves"
)
p_serve.add_argument("--world", default="SimpleFarm 公共农场", help="世界名称") p_serve.add_argument("--world", default="SimpleFarm 公共农场", help="世界名称")
p_serve.add_argument("--max-sessions", type=int, default=64, help="同时在线的牧场上限") p_serve.add_argument("--max-sessions", type=int, default=64, help="同时在线的牧场上限")
p_serve.add_argument( p_serve.add_argument(
+200 -47
View File
@@ -10,8 +10,11 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import json import json
import os import os
import shutil
import sqlite3
import tempfile import tempfile
import time import time
import unittest import unittest
@@ -21,7 +24,15 @@ from farm import config
from farm.engine import FarmEngine, GameError, Scheduler, tick_decision from farm.engine import FarmEngine, GameError, Scheduler, tick_decision
from farm.models import Animal, GameState, Plot, clamp, format_duration from farm.models import Animal, GameState, Plot, clamp, format_duration
from farm.server.actions import run_action from farm.server.actions import run_action
from farm.storage import SAVE_VERSION, delete_save, load_state, save_state from farm.storage import (
DB_NAME,
SAVE_VERSION,
delete_save,
list_farms,
load_state,
migrate_json_saves,
save_state,
)
def make_engine(**kwargs) -> FarmEngine: def make_engine(**kwargs) -> FarmEngine:
@@ -222,17 +233,22 @@ class GameStateTestCase(unittest.TestCase):
class StorageTestCase(unittest.TestCase): class StorageTestCase(unittest.TestCase):
"""SQLite 存档:往返、多牧场隔离、坏档降级。
存档层换了实现,但这组用例守的是**行为契约**——"读不懂就降级成新档,
绝不抛异常"这条从 JSON 时代起就没变过。
"""
def setUp(self): def setUp(self):
self.dir = tempfile.mkdtemp(prefix="simplefarm-test-") self.dir = tempfile.mkdtemp(prefix="simplefarm-test-")
self.path = os.path.join(self.dir, "save.json") self.db = os.path.join(self.dir, DB_NAME)
def tearDown(self): def tearDown(self):
delete_save(self.path) shutil.rmtree(self.dir, ignore_errors=True)
def write_raw(self, payload) -> str: def raw_conn(self):
with open(self.path, "w", encoding="utf-8") as handle: """直连库文件,绕开 storage 去核对落盘结果。"""
json.dump(payload, handle, ensure_ascii=False) return sqlite3.connect(self.db)
return self.path
def test_round_trip_keeps_the_important_fields(self): def test_round_trip_keeps_the_important_fields(self):
engine = make_engine() engine = make_engine()
@@ -240,70 +256,207 @@ class StorageTestCase(unittest.TestCase):
engine.buy_animal("chicken") engine.buy_animal("chicken")
engine.state.day = 7 engine.state.day = 7
engine.state.exp = 42 engine.state.exp = 42
engine.state.inventory["wheat"] = 3
engine.state.buildings["well"] = 1
engine.state.weather, engine.state.forecast = "drought", "rain"
save_state(engine.state, self.path) save_state(engine.state, self.db, "alice")
loaded, message = load_state(self.path) loaded, message = load_state(self.db, "alice")
assert loaded is not None, message assert loaded is not None, message
self.assertEqual(loaded.day, 7) self.assertEqual(loaded.day, 7)
self.assertEqual(loaded.exp, 42) self.assertEqual(loaded.exp, 42)
self.assertEqual(loaded.plots[0].crop, "wheat") self.assertEqual(loaded.plots[0].crop, "wheat")
self.assertEqual([a.kind for a in loaded.animals], ["chicken"]) self.assertEqual([a.kind for a in loaded.animals], ["chicken"])
self.assertEqual(dict(loaded.inventory), {"wheat": 3})
self.assertEqual(loaded.buildings["well"], 1)
self.assertEqual((loaded.weather, loaded.forecast), ("drought", "rain"))
self.assertIn("第 7 天", message) self.assertIn("第 7 天", message)
def test_saved_file_is_readable_json_with_version(self): def test_common_fields_are_queryable_columns(self):
"""换成 SQLite 的意义就在这:不碰 payload 也能直接排榜、巡检。"""
engine = make_engine() engine = make_engine()
save_state(engine.state, self.path) engine.state.day = 4
with open(self.path, encoding="utf-8") as handle: save_state(engine.state, self.db, "alice")
payload = json.load(handle)
self.assertEqual(payload["version"], SAVE_VERSION)
self.assertIsInstance(payload["state"], dict)
def test_missing_file_is_not_an_error(self): with contextlib.closing(self.raw_conn()) as conn:
state, message = load_state(self.path) row = conn.execute(
"SELECT version, day, gold, weather, net_worth FROM farms"
).fetchone()
self.assertEqual(row[0], SAVE_VERSION)
self.assertEqual(row[1], 4)
self.assertEqual(row[2], engine.state.gold)
self.assertEqual(row[3], engine.state.weather)
self.assertEqual(row[4], engine.state.net_worth)
def test_payload_stays_readable_json(self):
"""payload 是故意留的 JSON——给人看、给工具改,不是黑盒二进制。"""
save_state(make_engine().state, self.db, "alice")
with contextlib.closing(self.raw_conn()) as conn:
payload = conn.execute("SELECT payload FROM farms").fetchone()[0]
self.assertIsInstance(json.loads(payload), dict)
def test_each_farm_gets_its_own_row(self):
alice = make_engine()
alice.state.day = 11
bob = make_engine()
bob.state.day = 3
save_state(alice.state, self.db, "alice")
save_state(bob.state, self.db, "bob")
self.assertEqual({row["farm"] for row in list_farms(self.db)}, {"alice", "bob"})
loaded, _ = load_state(self.db, "alice")
self.assertEqual(loaded.day if loaded else None, 11)
loaded, _ = load_state(self.db, "bob")
self.assertEqual(loaded.day if loaded else None, 3)
def test_saving_twice_updates_in_place(self):
save_state(make_engine().state, self.db, "alice")
engine = make_engine()
engine.state.day = 30
save_state(engine.state, self.db, "alice")
with contextlib.closing(self.raw_conn()) as conn:
count = conn.execute("SELECT COUNT(*) FROM farms").fetchone()[0]
self.assertEqual(count, 1, "同一个牧场不该插出两行")
loaded, _ = load_state(self.db, "alice")
self.assertEqual(loaded.day if loaded else None, 30)
def test_list_farms_orders_by_net_worth(self):
poor = make_engine()
poor.state.gold = 10
rich = make_engine()
rich.state.gold = 9000
save_state(poor.state, self.db, "poor")
save_state(rich.state, self.db, "rich")
self.assertEqual([row["farm"] for row in list_farms(self.db)], ["rich", "poor"])
def test_missing_database_is_not_an_error(self):
state, message = load_state(self.db, "alice")
self.assertIsNone(state) self.assertIsNone(state)
self.assertIn("未找到存档", message) self.assertIn("未找到存档", message)
def test_broken_json_degrades_instead_of_raising(self): def test_missing_farm_says_so(self):
with open(self.path, "w", encoding="utf-8") as handle: save_state(make_engine().state, self.db, "alice")
handle.write("{ this is not json") state, message = load_state(self.db, "bob")
state, message = load_state(self.path)
self.assertIsNone(state) self.assertIsNone(state)
self.assertIn("无法解析", message) self.assertIn("没有牧场", message)
def test_not_a_database_degrades_instead_of_raising(self):
with open(self.db, "w", encoding="utf-8") as handle:
handle.write("这不是 SQLite")
state, message = load_state(self.db, "alice")
self.assertIsNone(state)
self.assertIn("无法读取", message)
def test_wrong_version_is_refused(self): def test_wrong_version_is_refused(self):
self.write_raw({"version": SAVE_VERSION + 1, "state": {}}) save_state(make_engine().state, self.db, "alice")
state, message = load_state(self.path) with contextlib.closing(self.raw_conn()) as conn, conn:
conn.execute("UPDATE farms SET version = ?", (SAVE_VERSION + 1,))
state, message = load_state(self.db, "alice")
self.assertIsNone(state) self.assertIsNone(state)
self.assertIn("版本不兼容", message) self.assertIn("版本不兼容", message)
def test_root_must_be_an_object(self): def test_broken_payload_degrades(self):
self.write_raw([1, 2, 3]) save_state(make_engine().state, self.db, "alice")
state, message = load_state(self.path) with contextlib.closing(self.raw_conn()) as conn, conn:
self.assertIsNone(state) conn.execute("UPDATE farms SET payload = ?", ("{ 不是 json",))
self.assertIn("根节点不是对象", message) state, message = load_state(self.db, "alice")
def test_state_field_is_required(self):
self.write_raw({"version": SAVE_VERSION})
state, message = load_state(self.path)
self.assertIsNone(state)
self.assertIn("缺少 state", message)
def test_unexpected_state_shape_degrades(self):
self.write_raw({"version": SAVE_VERSION, "state": {"plots": "nonsense", "animals": 7}})
state, message = load_state(self.path)
self.assertIsNone(state) self.assertIsNone(state)
self.assertIn("存档内容异常", message) self.assertIn("存档内容异常", message)
def test_atomic_write_leaves_no_temp_file(self): def test_unexpected_state_shape_degrades(self):
engine = make_engine() save_state(make_engine().state, self.db, "alice")
save_state(engine.state, self.path) bad = json.dumps({"plots": "nonsense", "animals": 7})
self.assertFalse(os.path.exists(self.path + ".tmp")) with contextlib.closing(self.raw_conn()) as conn, conn:
conn.execute("UPDATE farms SET payload = ?", (bad,))
state, message = load_state(self.db, "alice")
self.assertIsNone(state)
self.assertIn("存档内容异常", message)
def test_delete_save_reports_truthfully(self): def test_delete_removes_the_row_but_keeps_the_library(self):
save_state(make_engine().state, self.path) save_state(make_engine().state, self.db, "alice")
self.assertTrue(delete_save(self.path)) save_state(make_engine().state, self.db, "bob")
self.assertFalse(os.path.exists(self.path))
self.assertTrue(delete_save(self.db, "alice"))
self.assertFalse(delete_save(self.db, "alice"), "删过的再删就没了")
self.assertEqual([row["farm"] for row in list_farms(self.db)], ["bob"])
self.assertTrue(os.path.exists(self.db), "库里还有别的牧场,文件不能删")
def test_delete_on_a_missing_database_is_false(self):
self.assertFalse(delete_save(self.db, "alice"))
def test_wal_mode_is_on(self):
save_state(make_engine().state, self.db, "alice")
with contextlib.closing(self.raw_conn()) as conn:
self.assertEqual(conn.execute("PRAGMA journal_mode").fetchone()[0], "wal")
class MigrationTestCase(unittest.TestCase):
"""老版本是一牧场一个 JSON,第一次启动时要并进库里,且一份都不许丢。"""
def setUp(self):
self.dir = tempfile.mkdtemp(prefix="simplefarm-migrate-")
self.db = os.path.join(self.dir, DB_NAME)
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def write_json(self, name: str, payload) -> str:
path = os.path.join(self.dir, f"{name}.json")
with open(path, "w", encoding="utf-8") as handle:
if isinstance(payload, str):
handle.write(payload)
else:
json.dump(payload, handle, ensure_ascii=False)
return path
def legacy(self, day: int = 12, version: int = SAVE_VERSION) -> dict:
return {
"version": version,
"saved_at": "2026-09-18 21:21:45",
"state": {"day": day, "gold": 340, "level": 3, "plots": [], "animals": []},
}
def test_legacy_json_is_imported_then_archived(self):
src = self.write_json("alice", self.legacy(day=12))
self.assertEqual(migrate_json_saves(self.dir), ["alice"])
state, message = load_state(self.db, "alice")
assert state is not None, message
self.assertEqual(state.day, 12)
self.assertTrue(os.path.exists(src + ".migrated"))
self.assertFalse(os.path.exists(src), "迁过的 json 要让位,免得反复导入")
def test_migration_is_idempotent(self):
self.write_json("alice", self.legacy())
migrate_json_saves(self.dir)
self.assertEqual(migrate_json_saves(self.dir), [], "第二次没有可迁的东西了")
def test_migration_never_overwrites_a_newer_farm(self):
self.write_json("alice", self.legacy(day=999))
fresh = make_engine()
fresh.state.day = 5
save_state(fresh.state, self.db, "alice")
self.assertEqual(migrate_json_saves(self.dir), [])
state, _ = load_state(self.db, "alice")
self.assertEqual(state.day if state else None, 5, "库里的新档优先")
self.assertTrue(os.path.exists(os.path.join(self.dir, "alice.json")))
def test_unreadable_files_are_left_alone(self):
self.write_json("broken", "{ 这不是 json")
self.write_json("future", self.legacy(version=SAVE_VERSION + 1))
self.write_json("ancient", self.legacy(version=0))
self.write_json("notobject", "[]")
self.assertEqual(migrate_json_saves(self.dir), [])
for name in ("broken", "future", "ancient", "notobject"):
self.assertTrue(os.path.exists(os.path.join(self.dir, f"{name}.json")), name)
def test_migration_is_a_noop_without_json(self):
self.assertEqual(migrate_json_saves(self.dir), [])
self.assertEqual(migrate_json_saves(os.path.join(self.dir, "没有这个目录")), [])
class ActionTestCase(unittest.TestCase): class ActionTestCase(unittest.TestCase):
+11 -8
View File
@@ -43,6 +43,7 @@ from farm.server.session import (
validate_farm_id, validate_farm_id,
validate_owner, validate_owner,
) )
from farm.storage import DB_NAME, farm_exists
try: # pragma: no cover - 环境相关 try: # pragma: no cover - 环境相关
import fastapi # noqa: F401 import fastapi # noqa: F401
@@ -216,9 +217,9 @@ class FarmSessionTestCase(unittest.TestCase):
self.assertIn("第 9 天", reopened.load_message) self.assertIn("第 9 天", reopened.load_message)
def test_broken_save_degrades_to_a_new_farm(self): def test_broken_save_degrades_to_a_new_farm(self):
path = os.path.join(self.dir, "alice.json") path = os.path.join(self.dir, DB_NAME)
with open(path, "w", encoding="utf-8") as handle: with open(path, "w", encoding="utf-8") as handle:
handle.write("这不是 JSON") handle.write("这不是 SQLite")
session = FarmSession("alice", self.dir, 2400.0) session = FarmSession("alice", self.dir, 2400.0)
self.assertEqual(session.engine.state.day, 1) self.assertEqual(session.engine.state.day, 1)
self.assertIn("已开新档", session.load_message) self.assertIn("已开新档", session.load_message)
@@ -255,7 +256,7 @@ class FarmSessionTestCase(unittest.TestCase):
session.dirty = True session.dirty = True
session.saved_at = 0.0 session.saved_at = 0.0
self.assertTrue(session.autosave()) self.assertTrue(session.autosave())
self.assertTrue(os.path.exists(session.path)) self.assertTrue(farm_exists(session.db_path, session.farm_id))
def test_autosave_waits_between_writes(self): def test_autosave_waits_between_writes(self):
session = FarmSession("alice", self.dir, 2400.0) session = FarmSession("alice", self.dir, 2400.0)
@@ -346,7 +347,7 @@ class SessionManagerTestCase(unittest.TestCase):
dropped = manager.reap() dropped = manager.reap()
self.assertEqual(dropped, ["alice"]) self.assertEqual(dropped, ["alice"])
self.assertEqual(manager.count(), 0) self.assertEqual(manager.count(), 0)
self.assertTrue(os.path.exists(session.path), "回收前必须先落盘") self.assertTrue(farm_exists(session.db_path, session.farm_id), "回收前必须先落盘")
def test_reap_keeps_farms_that_are_still_fresh(self): def test_reap_keeps_farms_that_are_still_fresh(self):
manager = self.manager(idle_timeout=3600) manager = self.manager(idle_timeout=3600)
@@ -387,7 +388,7 @@ class SessionManagerTestCase(unittest.TestCase):
manager.get(name) manager.get(name)
manager.shutdown() manager.shutdown()
for name in ("alice", "bob"): for name in ("alice", "bob"):
self.assertTrue(os.path.exists(os.path.join(self.dir, f"{name}.json"))) self.assertTrue(farm_exists(os.path.join(self.dir, DB_NAME), name))
class FarmClockTestCase(unittest.TestCase): class FarmClockTestCase(unittest.TestCase):
@@ -1024,8 +1025,10 @@ class RestApiTestCase(unittest.TestCase):
def test_save_goes_through_the_session_not_the_default_path(self): def test_save_goes_through_the_session_not_the_default_path(self):
"""存档位置由会话决定;直落 engine.save() 会写错地方、覆盖别人的档。""" """存档位置由会话决定;直落 engine.save() 会写错地方、覆盖别人的档。"""
self.client.post("/api/actions?farm=alice", json={"action": "save", "params": {}}) self.client.post("/api/actions?farm=alice", json={"action": "save", "params": {}})
self.assertTrue(os.path.exists(os.path.join(self.dir, "alice.json"))) self.assertTrue(farm_exists(os.path.join(self.dir, DB_NAME), "alice"))
self.assertFalse(os.path.exists(os.path.join(self.dir, "save.json"))) self.assertFalse(
os.path.exists(os.path.join(self.dir, "save.json")), "别在会话目录外另起炉灶"
)
def test_reset_only_touches_its_own_farm(self): def test_reset_only_touches_its_own_farm(self):
self.client.post( self.client.post(
@@ -1255,7 +1258,7 @@ class LiveServerTestCase(unittest.TestCase):
client = self.connect("live-save") client = self.connect("live-save")
client.plant(1, "wheat") client.plant(1, "wheat")
client.save() client.save()
self.assertTrue(os.path.exists(os.path.join(self.dir, "live-save.json"))) self.assertTrue(farm_exists(os.path.join(self.dir, DB_NAME), "live-save"))
class SessionIsolationAcrossProcessesTestCase(unittest.TestCase): class SessionIsolationAcrossProcessesTestCase(unittest.TestCase):
+1 -1
View File
@@ -70,7 +70,7 @@ def make_app(**kwargs) -> TuiApp:
engine.state.gold = 3000 engine.state.gold = 3000
directory = tempfile.mkdtemp(prefix="simplefarm-tui-") directory = tempfile.mkdtemp(prefix="simplefarm-tui-")
options = { options = {
"save_path": os.path.join(directory, "save.json"), "save_path": os.path.join(directory, "farm.db"),
"use_color": False, "use_color": False,
"autosave": False, "autosave": False,
} }