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
+2
-1
@@ -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
|
||||
|
||||
+17
-7
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user