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

+200 -47
View File
@@ -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):