- add character promotion and costume potential flows - add equipment management and enhancement - harden development mail and save tools
210 lines
7.1 KiB
Python
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())
|