Files
bd2/tools/python/save_checkpoint.py
T
Flechazo f55a71ac52 feat: expand growth, equipment, and mail
- add character promotion and costume potential flows
- add equipment management and enhancement
- harden development mail and save tools
2026-09-21 22:57:53 +08:00

210 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Create, verify, and restore complete local-account checkpoints.
Unlike the old tutorial-specific Go command this tool does not synthesize game
state. It copies the nine authoritative JSON files, records SHA-256 hashes, and
requires --apply plus stopped client/server processes before restore.
"""
from __future__ import annotations
import argparse
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
STATE_FILES = (
"characters.json",
"collection.json",
"deck.json",
"equipment.json",
"items.json",
"mail.json",
"missions.json",
"progress.json",
"wallet.json",
)
MANIFEST = "checkpoint.json"
WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_STATE = WORKSPACE_ROOT / "data" / "state"
def digest(path: Path) -> str:
result = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
result.update(block)
return result.hexdigest().upper()
def inspect_files(directory: Path) -> dict[str, dict[str, object]]:
result = {}
for name in STATE_FILES:
path = directory / name
if not path.is_file():
raise FileNotFoundError(f"missing account state file: {path}")
with path.open("r", encoding="utf-8") as stream:
json.load(stream)
result[name] = {"size": path.stat().st_size, "sha256": digest(path)}
return result
def running_processes() -> list[str]:
if os.name != "nt":
return []
result = subprocess.run(
["tasklist", "/FO", "CSV", "/NH"],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
raise RuntimeError(
"cannot verify that the client/server are stopped; "
f"tasklist failed with exit code {result.returncode}: {detail}"
)
return [
line
for line in result.stdout.splitlines()
if "browndust2" in line.lower()
or "brown dust ii" in line.lower()
or "bd2server.exe" in line.lower()
]
def write_manifest(target: Path, files: dict[str, dict[str, object]], label: str) -> None:
value = {
"format": 1,
"created_at": datetime.now().astimezone().isoformat(),
"label": label,
"files": files,
}
path = target / MANIFEST
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temporary.open("w", encoding="utf-8", newline="\n") as stream:
json.dump(value, stream, ensure_ascii=False, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def create(state: Path, target: Path, label: str) -> dict[str, dict[str, object]]:
source_info = inspect_files(state)
if target.exists():
raise FileExistsError(f"checkpoint already exists: {target}")
target.mkdir(parents=True)
try:
for name in STATE_FILES:
shutil.copy2(state / name, target / name)
copied_info = inspect_files(target)
if copied_info != source_info:
raise OSError("checkpoint differs from source after SHA-256 verification")
write_manifest(target, copied_info, label)
return copied_info
except Exception:
shutil.rmtree(target, ignore_errors=True)
raise
def verify(checkpoint: Path) -> dict[str, dict[str, object]]:
actual = inspect_files(checkpoint)
manifest_path = checkpoint / MANIFEST
if manifest_path.is_file():
with manifest_path.open("r", encoding="utf-8") as stream:
manifest = json.load(stream)
if manifest.get("format") != 1 or manifest.get("files") != actual:
raise ValueError("checkpoint manifest does not match its files")
return actual
def atomic_copy(source: Path, target: Path) -> None:
temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
try:
with source.open("rb") as reader, temporary.open("wb") as writer:
shutil.copyfileobj(reader, writer, length=1024 * 1024)
writer.flush()
os.fsync(writer.fileno())
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
def restore(state: Path, checkpoint: Path, apply: bool) -> Path | None:
checkpoint_info = verify(checkpoint)
print(json.dumps(checkpoint_info, ensure_ascii=False, indent=2))
if not apply:
print("dry run only; stop client/server and pass --apply to restore", file=sys.stderr)
return None
running = running_processes()
if running:
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup = state / "checkpoints" / f"{stamp}-before-restore"
create(state, backup, "automatic backup before restore")
for name in STATE_FILES:
atomic_copy(checkpoint / name, state / name)
if inspect_files(state) != checkpoint_info:
raise OSError(f"restore verification failed; previous state is at {backup}")
return backup
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
command = sub.add_parser("create")
command.add_argument("--state", type=Path, default=DEFAULT_STATE)
command.add_argument("--label", default="manual")
command.add_argument("--output", type=Path)
command = sub.add_parser("verify")
command.add_argument("checkpoint", type=Path)
command = sub.add_parser("restore")
command.add_argument("checkpoint", type=Path)
command.add_argument("--state", type=Path, default=DEFAULT_STATE)
command.add_argument("--apply", action="store_true")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
if args.command == "create":
state = args.state.resolve()
output = args.output
if output is None:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
safe_label = "-".join(args.label.strip().split()) or "manual"
output = state / "checkpoints" / f"{stamp}-{safe_label}"
output = output.resolve()
info = create(state, output, args.label)
print(f"created {output}")
print(json.dumps(info, ensure_ascii=False, indent=2))
elif args.command == "verify":
print(json.dumps(verify(args.checkpoint.resolve()), ensure_ascii=False, indent=2))
else:
backup = restore(args.state.resolve(), args.checkpoint.resolve(), args.apply)
if backup is not None:
print(f"restored {args.checkpoint.resolve()}; previous state: {backup}")
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
parser.exit(1, f"save_checkpoint: {exc}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())