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
+200
-47
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user