为什么:游戏的时间(40 分钟一天、只能自然流动)本身就是唯一的行动节奏 阀门,再叠一层"每天 10 点行动点"纯属重复惩罚——浇水还要体力既不拟真 也不好玩。删掉之后,稀缺的只剩时间本身,放置玩法的定位反而更纯粹。 改动范围(引擎到界面连根拔): - config 删 START_AP/AP_COST;models 删 ap 字段与 max_ap,存档 payload 里的旧 ap 键被 from_dict 自然忽略; - engine 删 _spend_ap 与全部 8 个调用点、每日恢复逻辑与日志; available_actions 不再按行动点筛,cost 只剩金币; - storage 建表去掉 ap 列,老库在 connect() 里 ALTER TABLE DROP COLUMN 无痛迁移(实测 saves/farm.db 已自动迁完); - bot 删 AP 分支(无 AP 后 available_actions 恒可用);client/TUI/CLI/ README/帮助文本同步清理。 网页端顺带两件事: - "下一天"倒计时改为客户端本地走秒——SSE 只在有变化时推送,以前倒计时 只在推送瞬间跳变,现在每秒都在动(记基点 + 1s 定时器补齐); - 字体优化:页面字体栈补齐 MiSans/HarmonyOS Sans/PingFang,正文字号 14→15,统计数字 tabular-nums;canvas 小字统一字体栈并加粗提对比度。 测试适配:删 2 个 AP 专属用例(无 AP 可测)、改名/收敛 5 个, 532 项全绿(skip 39),ruff/pyright 干净。真机验证:老库迁移、 统计条无行动点、倒计时逐秒递减(39:04→39:02)、动作不再报 no_ap。
377 lines
13 KiB
Python
377 lines
13 KiB
Python
"""存档读写(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
|