"""存档读写(SQLite)。 存档是一个 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 from .models import GameState 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, 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/farm.db。""" return os.path.join(default_save_dir(), DB_NAME) 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() key = os.path.abspath(path) parent = os.path.dirname(key) os.makedirs(parent, exist_ok=True) # 空文件也算新库(比如刚被截断过)。WAL 是写在库文件头里的**持久**属性, # 建库时设一次就够;每次连接都重设会白花约 2ms(实测),而存档在 # 每 60 秒一次的自动存盘路径上,能省就省。 fresh = not os.path.exists(key) or os.path.getsize(key) == 0 conn = sqlite3.connect(key, timeout=BUSY_TIMEOUT) try: conn.row_factory = sqlite3.Row if fresh: conn.execute("PRAGMA journal_mode=WAL") conn.executescript(_SCHEMA) # v2 时代的库里留着已废弃的 ap 列(行动点从玩法里移除了),老库在此无痛迁移。 cols = {row[1] for row in conn.execute("PRAGMA table_info(farms)")} if "ap" in cols: conn.execute("ALTER TABLE farms DROP COLUMN ap") 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, 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, 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.weather, state.forecast, state.net_worth, payload, ), ) return path def _raw_state(state: GameState) -> dict[str, Any]: """导出可完整还原的原始字段(与 to_dict 的展示字段不同)。""" return { "day": state.day, "weather": state.weather, "forecast": state.forecast, "gold": state.gold, "level": state.level, "exp": state.exp, "feed": state.feed, "inventory": dict(state.inventory), "buildings": dict(state.buildings), "plots": [ { "index": p.index, "crop": p.crop, "growth": p.growth, "moisture": p.moisture, "planted_day": p.planted_day, } for p in state.plots ], "animals": [ { "id": a.aid, "kind": a.kind, "fed": a.fed, "progress": a.progress, "starve": a.starve, "pending": a.pending, } for a in state.animals ], "market": state.market, "stats": { "harvested": state.total_harvested, "collected": state.total_collected, "earned": state.total_earned, "spent": state.total_spent, }, } 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 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 row is None: return None, f"存档里没有牧场「{key}」" 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})" ) try: raw = json.loads(row["payload"]) except (TypeError, json.JSONDecodeError) as exc: return None, f"存档内容异常:{exc}" if not isinstance(raw, dict): return None, "存档格式错误:状态不是对象" try: state = GameState.from_dict(raw) except (TypeError, ValueError, KeyError) as exc: return None, f"存档内容异常:{exc}" return state, f"已载入存档(第 {state.day} 天)" def delete_save(path: str | None = None, farm_id: str = DEFAULT_FARM_ID) -> bool: """删掉一个牧场,返回是否真的删到了。 只删那一行,**不删库文件**:同一台服务器上还有别的牧场, 而且连接开开关关,删文件容易留下悬空的 WAL。 """ path = path or default_save_path() 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, 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"], "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