From bc3ae96a3883e7bfe262297e622c455703c0af70 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:04:30 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=AD=98=E6=A1=A3=E5=B1=82?= =?UTF-8?q?=E4=BB=8E=E3=80=8C=E4=B8=80=E7=89=A7=E5=9C=BA=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=20JSON=E3=80=8D=E6=8D=A2=E6=88=90=20SQLite=EF=BC=88=E5=8D=95?= =?UTF-8?q?=E5=BA=93=E5=8D=95=E8=A1=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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() 排榜、重启后进度分毫不差,全过。 --- .workbuddy/memory/2026-09-19.md | 34 ++++ .workbuddy/memory/MEMORY.md | 16 +- README.md | 10 +- farm/server/app.py | 3 +- farm/server/session.py | 24 ++- farm/storage.py | 317 ++++++++++++++++++++++++++++---- main.py | 4 +- tests/test_engine.py | 247 ++++++++++++++++++++----- tests/test_server.py | 19 +- tests/test_tui_keys.py | 2 +- 10 files changed, 563 insertions(+), 113 deletions(-) diff --git a/.workbuddy/memory/2026-09-19.md b/.workbuddy/memory/2026-09-19.md index aa5ff46..7e0c236 100644 --- a/.workbuddy/memory/2026-09-19.md +++ b/.workbuddy/memory/2026-09-19.md @@ -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`,重发一次就好,不是真故障。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 45775f5..8eb5538 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -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`, diff --git a/README.md b/README.md index 3bf8208..816b8c7 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ SSE 长连接在两种协议下都照常工作。 ## 7. 多人模型 -* **一个牧场 = 一个玩家**:独立引擎、独立时钟、独立存档(`saves/<牧场名>.json`)。 +* **一个牧场 = 一个玩家**:独立引擎、独立时钟,存档在 `saves/farm.db`(SQLite,**一个牧场一行**)。 * 牧场之间完全隔离(金币、土地、市场各自独立),但共享排行榜(`GET /api/farms`)。 * 同牧场可以有多人/多程序同时操作,引擎内的 `RLock` 保证串行,不会出现"两个人同时种同一块地"这类竞态。 * 所有牧场的时钟由**一个** `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`) | | 停止 | `uv run main.py stop`(先走 HTTP 优雅停机,失败再发信号) | | 状态 | `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"` 可直接查) | | 接口文档 | | 实现上不依赖 systemd / nohup / 任何第三方库:POSIX 用新会话、Windows 用 `DETACHED_PROCESS`, @@ -382,7 +382,7 @@ farm/ config.py 数值与静态表(作物、牲畜、经济、时间) models.py Plot / Animal / GameState —— 只持有数据 engine.py FarmEngine / Scheduler —— 纯游戏规则(不依赖网络与终端) - storage.py 存档读写(临时文件 + 原子替换) + storage.py 存档读写(SQLite,一牧场一行;老 JSON 档启动时自动导入) keys.py 跨平台按键读取 net.py 回环请求绕过系统代理的小工具 tui.py 终端界面(自适应宽度、双宽字符对齐,与引擎解耦) @@ -481,7 +481,9 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是 * 推送节流 250ms,高频操作自动合并成一帧;每条推送带自增 `seq`,客户端据此丢弃乱序帧; * 引擎调用统一丢进线程池,不阻塞事件循环; * 单条连接/单个牧场出错不影响其它牧场; -* 存档用"临时文件 + `os.replace`"原子替换,崩溃不会写坏存档;存档读取失败会降级为开新档而不是崩溃。 +* 存档是 SQLite(`saves/farm.db`,一牧场一行):写入包在事务里,掉电也不会写坏档; + 老版本 JSON 存档启动时自动导入(原文件改名 `*.json.migrated` 留底); + 存档读取失败会降级为开新档而不是崩溃。 **本地请求不走系统代理**(`farm/net.py`): diff --git a/farm/server/app.py b/farm/server/app.py index 67cf56f..f3343ba 100644 --- a/farm/server/app.py +++ b/farm/server/app.py @@ -28,6 +28,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Str from .. import config from ..engine import GameError +from ..storage import StorageError from . import actions as actions_mod from .hub import ( CLOSE_MESSAGE, @@ -597,7 +598,7 @@ def _register_admin_routes(app: FastAPI, svc: _Services) -> None: try: session.save() saved.append(session.farm_id) - except OSError as exc: + except (OSError, StorageError) as exc: raise GameError( "save_failed", f"{session.farm_id} 存档失败:{exc}", status=500 ) from exc diff --git a/farm/server/session.py b/farm/server/session.py index e822d25..5abaf5d 100644 --- a/farm/server/session.py +++ b/farm/server/session.py @@ -24,7 +24,13 @@ from typing import Any from .. import config 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" MAX_FARM_ID = 24 @@ -68,7 +74,8 @@ class FarmSession: self.farm_id = farm_id self.owner = validate_owner(owner) or farm_id 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.engine = FarmEngine(tick_seconds=tick_seconds) @@ -95,7 +102,7 @@ class FarmSession: self.dirty = True 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: self.engine.state = state self.load_message = message @@ -109,7 +116,7 @@ class FarmSession: def save(self) -> str: 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() return path @@ -122,12 +129,12 @@ class FarmSession: try: self.save() return True - except OSError: + except (OSError, StorageError): return False def shutdown(self) -> None: # 停机时尽力落盘;写失败也不能挡住进程退出 - with contextlib.suppress(OSError): + with contextlib.suppress(OSError, StorageError): self.save() def touch(self, count_action: bool = False) -> None: @@ -203,6 +210,9 @@ class SessionManager: self._lock = threading.RLock() self._sessions: dict[str, FarmSession] = {} 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: @@ -270,7 +280,7 @@ class SessionManager: continue try: session.shutdown() - except OSError: + except (OSError, StorageError): continue # 存不下就别丢,宁可占着内存 with self._lock: self._sessions.pop(session.farm_id, None) diff --git a/farm/storage.py b/farm/storage.py index b6b04e0..ddaaa53 100644 --- a/farm/storage.py +++ b/farm/storage.py @@ -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 +import contextlib import json import os +import sqlite3 import time from typing import Any @@ -18,33 +33,135 @@ SAVE_VERSION = 2 # v2 起:谷仓存货 / 建筑 / 天气 # 一律走默认值,所以没必要因为升了一版就把玩家手里的档作废。 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: - """默认存档位置:项目根目录下的 saves/save.json。""" - root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - return os.path.join(root, "saves", "save.json") + """默认存档库:项目根目录下的 saves/farm.db。""" + return os.path.join(default_save_dir(), DB_NAME) -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() - directory = os.path.dirname(os.path.abspath(path)) - os.makedirs(directory, exist_ok=True) + key = os.path.abspath(path) + parent = os.path.dirname(key) + os.makedirs(parent, exist_ok=True) - payload = { - "version": SAVE_VERSION, - "saved_at": time.strftime("%Y-%m-%d %H:%M:%S"), - "state": state.to_dict(), - } - # 注意:to_dict() 里的派生字段在读取时会被忽略,这里只取原始字段 - payload["state"] = _raw_state(state) + # 空文件也算新库(比如刚被截断过)。WAL 是写在库文件头里的**持久**属性, + # 建库时设一次就够;每次连接都重设会白花约 2ms(实测),而存档在 + # 每 60 秒一次的自动存盘路径上,能省就省。 + fresh = not os.path.exists(key) or os.path.getsize(key) == 0 - tmp_path = path + ".tmp" - with open(tmp_path, "w", encoding="utf-8") as handle: - json.dump(payload, handle, ensure_ascii=False, indent=2) - handle.flush() - os.fsync(handle.fileno()) - os.replace(tmp_path, path) + conn = sqlite3.connect(key, timeout=BUSY_TIMEOUT) + try: + conn.row_factory = sqlite3.Row + if fresh: + conn.execute("PRAGMA journal_mode=WAL") + 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 @@ -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 并给出原因, 调用方应当降级为"开新档"而不是崩溃。 """ path = path or default_save_path() + key = normalize_farm_id(farm_id) if not os.path.exists(path): return None, f"未找到存档 {path}" try: - with open(path, encoding="utf-8") as handle: - payload = json.load(handle) - except (OSError, json.JSONDecodeError) as exc: - return None, f"存档无法解析:{exc}" + with contextlib.closing(connect(path)) as conn: + row = conn.execute( + "SELECT version, payload FROM farms WHERE farm_id = ?", (key,) + ).fetchone() + except sqlite3.DatabaseError as exc: + return None, f"存档无法读取:{exc}" - if not isinstance(payload, dict): - return None, "存档格式错误:根节点不是对象" + if row is 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): return None, ( 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): - return None, "存档缺少 state 字段" + return None, "存档格式错误:状态不是对象" try: 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} 天)" -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() - try: - os.remove(path) - return True - except OSError: + key = normalize_farm_id(farm_id) + if not os.path.exists(path): 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 diff --git a/main.py b/main.py index 5c70bce..0bc2ad0 100644 --- a/main.py +++ b/main.py @@ -117,7 +117,9 @@ def _add_server_parsers(sub) -> None: default=config.DEFAULT_TICK_SECONDS, 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("--max-sessions", type=int, default=64, help="同时在线的牧场上限") p_serve.add_argument( diff --git a/tests/test_engine.py b/tests/test_engine.py index 74dd34d..9dbba6a 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -10,8 +10,11 @@ from __future__ import annotations +import contextlib import json import os +import shutil +import sqlite3 import tempfile import time import unittest @@ -21,7 +24,15 @@ from farm import config from farm.engine import FarmEngine, GameError, Scheduler, tick_decision from farm.models import Animal, GameState, Plot, clamp, format_duration 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: @@ -222,17 +233,22 @@ class GameStateTestCase(unittest.TestCase): class StorageTestCase(unittest.TestCase): + """SQLite 存档:往返、多牧场隔离、坏档降级。 + + 存档层换了实现,但这组用例守的是**行为契约**——"读不懂就降级成新档, + 绝不抛异常"这条从 JSON 时代起就没变过。 + """ + def setUp(self): 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): - delete_save(self.path) + shutil.rmtree(self.dir, ignore_errors=True) - def write_raw(self, payload) -> str: - with open(self.path, "w", encoding="utf-8") as handle: - json.dump(payload, handle, ensure_ascii=False) - return self.path + def raw_conn(self): + """直连库文件,绕开 storage 去核对落盘结果。""" + return sqlite3.connect(self.db) def test_round_trip_keeps_the_important_fields(self): engine = make_engine() @@ -240,70 +256,207 @@ class StorageTestCase(unittest.TestCase): engine.buy_animal("chicken") engine.state.day = 7 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) - loaded, message = load_state(self.path) + save_state(engine.state, self.db, "alice") + loaded, message = load_state(self.db, "alice") assert loaded is not None, message self.assertEqual(loaded.day, 7) self.assertEqual(loaded.exp, 42) self.assertEqual(loaded.plots[0].crop, "wheat") 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) - def test_saved_file_is_readable_json_with_version(self): + def test_common_fields_are_queryable_columns(self): + """换成 SQLite 的意义就在这:不碰 payload 也能直接排榜、巡检。""" engine = make_engine() - save_state(engine.state, self.path) - with open(self.path, encoding="utf-8") as handle: - payload = json.load(handle) - self.assertEqual(payload["version"], SAVE_VERSION) - self.assertIsInstance(payload["state"], dict) + engine.state.day = 4 + save_state(engine.state, self.db, "alice") - def test_missing_file_is_not_an_error(self): - state, message = load_state(self.path) + with contextlib.closing(self.raw_conn()) as conn: + 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.assertIn("未找到存档", message) - def test_broken_json_degrades_instead_of_raising(self): - with open(self.path, "w", encoding="utf-8") as handle: - handle.write("{ this is not json") - state, message = load_state(self.path) + def test_missing_farm_says_so(self): + save_state(make_engine().state, self.db, "alice") + state, message = load_state(self.db, "bob") 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): - self.write_raw({"version": SAVE_VERSION + 1, "state": {}}) - state, message = load_state(self.path) + save_state(make_engine().state, self.db, "alice") + 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.assertIn("版本不兼容", message) - def test_root_must_be_an_object(self): - self.write_raw([1, 2, 3]) - state, message = load_state(self.path) - self.assertIsNone(state) - self.assertIn("根节点不是对象", message) - - 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) + def test_broken_payload_degrades(self): + save_state(make_engine().state, self.db, "alice") + with contextlib.closing(self.raw_conn()) as conn, conn: + conn.execute("UPDATE farms SET payload = ?", ("{ 不是 json",)) + state, message = load_state(self.db, "alice") self.assertIsNone(state) self.assertIn("存档内容异常", message) - def test_atomic_write_leaves_no_temp_file(self): - engine = make_engine() - save_state(engine.state, self.path) - self.assertFalse(os.path.exists(self.path + ".tmp")) + def test_unexpected_state_shape_degrades(self): + save_state(make_engine().state, self.db, "alice") + bad = json.dumps({"plots": "nonsense", "animals": 7}) + 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): - save_state(make_engine().state, self.path) - self.assertTrue(delete_save(self.path)) - self.assertFalse(os.path.exists(self.path)) + def test_delete_removes_the_row_but_keeps_the_library(self): + save_state(make_engine().state, self.db, "alice") + save_state(make_engine().state, self.db, "bob") + + 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): diff --git a/tests/test_server.py b/tests/test_server.py index 9e59776..3529b75 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -43,6 +43,7 @@ from farm.server.session import ( validate_farm_id, validate_owner, ) +from farm.storage import DB_NAME, farm_exists try: # pragma: no cover - 环境相关 import fastapi # noqa: F401 @@ -216,9 +217,9 @@ class FarmSessionTestCase(unittest.TestCase): self.assertIn("第 9 天", reopened.load_message) 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: - handle.write("这不是 JSON") + handle.write("这不是 SQLite") session = FarmSession("alice", self.dir, 2400.0) self.assertEqual(session.engine.state.day, 1) self.assertIn("已开新档", session.load_message) @@ -255,7 +256,7 @@ class FarmSessionTestCase(unittest.TestCase): session.dirty = True session.saved_at = 0.0 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): session = FarmSession("alice", self.dir, 2400.0) @@ -346,7 +347,7 @@ class SessionManagerTestCase(unittest.TestCase): dropped = manager.reap() self.assertEqual(dropped, ["alice"]) 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): manager = self.manager(idle_timeout=3600) @@ -387,7 +388,7 @@ class SessionManagerTestCase(unittest.TestCase): manager.get(name) manager.shutdown() 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): @@ -1024,8 +1025,10 @@ class RestApiTestCase(unittest.TestCase): def test_save_goes_through_the_session_not_the_default_path(self): """存档位置由会话决定;直落 engine.save() 会写错地方、覆盖别人的档。""" self.client.post("/api/actions?farm=alice", json={"action": "save", "params": {}}) - self.assertTrue(os.path.exists(os.path.join(self.dir, "alice.json"))) - self.assertFalse(os.path.exists(os.path.join(self.dir, "save.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")), "别在会话目录外另起炉灶" + ) def test_reset_only_touches_its_own_farm(self): self.client.post( @@ -1255,7 +1258,7 @@ class LiveServerTestCase(unittest.TestCase): client = self.connect("live-save") client.plant(1, "wheat") 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): diff --git a/tests/test_tui_keys.py b/tests/test_tui_keys.py index b51fb78..8faac30 100644 --- a/tests/test_tui_keys.py +++ b/tests/test_tui_keys.py @@ -70,7 +70,7 @@ def make_app(**kwargs) -> TuiApp: engine.state.gold = 3000 directory = tempfile.mkdtemp(prefix="simplefarm-tui-") options = { - "save_path": os.path.join(directory, "save.json"), + "save_path": os.path.join(directory, "farm.db"), "use_color": False, "autosave": False, }