动手前先修掉了两处「死机制」,新增的三个系统各自激活一个: * 天气:小麦 2 天成熟、初始水分 2、每天只掉 1 点,等于永远不需要浇水。 现在水分消耗由天气决定(晴 1 / 多云 0 / 干旱 2,雨天反补 3),而 `state.forecast` 把明天的天气提前告诉你——「今晚浇不浇」成了能算的决策。 5 种天气、权重合计 100、同种不连出;暴风雨会毁未熟作物,温室免疫。 * 谷仓:市场波动原本纯装饰(收获当场按市价变现,没有择时空间)。现在收获与 收取只进 `state.inventory`,靠新增的 `sell` 换金币,价格波动才真正参与决策。 仓满时 `_store()` 抛 `granary_full` 且不改状态,作物原样留在地里。 * 建筑:新增 `build`,买的是「以后不用天天手动做」——水井自动浇干地、 喂食器自动喂食(只吃库存饲料,绝不自动花钱)、温室、谷仓扩容。 每天在 `advance_day` 里先于生长执行,同类越造越贵(×1.6)。 存档升到 `SAVE_VERSION=2`,配 `MIN_SAVE_VERSION=1` 向后兼容,旧档缺的新字段 一律走默认值,不作废玩家手里的档。`advance_day(weather=...)` 是留给测试的 确定性入口。 顺带修两个真 bug: * `daemon.py` 三处 `makedirs(os.path.dirname(path))`:`--pid-file pid.txt` 这种只给文件名的用法会让 dirname 变成空串,启动阶段直接崩。抽出 `ensure_parent_dir()` 兜底。 * `RemoteFarm.snapshot()` 是本地缓存,非实时客户端轮询会一直读到旧数据, 看起来像服务器卡死。补 docstring 指明要自己调 `refresh()`。 质量门禁:ruff format/check 全绿、pyright 0 错误、503 项测试 OK(skip 35)、 bench 无回归(brief 19.5µs / snapshot 48.5µs / POST full=1 400µs)。 另起真实服务器(--tick 3 + 预置存档)跑了 38 项端到端验收,全过。
139 lines
4.3 KiB
Python
139 lines
4.3 KiB
Python
"""存档读写。
|
||
|
||
存档是纯 JSON,方便人工检查,也方便外部工具/AI 直接读取分析。
|
||
写入采用"临时文件 + 原子替换",避免中途崩溃导致存档损坏。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import time
|
||
from typing import Any
|
||
|
||
from .models import GameState
|
||
|
||
SAVE_VERSION = 2 # v2 起:谷仓存货 / 建筑 / 天气
|
||
# 还能读回来的最老版本。存档读取是「宽容向后」的:老档缺的新字段
|
||
# 一律走默认值,所以没必要因为升了一版就把玩家手里的档作废。
|
||
MIN_SAVE_VERSION = 1
|
||
|
||
|
||
def default_save_path() -> str:
|
||
"""默认存档位置:项目根目录下的 saves/save.json。"""
|
||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
return os.path.join(root, "saves", "save.json")
|
||
|
||
|
||
def save_state(state: GameState, path: str | None = None) -> str:
|
||
"""把状态写入磁盘,返回实际写入路径。"""
|
||
path = path or default_save_path()
|
||
directory = os.path.dirname(os.path.abspath(path))
|
||
os.makedirs(directory, exist_ok=True)
|
||
|
||
payload = {
|
||
"version": SAVE_VERSION,
|
||
"saved_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"state": state.to_dict(),
|
||
}
|
||
# 注意:to_dict() 里的派生字段在读取时会被忽略,这里只取原始字段
|
||
payload["state"] = _raw_state(state)
|
||
|
||
tmp_path = path + ".tmp"
|
||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||
handle.flush()
|
||
os.fsync(handle.fileno())
|
||
os.replace(tmp_path, path)
|
||
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,
|
||
"ap": state.ap,
|
||
"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) -> tuple[GameState | None, str]:
|
||
"""读取存档。
|
||
|
||
返回 ``(state, message)``;失败时 ``state`` 为 None 并给出原因,
|
||
调用方应当降级为"开新档"而不是崩溃。
|
||
"""
|
||
path = path or default_save_path()
|
||
if not os.path.exists(path):
|
||
return None, f"未找到存档 {path}"
|
||
|
||
try:
|
||
with open(path, encoding="utf-8") as handle:
|
||
payload = json.load(handle)
|
||
except (OSError, json.JSONDecodeError) as exc:
|
||
return None, f"存档无法解析:{exc}"
|
||
|
||
if not isinstance(payload, dict):
|
||
return None, "存档格式错误:根节点不是对象"
|
||
|
||
version = payload.get("version", 0)
|
||
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})"
|
||
)
|
||
|
||
raw = payload.get("state")
|
||
if not isinstance(raw, dict):
|
||
return None, "存档缺少 state 字段"
|
||
|
||
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) -> bool:
|
||
path = path or default_save_path()
|
||
try:
|
||
os.remove(path)
|
||
return True
|
||
except OSError:
|
||
return False
|