Files
bd2/tools/python/rollback_quest38.py
T
2026-09-21 09:53:46 +08:00

274 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Narrowly roll the local account back to the pack21 quest-38 boundary.
This is a one-off development/recovery tool, not server code. It understands
both legacy progress saves and the v2 ``pack:quest`` schema. By default it only
prints the planned changes; pass --apply after stopping the client and server.
Before applying it copies all nine account files to a timestamped checkpoint
and verifies every SHA-256 hash.
"""
from __future__ import annotations
import argparse
import copy
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",
)
QUEST_ITEMS = {
900000033: (122, 19, 1),
900000034: (11, 19, 1),
900000035: (501, 27, 6),
900000036: (101, 27, 3),
}
ITEM_GRANT = "pack21:quest38:items"
WALLET_GRANT = "pack21:quest38"
WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_STATE = WORKSPACE_ROOT / "data" / "state"
def load_json(path: Path):
with path.open("r", encoding="utf-8") as stream:
return json.load(stream)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest().upper()
def quest_key(progress: dict, pack_id: int, quest_id: int) -> str:
version = int(progress.get("version", 0))
return f"{pack_id}:{quest_id}" if version >= 2 else str(quest_id)
def quest_cleared(progress: dict, pack_id: int, quest_id: int) -> bool:
value = progress.get("cleared_quests", {}).get(quest_key(progress, pack_id, quest_id))
if int(progress.get("version", 0)) >= 2:
return value is True
return value == pack_id
def running_game_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",
)
names = []
for line in result.stdout.splitlines():
lowered = line.lower()
if "browndust2" in lowered or "bd2server.exe" in lowered:
names.append(line)
return names
def build_changes(state: Path, reference: Path) -> dict[str, dict]:
progress = load_json(state / "progress.json")
prior_progress = load_json(reference / "progress.json")
if not quest_cleared(progress, 21, 38):
raise ValueError("current save has not cleared pack21 quest38")
if not quest_cleared(prior_progress, 21, 37) or quest_cleared(prior_progress, 21, 38):
raise ValueError("reference is not at the quest37-complete/quest38-pending boundary")
progress = copy.deepcopy(progress)
progress.get("cleared_quests", {}).pop(quest_key(progress, 21, 38), None)
progress.get("quests", {}).pop(quest_key(progress, 21, 38), None)
progress["position"] = copy.deepcopy(prior_progress["position"])
items = load_json(state / "items.json")
indices = items.get("grant_items", {}).get(ITEM_GRANT)
if (
items.get("granted", {}).get(ITEM_GRANT) is not True
or not isinstance(indices, list)
or set(map(int, indices)) != set(QUEST_ITEMS)
):
raise ValueError("quest38 item grant does not match the expected four instances")
remaining = []
removed: set[int] = set()
for item in items.get("items", []):
index = int(item.get("inven_index", 0))
if index not in QUEST_ITEMS:
remaining.append(item)
continue
expected = QUEST_ITEMS[index]
actual = (int(item.get("id", 0)), int(item.get("type", 0)), int(item.get("count", 0)))
if actual != expected:
raise ValueError(f"unexpected quest38 reward item {item!r}; expected {expected}")
removed.add(index)
if removed != set(QUEST_ITEMS):
raise ValueError(f"quest38 reward instances are incomplete: found {sorted(removed)}")
items = copy.deepcopy(items)
items["items"] = remaining
items["granted"].pop(ITEM_GRANT, None)
items["grant_items"].pop(ITEM_GRANT, None)
items["next_index"] = max(
(int(item.get("inven_index", 0)) + 1 for item in remaining), default=1
)
wallet = load_json(state / "wallet.json")
if wallet.get("granted", {}).get(WALLET_GRANT) is not True or int(wallet.get("gold", 0)) < 1500:
raise ValueError("quest38 wallet grant is absent or gold is below 1500")
wallet = copy.deepcopy(wallet)
wallet["gold"] = int(wallet["gold"]) - 1500
wallet["granted"].pop(WALLET_GRANT, None)
deck = load_json(state / "deck.json")
prior_deck = load_json(reference / "deck.json")
if "field_char_control_deck_type" not in prior_deck:
raise ValueError("reference deck has no field_char_control_deck_type")
deck = copy.deepcopy(deck)
deck["field_char_control_deck_type"] = prior_deck["field_char_control_deck_type"]
return {
"progress.json": progress,
"items.json": items,
"wallet.json": wallet,
"deck.json": deck,
}
def checkpoint_all(state: Path, target: Path) -> None:
if target.exists():
raise FileExistsError(f"checkpoint already exists: {target}")
target.mkdir(parents=True)
try:
for name in STATE_FILES:
source = state / name
if not source.is_file():
raise FileNotFoundError(f"missing state file: {source}")
shutil.copy2(source, target / name)
mismatches = [
name for name in STATE_FILES if sha256(state / name) != sha256(target / name)
]
if mismatches:
raise OSError(f"checkpoint SHA-256 mismatch: {mismatches}")
except Exception:
shutil.rmtree(target, ignore_errors=True)
raise
def atomic_write_json(path: Path, value: dict) -> None:
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 describe(changes: dict[str, dict]) -> dict:
progress = changes["progress.json"]
wallet = changes["wallet.json"]
items = changes["items.json"]
deck = changes["deck.json"]
return {
"quest37_cleared": quest_cleared(progress, 21, 37),
"quest38_cleared": quest_cleared(progress, 21, 38),
"position": progress.get("position"),
"gold": wallet.get("gold"),
"next_item_index": items.get("next_index"),
"field_char_control_deck_type": deck.get("field_char_control_deck_type"),
"files_to_modify": sorted(changes),
}
def verify_written_state(state: Path) -> dict:
progress = load_json(state / "progress.json")
items = load_json(state / "items.json")
wallet = load_json(state / "wallet.json")
deck = load_json(state / "deck.json")
if not quest_cleared(progress, 21, 37) or quest_cleared(progress, 21, 38):
raise ValueError("written progress is not at the quest38 boundary")
if ITEM_GRANT in items.get("granted", {}) or ITEM_GRANT in items.get("grant_items", {}):
raise ValueError("written inventory still contains the quest38 grant")
if any(int(item.get("inven_index", 0)) in QUEST_ITEMS for item in items.get("items", [])):
raise ValueError("written inventory still contains a quest38 reward instance")
if WALLET_GRANT in wallet.get("granted", {}):
raise ValueError("written wallet still contains the quest38 grant")
return describe(
{
"progress.json": progress,
"items.json": items,
"wallet.json": wallet,
"deck.json": deck,
}
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("state", nargs="?", default=DEFAULT_STATE, type=Path)
parser.add_argument(
"reference",
nargs="?",
default=DEFAULT_STATE / "checkpoints" / "20260920-1801-before-regular-gacha",
type=Path,
)
parser.add_argument("--apply", action="store_true", help="perform the rollback")
parser.add_argument(
"--backup",
type=Path,
help="checkpoint destination; defaults below state/checkpoints",
)
args = parser.parse_args()
state = args.state.resolve()
reference = args.reference.resolve()
try:
changes = build_changes(state, reference)
print(json.dumps(describe(changes), ensure_ascii=False, indent=2))
if not args.apply:
print("dry run only; stop client/server and pass --apply to write", file=sys.stderr)
return 0
running = running_game_processes()
if running:
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
backup = args.backup
if backup is None:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup = state / "checkpoints" / f"{stamp}-before-quest38-rollback"
backup = backup.resolve()
checkpoint_all(state, backup)
for name, value in changes.items():
atomic_write_json(state / name, value)
verified = verify_written_state(state)
print(f"rollback applied; verified checkpoint: {backup}")
print(json.dumps(verified, ensure_ascii=False, indent=2))
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
parser.exit(1, f"rollback_quest38: {exc}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())