Files
SimpleFarm/farm/storage.py
T
snowy-yang 15d290a60a 移除行动点系统,倒计时本地走秒,字体优化
为什么:游戏的时间(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。
2026-09-25 22:44:08 +08:00

377 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""存档读写(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